From 21be796265e422049235fce836593aba816ca5ae Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Thu, 13 Aug 2026 13:21:19 -0400 Subject: [PATCH] Initial commit: iCloud Drive GTK3 GUI for ElementaryOS - Dashboard with live sync status, current file indicator, auto-refresh - Logs page with color-coded ERROR/WARN/INFO lines and live follow - Photos tab: download recent photos as JPEG via icloudpd - Settings with save + optional service restart - Install/uninstall scripts and .desktop entry Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + com.elementary.icloud-linux.desktop | 11 + icloud_gui.py | 1351 +++++++++++++++++++++++++++ icloud_photos_fs.py | 374 ++++++++ install.sh | 160 ++++ uninstall.sh | 35 + 6 files changed, 1935 insertions(+) create mode 100644 .gitignore create mode 100644 com.elementary.icloud-linux.desktop create mode 100644 icloud_gui.py create mode 100644 icloud_photos_fs.py create mode 100755 install.sh create mode 100755 uninstall.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9460c99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.env diff --git a/com.elementary.icloud-linux.desktop b/com.elementary.icloud-linux.desktop new file mode 100644 index 0000000..63410ef --- /dev/null +++ b/com.elementary.icloud-linux.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=iCloud Drive +Comment=Sync iCloud Drive on Linux +Exec=python3 /home/nateemmert/Documents/icloud-gui/icloud_gui.py +Icon=folder-cloud +Terminal=false +Type=Application +Categories=Network;FileManager; +Keywords=iCloud;Apple;Sync;Drive; +StartupNotify=true +StartupWMClass=icloud_gui diff --git a/icloud_gui.py b/icloud_gui.py new file mode 100644 index 0000000..6180281 --- /dev/null +++ b/icloud_gui.py @@ -0,0 +1,1351 @@ +#!/usr/bin/env python3 +""" +iCloud for Linux - ElementaryOS GUI +A Granite-inspired GTK3 frontend for icloud-linux (icloudctl). +""" + +import gi +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +from gi.repository import Gtk, Gdk, GLib, Gio, Pango + +import os +import subprocess +import threading +import shutil +import yaml +from pathlib import Path + +ICLOUDCTL = os.path.expanduser("~/icloud-linux/icloudctl") +CONFIG_PATH = os.path.expanduser("~/.config/icloud-linux/config.yaml") + +# ── Stylesheet ───────────────────────────────────────────────────────────────── +CSS = """ +@define-color accent_color #64a6e0; +@define-color accent_bg #1a6eb0; +@define-color danger #c6262e; +@define-color success #68b723; +@define-color warning #e6a020; +@define-color surface #fafafa; +@define-color sidebar_bg #f0f0f0; +@define-color border #d4d4d4; + +window { + background-color: @surface; +} + +.sidebar { + background-color: @sidebar_bg; + border-right: 1px solid @border; + min-width: 200px; +} + +.sidebar row { + padding: 8px 12px; + border-radius: 6px; + margin: 2px 8px; +} + +.sidebar row:selected { + background-color: @accent_color; + color: white; +} + +.sidebar row label { + font-size: 13px; +} + +headerbar { + background: linear-gradient(to bottom, #f7f7f7, #efefef); + border-bottom: 1px solid @border; + min-height: 48px; + padding: 0 8px; +} + +headerbar .title { + font-weight: 600; + font-size: 14px; +} + +.status-badge { + border-radius: 12px; + padding: 3px 10px; + font-size: 12px; + font-weight: 600; +} + +.status-running { + background-color: alpha(@success, 0.15); + color: @success; + border: 1px solid alpha(@success, 0.4); +} + +.status-stopped { + background-color: alpha(@danger, 0.12); + color: @danger; + border: 1px solid alpha(@danger, 0.3); +} + +.status-unknown { + background-color: alpha(#888888, 0.12); + color: #666666; + border: 1px solid alpha(#888888, 0.3); +} + +.card { + background-color: white; + border: 1px solid @border; + border-radius: 8px; + padding: 16px; +} + +.action-btn { + border-radius: 6px; + padding: 6px 16px; + font-size: 13px; + font-weight: 500; +} + +.btn-primary { + background: @accent_bg; + color: white; + border: 1px solid shade(@accent_bg, 0.85); +} + +.btn-primary:hover { + background: shade(@accent_bg, 1.1); +} + +.btn-destructive { + background: @danger; + color: white; + border: 1px solid shade(@danger, 0.85); +} + +.btn-destructive:hover { + background: shade(@danger, 1.1); +} + +.log-view { + background-color: #1e1e2e; + color: #cdd6f4; + font-family: monospace; + font-size: 12px; + padding: 12px; + border-radius: 6px; +} + +separator { + background-color: @border; +} +""" + + +def apply_css(): + provider = Gtk.CssProvider() + provider.load_from_data(CSS.encode("utf-8")) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def icloudctl(*args, input_data=None, timeout=15): + """Run icloudctl and return (returncode, stdout, stderr). Short timeout — use for status/auth only.""" + cmd = [ICLOUDCTL] + list(args) + try: + r = subprocess.run( + cmd, + capture_output=True, + text=True, + input=input_data, + timeout=timeout, + ) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + return -1, "", f"icloudctl not found at {ICLOUDCTL}" + except subprocess.TimeoutExpired: + return -1, "", "Command timed out" + except Exception as e: + return -1, "", str(e) + + +def icloudctl_stream(*args, on_line, on_done): + """ + Run icloudctl with no timeout, streaming each output line to on_line(line) + (called on the GLib main thread via idle_add), then calling on_done(rc). + Returns the Popen object so the caller can cancel it. + """ + cmd = [ICLOUDCTL] + list(args) + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + except FileNotFoundError: + GLib.idle_add(on_line, f"icloudctl not found at {ICLOUDCTL}\n") + GLib.idle_add(on_done, -1) + return None + + def _reader(): + for line in proc.stdout: + GLib.idle_add(on_line, line) + proc.wait() + GLib.idle_add(on_done, proc.returncode) + + threading.Thread(target=_reader, daemon=True).start() + return proc + + +def get_service_status(): + code, out, err = icloudctl("status") + combined = (out + err).lower() + if "active (running)" in combined or "running" in combined: + return "running" + elif "inactive" in combined or "stopped" in combined or "dead" in combined: + return "stopped" + return "unknown" + + +def load_config(): + try: + with open(CONFIG_PATH) as f: + return yaml.safe_load(f) or {} + except Exception: + return {} + + +def save_config(data): + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + with open(CONFIG_PATH, "w") as f: + yaml.dump(data, f, default_flow_style=False) + + +# ── Dashboard page ───────────────────────────────────────────────────────────── + +class DashboardPage(Gtk.Box): + def __init__(self, app): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.app = app + self.set_margin_top(24) + self.set_margin_bottom(24) + self.set_margin_start(32) + self.set_margin_end(32) + + # Status card + status_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + status_card.get_style_context().add_class("card") + status_card.set_margin_bottom(20) + + header_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + + title_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + title = Gtk.Label() + title.set_markup("iCloud Drive") + title.set_halign(Gtk.Align.START) + self.current_file_label = Gtk.Label() + self.current_file_label.set_markup('Sync service status') + self.current_file_label.set_halign(Gtk.Align.START) + self.current_file_label.set_ellipsize(Pango.EllipsizeMode.MIDDLE) + title_box.pack_start(title, False, False, 0) + title_box.pack_start(self.current_file_label, False, False, 0) + header_row.pack_start(title_box, True, True, 0) + + self.status_badge = Gtk.Label(label="Checking...") + self.status_badge.get_style_context().add_class("status-badge") + self.status_badge.get_style_context().add_class("status-unknown") + header_row.pack_end(self.status_badge, False, False, 0) + + status_card.pack_start(header_row, False, False, 0) + status_card.pack_start(Gtk.Separator(), False, False, 0) + + btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.btn_start = self._make_btn("Start", "btn-primary", self._on_start) + self.btn_stop = self._make_btn("Stop", "btn-destructive", self._on_stop) + self.btn_restart = self._make_btn("Restart", "action-btn", self._on_restart) + self.btn_sync = self._make_btn("Sync Now","action-btn", self._on_sync) + for b in (self.btn_start, self.btn_stop, self.btn_restart, self.btn_sync): + btn_row.pack_start(b, False, False, 0) + + self.spinner = Gtk.Spinner() + btn_row.pack_end(self.spinner, False, False, 0) + + self.btn_cancel = Gtk.Button(label="Cancel") + self.btn_cancel.get_style_context().add_class("action-btn") + self.btn_cancel.connect("clicked", self._on_cancel) + self.btn_cancel.set_no_show_all(True) + btn_row.pack_end(self.btn_cancel, False, False, 0) + + status_card.pack_start(btn_row, False, False, 0) + self.pack_start(status_card, False, False, 0) + self._current_proc = None + + # Quick actions card + quick_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + quick_card.get_style_context().add_class("card") + quick_card.set_margin_bottom(20) + + ql = Gtk.Label() + ql.set_markup('QUICK ACTIONS') + ql.set_halign(Gtk.Align.START) + quick_card.pack_start(ql, False, False, 0) + + actions = [ + ("Hydrate Files", "Download pending file contents", self._on_hydrate), + ("Refresh Metadata", "Pull latest file list from iCloud", self._on_refresh), + ("Clear Cache", "Reset local mirror and database", self._on_clear_cache), + ("Run Doctor", "Diagnose setup issues", self._on_doctor), + ] + for label, tip, cb in actions: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.set_margin_top(2) + lbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + l1 = Gtk.Label() + l1.set_markup(f"{GLib.markup_escape_text(label)}") + l1.set_halign(Gtk.Align.START) + l2 = Gtk.Label() + l2.set_markup(f'{GLib.markup_escape_text(tip)}') + l2.set_halign(Gtk.Align.START) + lbox.pack_start(l1, False, False, 0) + lbox.pack_start(l2, False, False, 0) + row.pack_start(lbox, True, True, 0) + btn = Gtk.Button(label="Run") + btn.get_style_context().add_class("action-btn") + btn.connect("clicked", cb) + row.pack_end(btn, False, False, 0) + quick_card.pack_start(row, False, False, 0) + + self.pack_start(quick_card, False, False, 0) + + # Output area + out_label = Gtk.Label() + out_label.set_markup('OUTPUT') + out_label.set_halign(Gtk.Align.START) + out_label.set_margin_bottom(6) + self.pack_start(out_label, False, False, 0) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scroll.set_vexpand(True) + self.output_buf = Gtk.TextBuffer() + tv = Gtk.TextView(buffer=self.output_buf) + tv.set_editable(False) + tv.set_monospace(True) + tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + tv.get_style_context().add_class("log-view") + scroll.add(tv) + self.pack_start(scroll, True, True, 0) + + GLib.timeout_add(500, self._refresh_status_once) + GLib.timeout_add(10000, self._periodic_refresh) + + def _make_btn(self, label, css_class, cb): + b = Gtk.Button(label=label) + b.get_style_context().add_class("action-btn") + b.get_style_context().add_class(css_class) + b.connect("clicked", cb) + return b + + def _set_status(self, status): + ctx = self.status_badge.get_style_context() + for cls in ("status-running", "status-stopped", "status-unknown"): + ctx.remove_class(cls) + if status == "running": + ctx.add_class("status-running") + self.status_badge.set_text("Running") + elif status == "stopped": + ctx.add_class("status-stopped") + self.status_badge.set_text("Stopped") + else: + ctx.add_class("status-unknown") + self.status_badge.set_text("Unknown") + + def _refresh_status_once(self): + self._do_refresh_status() + return False + + def _periodic_refresh(self): + self._do_refresh_status() + return True + + def _do_refresh_status(self): + def _worker(): + s = get_service_status() + GLib.idle_add(self._set_status, s) + threading.Thread(target=_worker, daemon=True).start() + + def _run_cmd(self, *args, confirm_msg=None): + if confirm_msg: + dlg = Gtk.MessageDialog( + transient_for=self.get_toplevel(), + modal=True, + message_type=Gtk.MessageType.WARNING, + buttons=Gtk.ButtonsType.OK_CANCEL, + text=confirm_msg, + ) + resp = dlg.run() + dlg.destroy() + if resp != Gtk.ResponseType.OK: + return + + # Cancel any running command first + self._on_cancel(None) + + self.spinner.start() + self.btn_cancel.show() + self.output_buf.set_text("") + + def _on_line(line): + end = self.output_buf.get_end_iter() + # Highlight the current file being synced + stripped = line.strip() + if "hydrate-start" in stripped or "file-sync-start" in stripped or "Downloading" in stripped: + for part in stripped.split("path='"): + if "'" in part: + path = part.split("'")[0] + GLib.idle_add(self._set_current_file, path) + break + self.output_buf.insert(end, line) + + def _on_done(rc): + self._current_proc = None + self.spinner.stop() + self.btn_cancel.hide() + self.current_file_label.set_markup('Sync service status') + if rc != 0 and rc is not None: + end = self.output_buf.get_end_iter() + self.output_buf.insert(end, f"\n[exited with code {rc}]") + self._do_refresh_status() + + self._current_proc = icloudctl_stream(*args, on_line=_on_line, on_done=_on_done) + + def _set_current_file(self, path): + self.current_file_label.set_markup( + f'Syncing: {GLib.markup_escape_text(path)}' + ) + + def _on_cancel(self, _): + if self._current_proc and self._current_proc.poll() is None: + self._current_proc.terminate() + self._current_proc = None + self.spinner.stop() + self.btn_cancel.hide() + self.current_file_label.set_markup('Sync service status') + + def _on_start(self, _): self._run_cmd("start") + def _on_stop(self, _): self._run_cmd("stop") + def _on_restart(self, _): self._run_cmd("restart") + def _on_sync(self, _): self._run_cmd("sync") + def _on_hydrate(self, _): self._run_cmd("hydrate") + def _on_refresh(self, _): self._run_cmd("refresh") + def _on_doctor(self, _): self._run_cmd("doctor") + def _on_clear_cache(self, _): + self._run_cmd( + "clear-cache", + confirm_msg="Clear all cached data? The service will need to re-index iCloud Drive.", + ) + + +# ── Logs page ────────────────────────────────────────────────────────────────── + +# Detect the systemd unit name icloudctl uses (user service) +_JOURNAL_UNITS = ["icloud-linux.service", "icloud.service"] + +def _journal_cmd(n_lines=300): + """Return a journalctl command that reads recent user-service logs, no timeout.""" + for unit in _JOURNAL_UNITS: + # Check if this unit has any journal entries + r = subprocess.run( + ["journalctl", "--user", "-u", unit, "-n", "1", "--no-pager"], + capture_output=True, text=True + ) + if r.returncode == 0 and r.stdout.strip(): + return ["journalctl", "--user", "-u", unit, f"-n{n_lines}", "--no-pager", "--output=short"] + # Fall back to reading icloudctl logs output with a long timeout via Popen + return [ICLOUDCTL, "logs"] + + +class LogsPage(Gtk.Box): + def __init__(self, app): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.app = app + self.set_margin_top(24) + self.set_margin_bottom(24) + self.set_margin_start(32) + self.set_margin_end(32) + + self._follow_proc = None + + toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + toolbar.set_margin_bottom(12) + + title = Gtk.Label() + title.set_markup("Service Logs") + title.set_halign(Gtk.Align.START) + toolbar.pack_start(title, True, True, 0) + + self.follow_toggle = Gtk.ToggleButton(label="Live follow") + self.follow_toggle.set_active(True) + self.follow_toggle.connect("toggled", self._on_follow_toggled) + toolbar.pack_end(self.follow_toggle, False, False, 0) + + refresh_btn = Gtk.Button(label="Reload") + refresh_btn.get_style_context().add_class("action-btn") + refresh_btn.connect("clicked", self._reload) + toolbar.pack_end(refresh_btn, False, False, 0) + + self.pack_start(toolbar, False, False, 0) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scroll.set_vexpand(True) + self.log_buf = Gtk.TextBuffer() + self._add_tags() + self.tv = Gtk.TextView(buffer=self.log_buf) + self.tv.set_editable(False) + self.tv.set_monospace(True) + self.tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self.tv.get_style_context().add_class("log-view") + scroll.add(self.tv) + self.pack_start(scroll, True, True, 0) + + GLib.timeout_add(400, self._initial_load) + + def _add_tags(self): + self.log_buf.create_tag("error", foreground="#f38ba8") + self.log_buf.create_tag("warn", foreground="#fab387") + self.log_buf.create_tag("info", foreground="#a6e3a1") + self.log_buf.create_tag("dim", foreground="#6c7086") + + def _initial_load(self): + self._reload() + return False + + def _on_follow_toggled(self, toggle): + if toggle.get_active(): + self._start_follow() + else: + self._stop_follow() + + def _reload(self, *_): + """Load recent log snapshot, then start live follow if toggled on.""" + self._stop_follow() + self.log_buf.set_text("") + + cmd = _journal_cmd(300) + + def _worker(): + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + GLib.idle_add(self._append_text, r.stdout + r.stderr) + except subprocess.TimeoutExpired: + GLib.idle_add(self._append_text, "(log read timed out — try Reload)\n") + except Exception as e: + GLib.idle_add(self._append_text, f"(error reading logs: {e})\n") + if self.follow_toggle.get_active(): + GLib.idle_add(self._start_follow) + + threading.Thread(target=_worker, daemon=True).start() + + def _start_follow(self): + self._stop_follow() + # Build a follow command: journalctl -f or icloudctl logs + base = _journal_cmd(0) + if base[0] == "journalctl": + cmd = base[:4] + ["-f", "--output=short"] # replace -nN with -f + else: + cmd = base # icloudctl logs (best we can do) + + try: + self._follow_proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1 + ) + except Exception: + return + + proc = self._follow_proc + + def _reader(): + for line in proc.stdout: + if proc.poll() is not None: + break + GLib.idle_add(self._append_line, line) + + threading.Thread(target=_reader, daemon=True).start() + + def _stop_follow(self): + if self._follow_proc and self._follow_proc.poll() is None: + self._follow_proc.terminate() + self._follow_proc = None + + def resume_after_sleep(self): + """Called on system wake — restart the follow stream.""" + self._reload() + + def _append_text(self, text): + end = self.log_buf.get_end_iter() + for line in text.splitlines(): + self._insert_colored_line(end, line) + self._scroll_to_bottom() + + def _append_line(self, line): + end = self.log_buf.get_end_iter() + self._insert_colored_line(end, line.rstrip()) + self._scroll_to_bottom() + + def _insert_colored_line(self, end, line): + ll = line.lower() + if "error" in ll or "fail" in ll or "critical" in ll: + tag = "error" + elif "warn" in ll: + tag = "warn" + elif "info" in ll or "start" in ll or "running" in ll: + tag = "info" + else: + tag = "dim" + self.log_buf.insert_with_tags_by_name(end, line + "\n", tag) + + def _scroll_to_bottom(self): + mark = self.log_buf.get_insert() + self.log_buf.place_cursor(self.log_buf.get_end_iter()) + self.tv.scroll_mark_onscreen(self.log_buf.get_insert()) + + +# ── Settings page ────────────────────────────────────────────────────────────── + +class SettingsPage(Gtk.Box): + def __init__(self, app): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.app = app + self.set_margin_top(24) + self.set_margin_bottom(24) + self.set_margin_start(32) + self.set_margin_end(32) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_vexpand(True) + inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) + scroll.add(inner) + + # Account + inner.pack_start(self._section("ACCOUNT"), False, False, 0) + acct_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + acct_card.get_style_context().add_class("card") + self.entry_user = self._entry_row(acct_card, "Apple ID", "your@icloud.com", False) + acct_card.pack_start(Gtk.Separator(), False, False, 0) + self.entry_pass = self._entry_row(acct_card, "Password", "Your Apple ID password", True) + inner.pack_start(acct_card, False, False, 0) + + # Storage + inner.pack_start(self._section("STORAGE"), False, False, 0) + stor_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + stor_card.get_style_context().add_class("card") + self.entry_cache = self._entry_row(stor_card, "Cache directory", "~/.cache/icloud-linux", False) + stor_card.pack_start(Gtk.Separator(), False, False, 0) + self.entry_mount = self._entry_row(stor_card, "Mount point", "~/iCloud", False) + inner.pack_start(stor_card, False, False, 0) + + # Sync + inner.pack_start(self._section("SYNC"), False, False, 0) + sync_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + sync_card.get_style_context().add_class("card") + self.switch_autosync = self._switch_row(sync_card, "Auto-sync", "Poll iCloud automatically") + sync_card.pack_start(Gtk.Separator(), False, False, 0) + self.entry_upload_interval = self._entry_row(sync_card, "Upload interval (s)", "30", False) + sync_card.pack_start(Gtk.Separator(), False, False, 0) + self.entry_refresh_interval = self._entry_row(sync_card, "Refresh interval (s)", "300", False) + sync_card.pack_start(Gtk.Separator(), False, False, 0) + + workers_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + workers_row.set_margin_start(12) + workers_row.set_margin_end(12) + workers_row.set_margin_top(10) + workers_row.set_margin_bottom(10) + wbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + wl1 = Gtk.Label(label="Warmup workers") + wl1.set_halign(Gtk.Align.START) + wl2 = Gtk.Label() + wl2.set_markup('Parallel download threads (1 = safe, 3-4 = faster)') + wl2.set_halign(Gtk.Align.START) + wbox.pack_start(wl1, False, False, 0) + wbox.pack_start(wl2, False, False, 0) + workers_row.pack_start(wbox, True, True, 0) + self.spin_workers = Gtk.SpinButton.new_with_range(1, 8, 1) + self.spin_workers.set_value(3) + self.spin_workers.set_valign(Gtk.Align.CENTER) + workers_row.pack_end(self.spin_workers, False, False, 0) + sync_card.pack_start(workers_row, False, False, 0) + sync_card.pack_start(Gtk.Separator(), False, False, 0) + + warmup_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + warmup_row.set_margin_start(12) + warmup_row.set_margin_end(12) + warmup_row.set_margin_top(10) + warmup_row.set_margin_bottom(10) + warmup_row.pack_start(Gtk.Label(label="Warmup mode"), True, True, 0) + self.warmup_combo = Gtk.ComboBoxText() + for opt in ("background", "lazy", "none"): + self.warmup_combo.append_text(opt) + self.warmup_combo.set_active(0) + warmup_row.pack_end(self.warmup_combo, False, False, 0) + sync_card.pack_start(warmup_row, False, False, 0) + inner.pack_start(sync_card, False, False, 0) + + # Save + btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + save_btn = Gtk.Button(label="Save Settings") + save_btn.get_style_context().add_class("action-btn") + save_btn.get_style_context().add_class("btn-primary") + save_btn.connect("clicked", self._save) + btn_row.pack_end(save_btn, False, False, 0) + inner.pack_start(btn_row, False, False, 0) + + self.pack_start(scroll, True, True, 0) + self._load() + + def _section(self, label): + l = Gtk.Label() + l.set_markup(f'{label}') + l.set_halign(Gtk.Align.START) + l.set_margin_bottom(4) + return l + + def _entry_row(self, parent, label, placeholder, is_password): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.set_margin_start(12) + row.set_margin_end(12) + row.set_margin_top(10) + row.set_margin_bottom(10) + lbl = Gtk.Label(label=label) + lbl.set_halign(Gtk.Align.START) + lbl.set_size_request(200, -1) + row.pack_start(lbl, False, False, 0) + entry = Gtk.Entry() + entry.set_placeholder_text(placeholder) + entry.set_hexpand(True) + if is_password: + entry.set_visibility(False) + entry.set_input_purpose(Gtk.InputPurpose.PASSWORD) + row.pack_end(entry, True, True, 0) + parent.pack_start(row, False, False, 0) + return entry + + def _switch_row(self, parent, label, subtitle): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.set_margin_start(12) + row.set_margin_end(12) + row.set_margin_top(10) + row.set_margin_bottom(10) + lbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + l1 = Gtk.Label(label=label) + l1.set_halign(Gtk.Align.START) + l2 = Gtk.Label() + l2.set_markup(f'{GLib.markup_escape_text(subtitle)}') + l2.set_halign(Gtk.Align.START) + lbox.pack_start(l1, False, False, 0) + lbox.pack_start(l2, False, False, 0) + row.pack_start(lbox, True, True, 0) + sw = Gtk.Switch() + sw.set_valign(Gtk.Align.CENTER) + sw.set_active(True) + row.pack_end(sw, False, False, 0) + parent.pack_start(row, False, False, 0) + return sw + + def _load(self): + cfg = load_config() + self.entry_user.set_text(cfg.get("username", "")) + self.entry_pass.set_text(cfg.get("password", "")) + self.entry_cache.set_text(cfg.get("cache_dir", "~/.cache/icloud-linux")) + self.entry_mount.set_text(cfg.get("mount_point", "~/iCloud")) + self.switch_autosync.set_active(cfg.get("auto_sync", True)) + self.entry_upload_interval.set_text(str(cfg.get("upload_interval_seconds", cfg.get("upload_interval", 30)))) + self.entry_refresh_interval.set_text(str(cfg.get("remote_refresh_interval_seconds", cfg.get("remote_refresh_interval", 300)))) + self.spin_workers.set_value(cfg.get("warmup_workers", 1)) + warmup = cfg.get("warmup_mode", "background") + model = self.warmup_combo.get_model() + for i, row in enumerate(model): + if row[0] == warmup: + self.warmup_combo.set_active(i) + break + + def _save(self, _): + cfg = load_config() + cfg["username"] = self.entry_user.get_text().strip() + cfg["password"] = self.entry_pass.get_text() + cfg["cache_dir"] = self.entry_cache.get_text().strip() + cfg["mount_point"] = self.entry_mount.get_text().strip() + cfg["auto_sync"] = self.switch_autosync.get_active() + try: + cfg["upload_interval_seconds"] = int(self.entry_upload_interval.get_text()) + except ValueError: + pass + try: + cfg["remote_refresh_interval_seconds"] = int(self.entry_refresh_interval.get_text()) + except ValueError: + pass + cfg["warmup_workers"] = int(self.spin_workers.get_value()) + cfg["warmup_mode"] = self.warmup_combo.get_active_text() + save_config(cfg) + + dlg = Gtk.MessageDialog( + transient_for=self.get_toplevel(), + modal=True, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text="Settings saved.", + ) + dlg.format_secondary_text("Restart iCloud Drive service now to apply changes?") + resp = dlg.run() + dlg.destroy() + if resp == Gtk.ResponseType.YES: + threading.Thread( + target=lambda: subprocess.run([ICLOUDCTL, "restart"], capture_output=True), + daemon=True + ).start() + + +# ── Photos page ─────────────────────────────────────────────────────────────── + +PHOTOS_MOUNT = os.path.expanduser("~/Pictures/iCloud Photos") +VENV_PYTHON = os.path.expanduser("~/icloud-linux/icloud-linux/.venv/bin/python") + +class PhotosPage(Gtk.Box): + def __init__(self, app): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.app = app + self.set_margin_top(24) + self.set_margin_bottom(24) + self.set_margin_start(32) + self.set_margin_end(32) + + self._dl_proc = None + + # ── Status card ── + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + card.get_style_context().add_class("card") + card.set_margin_bottom(20) + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + title_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + title = Gtk.Label() + title.set_markup("iCloud Photos") + title.set_halign(Gtk.Align.START) + sub = Gtk.Label() + sub.set_markup(f'Downloads to {PHOTOS_MOUNT}') + sub.set_halign(Gtk.Align.START) + title_box.pack_start(title, False, False, 0) + title_box.pack_start(sub, False, False, 0) + row.pack_start(title_box, True, True, 0) + + self.status_badge = Gtk.Label(label="No photos yet") + self.status_badge.get_style_context().add_class("status-badge") + self.status_badge.get_style_context().add_class("status-stopped") + row.pack_end(self.status_badge, False, False, 0) + card.pack_start(row, False, False, 0) + + card.pack_start(Gtk.Separator(), False, False, 0) + + info = Gtk.Label() + info.set_markup( + 'Downloads your most recent photos to ~/Pictures/iCloud Photos\n' + 'so the Photos app can display them natively.' + ) + info.set_halign(Gtk.Align.START) + info.set_line_wrap(True) + card.pack_start(info, False, False, 0) + + card.pack_start(Gtk.Separator(), False, False, 0) + + btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.btn_sync = self._btn("Sync Recent (200)", "btn-primary", self._on_sync) + self.btn_more = self._btn("Sync More (1000)", "action-btn", self._on_sync_more) + self.btn_stop = self._btn("Stop", "btn-destructive", self._on_stop) + self.btn_open = self._btn("Open Photos App", "action-btn", self._on_open_photos) + self.btn_folder = self._btn("Open Folder", "action-btn", self._on_open_folder) + self.btn_stop.set_sensitive(False) + for b in (self.btn_sync, self.btn_more, self.btn_stop, self.btn_open, self.btn_folder): + btn_row.pack_start(b, False, False, 0) + self.spinner = Gtk.Spinner() + btn_row.pack_end(self.spinner, False, False, 0) + card.pack_start(btn_row, False, False, 0) + self.pack_start(card, False, False, 0) + + # ── Progress / info card ── + info_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + info_card.get_style_context().add_class("card") + info_card.set_margin_bottom(20) + + self.progress_label = Gtk.Label(label="") + self.progress_label.set_halign(Gtk.Align.START) + self.progress_bar = Gtk.ProgressBar() + self.progress_bar.set_pulse_step(0.05) + self.progress_bar.set_no_show_all(True) + self.photo_count = Gtk.Label(label="") + self.photo_count.set_halign(Gtk.Align.START) + self.last_sync_label = Gtk.Label(label="") + self.last_sync_label.set_halign(Gtk.Align.START) + self.cache_label = Gtk.Label(label="") + self.cache_label.set_halign(Gtk.Align.START) + + info_card.pack_start(self.progress_label, False, False, 0) + info_card.pack_start(self.progress_bar, False, False, 0) + info_card.pack_start(self.photo_count, False, False, 0) + info_card.pack_start(self.last_sync_label, False, False, 0) + info_card.pack_start(self.cache_label, False, False, 0) + self.pack_start(info_card, False, False, 0) + + # ── Cache management ── + cache_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + cache_card.get_style_context().add_class("card") + + cl = Gtk.Label() + cl.set_markup('CACHE') + cl.set_halign(Gtk.Align.START) + cache_card.pack_start(cl, False, False, 0) + + crow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.cache_size_label = Gtk.Label(label="Downloaded: calculating…") + self.cache_size_label.set_halign(Gtk.Align.START) + crow.pack_start(self.cache_size_label, True, True, 0) + clr_btn = self._btn("Clear cache", "action-btn", self._on_clear_cache) + crow.pack_end(clr_btn, False, False, 0) + cache_card.pack_start(crow, False, False, 0) + self.pack_start(cache_card, False, False, 0) + + GLib.timeout_add(800, self._init) + GLib.timeout_add(30000, self._refresh_cache_size) + + def _btn(self, label, css, cb): + b = Gtk.Button(label=label) + b.get_style_context().add_class("action-btn") + b.get_style_context().add_class(css) + b.connect("clicked", cb) + return b + + def _init(self): + self._refresh_status() + self._refresh_cache_size() + return False + + def _refresh_status(self): + photos_dir = Path(PHOTOS_MOUNT) + count = 0 + if photos_dir.exists(): + count = sum(1 for _ in photos_dir.rglob("*") if _.suffix.lower() in + (".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif")) + if count: + self.status_badge.set_text(f"{count} photos") + ctx = self.status_badge.get_style_context() + ctx.remove_class("status-stopped") + ctx.add_class("status-running") + else: + self.status_badge.set_text("No photos yet") + ctx = self.status_badge.get_style_context() + ctx.remove_class("status-running") + ctx.add_class("status-stopped") + return count + + def _run_icloudpd(self, recent=None): + cfg = load_config() + username = cfg.get("username", "") + password = cfg.get("password", "") + if not username or not password: + self.progress_label.set_markup( + 'Set your Apple ID and password in Settings first.' + ) + return + + os.makedirs(PHOTOS_MOUNT, exist_ok=True) + self.spinner.start() + self.progress_bar.show() + self.btn_sync.set_sensitive(False) + self.btn_more.set_sensitive(False) + self.btn_stop.set_sensitive(True) + self._pulse_id = GLib.timeout_add(100, self._pulse) + label = f"Downloading {recent} most recent photos…" if recent else "Downloading all photos…" + self.progress_label.set_text(label) + + cookie_dir = cfg.get("cookie_dir", os.path.expanduser("~/.config/icloud-linux/cookies")) + icloudpd = os.path.join(os.path.dirname(VENV_PYTHON), "icloudpd") + cmd = [ + icloudpd, + "--username", username, + "--password", password, + "--cookie-directory", cookie_dir, + "--directory", PHOTOS_MOUNT, + "--convert-all-heic-to-jpeg", + "--no-progress-bar", + ] + if recent: + cmd += ["--recent", str(recent)] + + try: + self._dl_proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1 + ) + except Exception as e: + self._stop_spinner() + self.progress_label.set_markup(f'Failed: {e}') + return + + proc = self._dl_proc + downloaded = [0] + + def _reader(): + for line in proc.stdout: + line = line.strip() + # icloudpd prints: "INFO Downloaded /path/to/file" + if " INFO " in line and "Downloaded " in line and "All photos" not in line: + downloaded[0] += 1 + GLib.idle_add(self._on_dl_progress, downloaded[0]) + proc.wait() + GLib.idle_add(self._on_dl_done, proc.returncode, downloaded[0]) + + threading.Thread(target=_reader, daemon=True).start() + + def _on_sync(self, _): + self._run_icloudpd(recent=200) + + def _on_sync_more(self, _): + self._run_icloudpd(recent=1000) + + def _on_stop(self, _): + if hasattr(self, "_dl_proc") and self._dl_proc and self._dl_proc.poll() is None: + self._dl_proc.terminate() + self._dl_proc = None + self._stop_spinner() + self.progress_label.set_text("Stopped.") + + def _pulse(self): + self.progress_bar.pulse() + return True + + def _stop_spinner(self): + self.spinner.stop() + self.progress_bar.hide() + self.btn_sync.set_sensitive(True) + self.btn_more.set_sensitive(True) + self.btn_stop.set_sensitive(False) + if hasattr(self, "_pulse_id"): + GLib.source_remove(self._pulse_id) + del self._pulse_id + + def _on_dl_progress(self, n): + self.progress_label.set_text(f"Downloading… {n} photos so far") + self.photo_count.set_markup(f'{n} photos downloaded') + + def _on_dl_done(self, rc, n): + self._stop_spinner() + self._refresh_cache_size() + if rc == 0: + self.photo_count.set_markup(f'{n} photos downloaded') + self.progress_label.set_text("Done — open the Photos app to browse.") + import datetime + ts = datetime.datetime.now().strftime("%b %d %I:%M %p") + self.last_sync_label.set_markup(f'Last synced: {ts}') + self._refresh_status() + else: + self.progress_label.set_markup( + f'Download failed (exit {rc}). Check logs.' + ) + + def _on_open_photos(self, _): + subprocess.Popen(["xdg-open", "photos://"], stderr=subprocess.DEVNULL) + + def _on_open_folder(self, _): + os.makedirs(PHOTOS_MOUNT, exist_ok=True) + subprocess.Popen(["xdg-open", PHOTOS_MOUNT], stderr=subprocess.DEVNULL) + + def _on_clear_cache(self, _): + dlg = Gtk.MessageDialog( + transient_for=self.get_toplevel(), + modal=True, + message_type=Gtk.MessageType.WARNING, + buttons=Gtk.ButtonsType.OK_CANCEL, + text="Delete all downloaded photos?", + ) + dlg.format_secondary_text( + f"This permanently removes all files in:\n{PHOTOS_MOUNT}\n\nThis cannot be undone." + ) + resp = dlg.run() + dlg.destroy() + if resp != Gtk.ResponseType.OK: + return + if Path(PHOTOS_MOUNT).exists(): + shutil.rmtree(PHOTOS_MOUNT, ignore_errors=True) + self.cache_size_label.set_text("Downloaded: 0 MB") + self.photo_count.set_text("") + self.last_sync_label.set_text("") + self._refresh_status() + + def _refresh_cache_size(self): + def _worker(): + d = Path(PHOTOS_MOUNT) + if d.exists(): + total = sum(f.stat().st_size for f in d.rglob("*") if f.is_file()) + txt = f"Downloaded: {total / 1024 / 1024:.1f} MB" + else: + txt = "Downloaded: 0 MB" + GLib.idle_add(self.cache_size_label.set_text, txt) + threading.Thread(target=_worker, daemon=True).start() + return True + + def resume_after_sleep(self): + self._refresh_status() + + +# ── Auth page ────────────────────────────────────────────────────────────────── + +class AuthPage(Gtk.Box): + def __init__(self, app): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=20) + self.app = app + self.set_margin_top(40) + self.set_margin_bottom(40) + self.set_margin_start(60) + self.set_margin_end(60) + self.set_valign(Gtk.Align.CENTER) + + title = Gtk.Label() + title.set_markup('Authenticate with Apple') + self.pack_start(title, False, False, 0) + + subtitle = Gtk.Label( + label="Sign in with your Apple ID to start syncing iCloud Drive.\n" + "Two-factor authentication is supported." + ) + subtitle.set_justify(Gtk.Justification.CENTER) + self.pack_start(subtitle, False, False, 0) + + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + card.get_style_context().add_class("card") + card.set_margin_top(8) + + self.status_label = Gtk.Label(label="") + self.status_label.set_halign(Gtk.Align.START) + + btn_auth = Gtk.Button(label="Sign in with Apple ID") + btn_auth.get_style_context().add_class("action-btn") + btn_auth.get_style_context().add_class("btn-primary") + btn_auth.set_hexpand(True) + btn_auth.connect("clicked", self._on_auth) + + self.sms_check = Gtk.CheckButton(label="Force SMS verification") + self.debug_check = Gtk.CheckButton(label="Debug mode") + + card.pack_start(btn_auth, False, False, 0) + card.pack_start(self.sms_check, False, False, 0) + card.pack_start(self.debug_check, False, False, 0) + card.pack_start(Gtk.Separator(), False, False, 0) + card.pack_start(self.status_label, False, False, 0) + self.pack_start(card, False, False, 0) + + # 2FA section (hidden until needed) + self.tfa_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.tfa_box.get_style_context().add_class("card") + self.tfa_box.set_no_show_all(True) + + tfa_lbl = Gtk.Label() + tfa_lbl.set_markup("Two-Factor Code") + tfa_lbl.set_halign(Gtk.Align.START) + self.tfa_entry = Gtk.Entry() + self.tfa_entry.set_placeholder_text("Enter 6-digit code from your device") + self.tfa_entry.set_max_length(6) + self.tfa_entry.set_input_purpose(Gtk.InputPurpose.DIGITS) + btn_tfa = Gtk.Button(label="Verify Code") + btn_tfa.get_style_context().add_class("action-btn") + btn_tfa.get_style_context().add_class("btn-primary") + btn_tfa.connect("clicked", self._on_tfa) + + self.tfa_box.pack_start(tfa_lbl, False, False, 0) + self.tfa_box.pack_start(self.tfa_entry, False, False, 0) + self.tfa_box.pack_start(btn_tfa, False, False, 0) + self.pack_start(self.tfa_box, False, False, 0) + + def _on_auth(self, _): + args = ["auth"] + if self.sms_check.get_active(): + args.append("--force-sms") + if self.debug_check.get_active(): + args.append("--debug") + self.status_label.set_markup('Connecting to Apple...') + + def _worker(): + rc, out, err = icloudctl(*args) + GLib.idle_add(self._auth_result, rc, out + err) + threading.Thread(target=_worker, daemon=True).start() + + def _auth_result(self, rc, output): + if "two-factor" in output.lower() or "verification" in output.lower(): + self.status_label.set_markup( + '2FA required - enter code from your Apple device.' + ) + self.tfa_box.show() + elif rc == 0: + self.status_label.set_markup('Authentication successful!') + else: + msg = GLib.markup_escape_text(output[:200]) + self.status_label.set_markup(f'Error: {msg}') + + def _on_tfa(self, _): + code = self.tfa_entry.get_text().strip() + if not code: + return + + def _worker(): + rc, out, err = icloudctl("auth", input_data=code + "\n") + GLib.idle_add(self._tfa_result, rc, out + err) + threading.Thread(target=_worker, daemon=True).start() + + def _tfa_result(self, rc, output): + if rc == 0: + self.status_label.set_markup( + 'Verification complete! You can now start the service.' + ) + self.tfa_box.hide() + else: + self.status_label.set_markup( + 'Verification failed. Try again.' + ) + + +# ── Main window ──────────────────────────────────────────────────────────────── + +PAGES = [ + ("Dashboard", "Home"), + ("Photos", "Photos"), + ("Logs", "Logs"), + ("Settings", "Settings"), + ("Auth", "Sign In"), +] + + +class ICloudWindow(Gtk.ApplicationWindow): + def __init__(self, app): + super().__init__(application=app) + self.set_title("iCloud Drive") + self.set_default_size(860, 600) + + hbar = Gtk.HeaderBar() + hbar.set_show_close_button(True) + hbar.set_title("iCloud Drive") + hbar.set_subtitle("Linux Sync Client") + self.set_titlebar(hbar) + + root = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.add(root) + + # Sidebar + sidebar_scroll = Gtk.ScrolledWindow() + sidebar_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + sidebar_scroll.set_size_request(190, -1) + + sidebar_list = Gtk.ListBox() + sidebar_list.set_selection_mode(Gtk.SelectionMode.SINGLE) + sidebar_list.get_style_context().add_class("sidebar") + sidebar_list.connect("row-selected", self._on_sidebar_select) + sidebar_scroll.get_style_context().add_class("sidebar") + + for name, display in PAGES: + row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + row_box.set_margin_top(2) + row_box.set_margin_bottom(2) + name_lbl = Gtk.Label(label=display) + name_lbl.set_halign(Gtk.Align.START) + row_box.pack_start(name_lbl, True, True, 0) + sidebar_list.add(row_box) + + sidebar_scroll.add(sidebar_list) + root.pack_start(sidebar_scroll, False, False, 0) + root.pack_start(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL), False, False, 0) + + # Stack + self.stack = Gtk.Stack() + self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self.stack.set_transition_duration(150) + + self._pages = { + "Dashboard": DashboardPage(self), + "Photos": PhotosPage(self), + "Logs": LogsPage(self), + "Settings": SettingsPage(self), + "Auth": AuthPage(self), + } + for name, page in self._pages.items(): + self.stack.add_named(page, name) + + root.pack_start(self.stack, True, True, 0) + sidebar_list.select_row(sidebar_list.get_row_at_index(0)) + self.show_all() + self._subscribe_sleep_signal() + + def _on_sidebar_select(self, listbox, row): + if row is None: + return + name = list(self._pages.keys())[row.get_index()] + self.stack.set_visible_child_name(name) + + def _subscribe_sleep_signal(self): + """Listen for system suspend/resume via logind D-Bus signal.""" + try: + bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) + bus.signal_subscribe( + "org.freedesktop.login1", + "org.freedesktop.login1.Manager", + "PrepareForSleep", + "/org/freedesktop/login1", + None, + Gio.DBusSignalFlags.NONE, + self._on_prepare_for_sleep, + None, + ) + except Exception: + pass # D-Bus unavailable — not critical + + def _on_prepare_for_sleep(self, connection, sender, path, iface, signal, params, user_data): + sleeping = params[0] + if sleeping: + return # going to sleep — nothing to do + # Waking up: give the network a moment, then recover + GLib.timeout_add_seconds(4, self._on_wake) + + def _on_wake(self): + dashboard = self._pages.get("Dashboard") + logs = self._pages.get("Logs") + photos = self._pages.get("Photos") + if dashboard: + dashboard._do_refresh_status() + if logs: + logs.resume_after_sleep() + if photos: + photos.resume_after_sleep() + return False # run once + + +class ICloudApp(Gtk.Application): + def __init__(self): + super().__init__(application_id="com.elementary.icloud-linux") + + def do_activate(self): + apply_css() + win = ICloudWindow(self) + win.present() + + +def main(): + app = ICloudApp() + app.run(None) + + +if __name__ == "__main__": + main() diff --git a/icloud_photos_fs.py b/icloud_photos_fs.py new file mode 100644 index 0000000..45f8350 --- /dev/null +++ b/icloud_photos_fs.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +""" +iCloud Photos FUSE filesystem — uses fuse-python (same library as icloud-linux). + +Directory layout mirrors icloudpd output: /YYYY/MM/DD/filename.HEIC +- Listing: instant (index from icloudpd --only-print-filenames) +- Open: downloads on demand via icloudpd, cached in ~/.cache/icloud-photos/ +""" + +import os +import sys +import stat +import errno +import time +import threading +import subprocess +import logging +from pathlib import Path +from collections import defaultdict + +import fuse +from fuse import Fuse + +if not hasattr(fuse, "__version__"): + fuse.__version__ = "0.2" +fuse.fuse_python_api = (0, 2) + +log = logging.getLogger("icloud-photos-fs") + +CACHE_DIR = Path.home() / ".cache" / "icloud-photos" +ICLOUDPD = str(Path(__file__).parent.parent / "icloud-linux" / "icloud-linux" / ".venv" / "bin" / "icloudpd") +# Fallback paths +for _candidate in [ + Path.home() / "icloud-linux" / "icloud-linux" / ".venv" / "bin" / "icloudpd", + Path.home() / "icloud-linux" / ".venv" / "bin" / "icloudpd", +]: + if _candidate.exists(): + ICLOUDPD = str(_candidate) + break + + +class MyStat(fuse.Stat): + def __init__(self): + self.st_mode = 0 + self.st_ino = 0 + self.st_dev = 0 + self.st_nlink = 0 + self.st_uid = os.getuid() + self.st_gid = os.getgid() + self.st_size = 0 + self.st_atime = 0 + self.st_mtime = 0 + self.st_ctime = 0 + + +class FileEntry: + __slots__ = ("rel_path", "mtime") + + def __init__(self, rel_path: str, mtime: float = 0.0): + self.rel_path = rel_path + self.mtime = mtime + + @property + def cached(self) -> Path: + return CACHE_DIR / self.rel_path.lstrip("/") + + @property + def size(self) -> int: + cp = self.cached + if cp.exists(): + return cp.stat().st_size + # Return a large placeholder so callers don't truncate reads before download + return 8 * 1024 * 1024 # 8 MB + + +class ICloudPhotosFS(Fuse): + def __init__(self, username, password, cookie_dir, library, + on_progress=None, on_done=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self._username = username + self._password = password + self._cookie_dir = cookie_dir + self._library = library + self._on_progress = on_progress + self._on_done = on_done + self._lock = threading.RLock() + self._mount_time = time.time() + + # rel_path → FileEntry (key has leading /) + self._files: dict[str, FileEntry] = {} + # "2024/06/12" → set of filenames + self._dirs: dict[str, set] = defaultdict(set) + + CACHE_DIR.mkdir(parents=True, exist_ok=True) + + def fsinit(self): + """Called after mount — start indexing, then prefetch recent photos.""" + threading.Thread(target=self._start_tasks, daemon=True).start() + + def _start_tasks(self): + self._build_index() + # Prefetch BEFORE signaling done so Photos sees real cached files immediately + self._prefetch_recent(200) + # Now tell the GUI we're ready + if self._on_done: + self._on_done(getattr(self, "_index_count", 0)) + + def _prefetch_recent(self, count: int): + log.info(f"Prefetching {count} most recent photos…") + cmd = [ + ICLOUDPD, + "--username", self._username, + "--password", self._password, + "--cookie-directory", self._cookie_dir, + "--library", self._library, + "--no-progress-bar", + "--directory", str(CACHE_DIR), + "--recent", str(count), + "--convert-all-heic-to-jpeg", + ] + try: + subprocess.run(cmd, capture_output=True, timeout=600) + log.info("Prefetch done") + except Exception as e: + log.error(f"Prefetch error: {e}") + + # ── Index ────────────────────────────────────────────────────────────────── + + def _build_index(self): + log.info("Building index via icloudpd…") + count = 0 + tmp_root = str(CACHE_DIR / "_index_root") + Path(tmp_root).mkdir(parents=True, exist_ok=True) + + cmd = [ + ICLOUDPD, + "--username", self._username, + "--password", self._password, + "--cookie-directory", self._cookie_dir, + "--library", self._library, + "--no-progress-bar", + "--directory", tmp_root, + "--only-print-filenames", + ] + + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, text=True, bufsize=1) + for line in proc.stdout: + line = line.strip() + if not line: + continue + try: + rel = os.path.relpath(line, tmp_root).replace(os.sep, "/") + except ValueError: + continue + if rel.startswith("..") or not rel: + continue + parts = rel.split("/") + if len(parts) < 2: + continue + + filename = parts[-1] + ext = os.path.splitext(filename)[1].lower() + + # Skip video sidecars — Photos app doesn't want .MOV/.MP4 here + if ext in (".mov", ".mp4", ".m4v", ".avi"): + continue + + # Present HEIC as .jpg (we serve converted JPEG bytes) + if ext in (".heic", ".heif"): + filename = os.path.splitext(filename)[0] + ".jpg" + parts = parts[:-1] + [filename] + rel = "/".join(parts) + + fuse_path = "/" + rel + dir_key = "/".join(parts[:-1]) + + with self._lock: + if fuse_path not in self._files: + # Store original rel_path so _download knows the real date path + self._files[fuse_path] = FileEntry(fuse_path) + self._dirs[dir_key].add(filename) + for depth in range(1, len(parts)): + _ = self._dirs["/".join(parts[:depth])] + count += 1 + if count % 200 == 0 and self._on_progress: + self._on_progress(count) + proc.wait() + except Exception as e: + log.error(f"Index failed: {e}") + + log.info(f"Index done: {count}") + # Don't call on_done yet — prefetch runs first in _start_tasks + self._index_count = count + + # ── FUSE ops ─────────────────────────────────────────────────────────────── + + def getattr(self, path): + st = MyStat() + t = int(self._mount_time) + + if path == "/": + st.st_mode = stat.S_IFDIR | 0o555 + st.st_nlink = 2 + st.st_atime = st.st_mtime = st.st_ctime = t + return st + + with self._lock: + if path in self._files: + entry = self._files[path] + st.st_mode = stat.S_IFREG | 0o444 + st.st_nlink = 1 + st.st_size = entry.size + st.st_atime = st.st_mtime = st.st_ctime = int(entry.mtime or t) + return st + dir_key = path.lstrip("/") + if dir_key in self._dirs: + st.st_mode = stat.S_IFDIR | 0o555 + st.st_nlink = 2 + st.st_atime = st.st_mtime = st.st_ctime = t + return st + + return -errno.ENOENT + + def readdir(self, path, offset): + yield fuse.Direntry(".") + yield fuse.Direntry("..") + dir_key = path.lstrip("/") + seen = set() + + with self._lock: + # Sub-directories + for k in list(self._dirs.keys()): + if dir_key == "": + top = k.split("/")[0] + if top not in seen: + seen.add(top) + yield fuse.Direntry(top) + elif k.startswith(dir_key + "/"): + rest = k[len(dir_key) + 1:] + child = rest.split("/")[0] + if child not in seen: + seen.add(child) + yield fuse.Direntry(child) + # Files + for fp in list(self._files.keys()): + fp_dir = "/".join(fp.lstrip("/").split("/")[:-1]) + if fp_dir == dir_key: + yield fuse.Direntry(fp.split("/")[-1]) + + # Per-date download locks so concurrent reads for same day don't double-download + _dl_locks: dict = {} + _dl_locks_mutex = threading.Lock() + _dl_dates_done: set = set() # dates already fully downloaded + + def _get_dl_lock(self, key): + with self._dl_locks_mutex: + if key not in self._dl_locks: + self._dl_locks[key] = threading.Lock() + return self._dl_locks[key] + + def _ensure_downloaded(self, path: str, entry: "FileEntry"): + """Download the file's day-batch if not already cached.""" + if entry.cached.exists(): + return + parts = path.lstrip("/").split("/") + try: + date_str = f"{parts[0]}-{parts[1]}-{parts[2]}" + except IndexError: + date_str = None + lock_key = date_str or path + with self._get_dl_lock(lock_key): + if entry.cached.exists(): + return # another thread beat us to it + if date_str and date_str in self._dl_dates_done: + return # already downloaded this day + self._download(path, entry, date_str) + if date_str: + self._dl_dates_done.add(date_str) + + def open(self, path, flags): + with self._lock: + entry = self._files.get(path) + if entry is None: + return -errno.ENOENT + write_flags = os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_APPEND + if flags & write_flags: + return -errno.EROFS + if not entry.cached.exists(): + log.info(f"Downloading on open: {path}") + self._ensure_downloaded(path, entry) + return 0 + + def read(self, path, size, offset): + with self._lock: + entry = self._files.get(path) + if entry is None: + return -errno.ENOENT + cp = entry.cached + if not cp.exists(): + self._ensure_downloaded(path, entry) + if not cp.exists(): + return b"" + try: + with open(cp, "rb") as f: + f.seek(offset) + return f.read(size) + except Exception: + return b"" + + def _download(self, fuse_path: str, entry: "FileEntry", date_str=None): + entry.cached.parent.mkdir(parents=True, exist_ok=True) + cmd = [ + ICLOUDPD, + "--username", self._username, + "--password", self._password, + "--cookie-directory", self._cookie_dir, + "--library", self._library, + "--no-progress-bar", + "--directory", str(CACHE_DIR), + "--convert-all-heic-to-jpeg", + ] + if date_str: + cmd += ["--skip-created-before", date_str, + "--skip-created-after", date_str] + try: + subprocess.run(cmd, capture_output=True, timeout=120) + except Exception as e: + log.error(f"Download error: {e}") + + # Block writes + def write(self, *_): return -errno.EROFS + def mknod(self, *_): return -errno.EROFS + def unlink(self, *_): return -errno.EROFS + def mkdir(self, *_): return -errno.EROFS + def rmdir(self, *_): return -errno.EROFS + def rename(self, *_): return -errno.EROFS + def symlink(self, *_): return -errno.EROFS + def link(self, *_): return -errno.EROFS + def chmod(self, *_): return -errno.EROFS + def chown(self, *_): return -errno.EROFS + def truncate(self, *_):return -errno.EROFS + def utime(self, *_): return 0 + def statfs(self): + st = fuse.StatVfs() + st.f_bsize = 4096 + st.f_blocks = 0 + st.f_bfree = 0 + st.f_bavail = 0 + return st + + +def mount(username, password, cookie_dir, mountpoint, library="PrimarySync", + on_progress=None, on_done=None): + if os.path.ismount(mountpoint): + # -z = lazy unmount (detaches even if busy) + os.system(f"fusermount -uz {mountpoint!r}") + time.sleep(1) + os.makedirs(mountpoint, exist_ok=True) + + server = ICloudPhotosFS( + username, password, cookie_dir, library, + on_progress=on_progress, on_done=on_done, + version="%prog 0.1", + usage="iCloud Photos FUSE", + dash_s_do="setsingle", + ) + server.parse(args=[mountpoint, "-f"], errex=1) + server.main() + + +def unmount(mountpoint: str): + os.system(f"fusermount -u {mountpoint!r}") diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9455d19 --- /dev/null +++ b/install.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# iCloud Drive for Linux — installer +# Installs icloud-linux (backend) + icloud-gui (GTK3 frontend) on ElementaryOS / Ubuntu / Debian. +set -euo pipefail + +REPO_URL="https://github.com/IsmaeelAkram/icloud-linux" +INSTALL_DIR="$HOME/.local/lib/icloud-linux" +GUI_INSTALL="$HOME/.local/lib/icloud-gui" +BIN_DIR="$HOME/.local/bin" +DESKTOP_DIR="$HOME/.local/share/applications" +ICONS_DIR="$HOME/.local/share/icons/hicolor/scalable/apps" + +BOLD="\033[1m" +GREEN="\033[0;32m" +YELLOW="\033[0;33m" +CYAN="\033[0;36m" +RED="\033[0;31m" +RESET="\033[0m" + +info() { echo -e "${CYAN} →${RESET} $*"; } +success() { echo -e "${GREEN} ✓${RESET} $*"; } +warn() { echo -e "${YELLOW} !${RESET} $*"; } +die() { echo -e "${RED} ✗ ERROR:${RESET} $*" >&2; exit 1; } +header() { echo -e "\n${BOLD}$*${RESET}"; } + +# ── Detect distro ───────────────────────────────────────────────────────────── +if ! command -v apt-get &>/dev/null; then + die "This installer requires apt (Debian/Ubuntu/ElementaryOS). For other distros, install deps manually." +fi + +header "iCloud Drive for Linux — Installer" +echo " Target install: $INSTALL_DIR" +echo " GUI install: $GUI_INSTALL" +echo "" + +# ── System dependencies ─────────────────────────────────────────────────────── +header "1/5 Installing system dependencies" +PKGS=( + python3 + python3-venv + python3-pip + python3-gi + python3-gi-cairo + gir1.2-gtk-3.0 + python3-yaml + fuse + git + libfuse2 + heif-gdk-pixbuf # HEIC/HEIF support for GNOME Photos +) + +# Elementary/Ubuntu 22+ ships libfuse3 but icloud-linux may need libfuse2 +sudo apt-get update -qq +sudo apt-get install -y "${PKGS[@]}" 2>&1 | grep -E "(install|already)" || true +success "Dependencies installed" + +# ── Clone / update icloud-linux backend ─────────────────────────────────────── +header "2/5 Installing icloud-linux backend" +if [ -d "$INSTALL_DIR/.git" ]; then + info "Already cloned — pulling latest" + git -C "$INSTALL_DIR" pull --ff-only +else + info "Cloning $REPO_URL" + git clone "$REPO_URL" "$INSTALL_DIR" +fi + +# Set up Python venv and install requirements +info "Setting up Python environment" +python3 -m venv "$INSTALL_DIR/venv" +"$INSTALL_DIR/venv/bin/pip" install -q --upgrade pip +"$INSTALL_DIR/venv/bin/pip" install -q -r "$INSTALL_DIR/requirements.txt" +success "Backend ready at $INSTALL_DIR" + +# ── Install the GUI ─────────────────────────────────────────────────────────── +header "3/5 Installing iCloud Drive GUI" +mkdir -p "$GUI_INSTALL" + +# Copy the GUI script alongside this installer script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$SCRIPT_DIR/icloud_gui.py" ]; then + cp "$SCRIPT_DIR/icloud_gui.py" "$GUI_INSTALL/icloud_gui.py" + success "Copied GUI from $SCRIPT_DIR" +else + die "icloud_gui.py not found next to install.sh — make sure you run install.sh from the icloud-gui directory." +fi + +# ── Wrapper script in PATH ──────────────────────────────────────────────────── +header "4/5 Creating launcher" +mkdir -p "$BIN_DIR" +cat > "$BIN_DIR/icloud-drive" < "$ICONS_DIR/com.elementary.icloud-linux.svg" <<'SVGEOF' + + + + + + + + + +SVGEOF + +cat > "$DESKTOP_DIR/com.elementary.icloud-linux.desktop" </dev/null || true +update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true +success "App menu entry installed" + +# ── Update GUI config to point at correct icloudctl ────────────────────────── +# Patch the ICLOUDCTL path in the installed GUI script +ICLOUDCTL_PATH="$INSTALL_DIR/icloudctl" +sed -i "s|ICLOUDCTL = .*|ICLOUDCTL = \"$ICLOUDCTL_PATH\"|" "$GUI_INSTALL/icloud_gui.py" +success "GUI configured to use $ICLOUDCTL_PATH" + +# ── Done ────────────────────────────────────────────────────────────────────── +header "Installation complete!" +echo "" +echo -e " ${BOLD}Next steps:${RESET}" +echo " 1. Open ${BOLD}iCloud Drive${RESET} from your app menu (Slingshot)" +echo " or run: icloud-drive" +echo "" +echo " 2. Go to ${BOLD}Settings${RESET} and enter your Apple ID + password" +echo "" +echo " 3. Go to ${BOLD}Sign In${RESET} and authenticate (handles 2FA)" +echo "" +echo " 4. On ${BOLD}Dashboard${RESET}, press ${BOLD}Start${RESET} to begin syncing" +echo "" +echo " To uninstall, run: $SCRIPT_DIR/uninstall.sh" +echo "" diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..a6900ae --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# iCloud Drive for Linux — uninstaller +set -euo pipefail + +INSTALL_DIR="$HOME/.local/lib/icloud-linux" +GUI_INSTALL="$HOME/.local/lib/icloud-gui" +BIN_DIR="$HOME/.local/bin" +DESKTOP_DIR="$HOME/.local/share/applications" +ICONS_DIR="$HOME/.local/share/icons/hicolor/scalable/apps" + +echo "This will remove:" +echo " $INSTALL_DIR (backend)" +echo " $GUI_INSTALL (GUI)" +echo " $BIN_DIR/icloud-drive" +echo " $DESKTOP_DIR/com.elementary.icloud-linux.desktop" +echo "" +read -rp "Continue? [y/N] " ans +[[ "$ans" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } + +# Stop the service first if running +if command -v icloudctl &>/dev/null || [ -f "$INSTALL_DIR/icloudctl" ]; then + "$INSTALL_DIR/icloudctl" stop 2>/dev/null || true +fi + +rm -rf "$INSTALL_DIR" "$GUI_INSTALL" +rm -f "$BIN_DIR/icloud-drive" +rm -f "$DESKTOP_DIR/com.elementary.icloud-linux.desktop" +rm -f "$ICONS_DIR/com.elementary.icloud-linux.svg" + +update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true +gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true + +echo "Uninstalled. Your config (~/.config/icloud-linux) and cache (~/.cache/icloud-linux) were left intact." +echo "Remove them manually if you want a clean slate:" +echo " rm -rf ~/.config/icloud-linux ~/.cache/icloud-linux"