#!/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 — always visible so user can enter code any time
tfa_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
tfa_card.get_style_context().add_class("card")
tfa_card.set_margin_top(4)
tfa_lbl = Gtk.Label()
tfa_lbl.set_markup("Two-Factor Authentication")
tfa_lbl.set_halign(Gtk.Align.START)
tfa_sub = Gtk.Label(label="If Apple sent a code to your device, enter it here.")
tfa_sub.set_halign(Gtk.Align.START)
code_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self.tfa_entry = Gtk.Entry()
self.tfa_entry.set_placeholder_text("6-digit code")
self.tfa_entry.set_max_length(6)
self.tfa_entry.set_input_purpose(Gtk.InputPurpose.DIGITS)
self.tfa_entry.set_hexpand(True)
self.tfa_entry.connect("activate", self._on_tfa) # Enter key submits
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)
code_row.pack_start(self.tfa_entry, True, True, 0)
code_row.pack_start(btn_tfa, False, False, 0)
self.tfa_status = Gtk.Label(label="")
self.tfa_status.set_halign(Gtk.Align.START)
tfa_card.pack_start(tfa_lbl, False, False, 0)
tfa_card.pack_start(tfa_sub, False, False, 0)
tfa_card.pack_start(code_row, False, False, 0)
tfa_card.pack_start(self.tfa_status, False, False, 0)
self.pack_start(tfa_card, 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() or "2fa" in output.lower():
self.status_label.set_markup(
'A code was sent to your Apple device — enter it below.'
)
self.tfa_entry.grab_focus()
elif rc == 0:
self.status_label.set_markup('Authentication successful!')
else:
msg = GLib.markup_escape_text(output[:300])
self.status_label.set_markup(f'{msg}')
def _on_tfa(self, _):
code = self.tfa_entry.get_text().strip()
if len(code) < 6:
self.tfa_status.set_markup('Please enter the full 6-digit code.')
return
self.tfa_status.set_markup('Verifying…')
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 or "authenticated" in output.lower() or "success" in output.lower():
self.tfa_status.set_markup('Verified! You can now start the service.')
self.tfa_entry.set_text("")
else:
msg = GLib.markup_escape_text(output[:200]) if output.strip() else f"exit code {rc}"
self.tfa_status.set_markup(f'Failed: {msg}')
# ── 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()