#!/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()