Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1365 lines
51 KiB
Python
1365 lines
51 KiB
Python
#!/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("<b>iCloud Drive</b>")
|
|
title.set_halign(Gtk.Align.START)
|
|
self.current_file_label = Gtk.Label()
|
|
self.current_file_label.set_markup('<span font_size="small" fgcolor="#888888">Sync service status</span>')
|
|
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('<span weight="bold" font_size="small" fgcolor="#888888">QUICK ACTIONS</span>')
|
|
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"<b>{GLib.markup_escape_text(label)}</b>")
|
|
l1.set_halign(Gtk.Align.START)
|
|
l2 = Gtk.Label()
|
|
l2.set_markup(f'<span font_size="small" fgcolor="#888888">{GLib.markup_escape_text(tip)}</span>')
|
|
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('<span weight="bold" font_size="small" fgcolor="#888888">OUTPUT</span>')
|
|
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('<span font_size="small" fgcolor="#888888">Sync service status</span>')
|
|
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'<span font_size="small" fgcolor="#888888">Syncing: {GLib.markup_escape_text(path)}</span>'
|
|
)
|
|
|
|
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('<span font_size="small" fgcolor="#888888">Sync service status</span>')
|
|
|
|
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("<b>Service Logs</b>")
|
|
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('<span font_size="small" fgcolor="#888888">Parallel download threads (1 = safe, 3-4 = faster)</span>')
|
|
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'<span weight="bold" font_size="small" fgcolor="#888888">{label}</span>')
|
|
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'<span font_size="small" fgcolor="#888888">{GLib.markup_escape_text(subtitle)}</span>')
|
|
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("<b>iCloud Photos</b>")
|
|
title.set_halign(Gtk.Align.START)
|
|
sub = Gtk.Label()
|
|
sub.set_markup(f'<span font_size="small" fgcolor="#888888">Downloads to {PHOTOS_MOUNT}</span>')
|
|
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 <b>~/Pictures/iCloud Photos</b>\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('<span weight="bold" font_size="small" fgcolor="#888888">CACHE</span>')
|
|
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(
|
|
'<span fgcolor="#c6262e">Set your Apple ID and password in Settings first.</span>'
|
|
)
|
|
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'<span fgcolor="#c6262e">Failed: {e}</span>')
|
|
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'<b>{n}</b> 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'<b>{n}</b> 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'<span font_size="small" fgcolor="#888888">Last synced: {ts}</span>')
|
|
self._refresh_status()
|
|
else:
|
|
self.progress_label.set_markup(
|
|
f'<span fgcolor="#c6262e">Download failed (exit {rc}). Check logs.</span>'
|
|
)
|
|
|
|
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('<span font_size="large" weight="bold">Authenticate with Apple</span>')
|
|
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("<b>Two-Factor Authentication</b>")
|
|
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('<span fgcolor="#64a6e0">Connecting to Apple...</span>')
|
|
|
|
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(
|
|
'<span fgcolor="#e6a020">A code was sent to your Apple device — enter it below.</span>'
|
|
)
|
|
self.tfa_entry.grab_focus()
|
|
elif rc == 0:
|
|
self.status_label.set_markup('<span fgcolor="#68b723">Authentication successful!</span>')
|
|
else:
|
|
msg = GLib.markup_escape_text(output[:300])
|
|
self.status_label.set_markup(f'<span fgcolor="#c6262e">{msg}</span>')
|
|
|
|
def _on_tfa(self, _):
|
|
code = self.tfa_entry.get_text().strip()
|
|
if len(code) < 6:
|
|
self.tfa_status.set_markup('<span fgcolor="#c6262e">Please enter the full 6-digit code.</span>')
|
|
return
|
|
self.tfa_status.set_markup('<span fgcolor="#64a6e0">Verifying…</span>')
|
|
|
|
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('<span fgcolor="#68b723">Verified! You can now start the service.</span>')
|
|
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'<span fgcolor="#c6262e">Failed: {msg}</span>')
|
|
|
|
|
|
# ── 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()
|