Initial commit: iCloud Drive GTK3 GUI for ElementaryOS

- Dashboard with live sync status, current file indicator, auto-refresh
- Logs page with color-coded ERROR/WARN/INFO lines and live follow
- Photos tab: download recent photos as JPEG via icloudpd
- Settings with save + optional service restart
- Install/uninstall scripts and .desktop entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 4.6 committed 2026-08-13 13:21:19 -04:00
commit 21be796265
6 files changed
+1935

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
*.pyo
.env
+11
View File
@@ -0,0 +1,11 @@
[Desktop Entry]
Name=iCloud Drive
Comment=Sync iCloud Drive on Linux
Exec=python3 /home/nateemmert/Documents/icloud-gui/icloud_gui.py
Icon=folder-cloud
Terminal=false
Type=Application
Categories=Network;FileManager;
Keywords=iCloud;Apple;Sync;Drive;
StartupNotify=true
StartupWMClass=icloud_gui
+1351
View File
File diff suppressed because it is too large. Load diff
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""
iCloud Photos FUSE filesystem — uses fuse-python (same library as icloud-linux).
Directory layout mirrors icloudpd output: /YYYY/MM/DD/filename.HEIC
- Listing: instant (index from icloudpd --only-print-filenames)
- Open: downloads on demand via icloudpd, cached in ~/.cache/icloud-photos/
"""
import os
import sys
import stat
import errno
import time
import threading
import subprocess
import logging
from pathlib import Path
from collections import defaultdict
import fuse
from fuse import Fuse
if not hasattr(fuse, "__version__"):
fuse.__version__ = "0.2"
fuse.fuse_python_api = (0, 2)
log = logging.getLogger("icloud-photos-fs")
CACHE_DIR = Path.home() / ".cache" / "icloud-photos"
ICLOUDPD = str(Path(__file__).parent.parent / "icloud-linux" / "icloud-linux" / ".venv" / "bin" / "icloudpd")
# Fallback paths
for _candidate in [
Path.home() / "icloud-linux" / "icloud-linux" / ".venv" / "bin" / "icloudpd",
Path.home() / "icloud-linux" / ".venv" / "bin" / "icloudpd",
]:
if _candidate.exists():
ICLOUDPD = str(_candidate)
break
class MyStat(fuse.Stat):
def __init__(self):
self.st_mode = 0
self.st_ino = 0
self.st_dev = 0
self.st_nlink = 0
self.st_uid = os.getuid()
self.st_gid = os.getgid()
self.st_size = 0
self.st_atime = 0
self.st_mtime = 0
self.st_ctime = 0
class FileEntry:
__slots__ = ("rel_path", "mtime")
def __init__(self, rel_path: str, mtime: float = 0.0):
self.rel_path = rel_path
self.mtime = mtime
@property
def cached(self) -> Path:
return CACHE_DIR / self.rel_path.lstrip("/")
@property
def size(self) -> int:
cp = self.cached
if cp.exists():
return cp.stat().st_size
# Return a large placeholder so callers don't truncate reads before download
return 8 * 1024 * 1024 # 8 MB
class ICloudPhotosFS(Fuse):
def __init__(self, username, password, cookie_dir, library,
on_progress=None, on_done=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self._username = username
self._password = password
self._cookie_dir = cookie_dir
self._library = library
self._on_progress = on_progress
self._on_done = on_done
self._lock = threading.RLock()
self._mount_time = time.time()
# rel_path → FileEntry (key has leading /)
self._files: dict[str, FileEntry] = {}
# "2024/06/12" → set of filenames
self._dirs: dict[str, set] = defaultdict(set)
CACHE_DIR.mkdir(parents=True, exist_ok=True)
def fsinit(self):
"""Called after mount — start indexing, then prefetch recent photos."""
threading.Thread(target=self._start_tasks, daemon=True).start()
def _start_tasks(self):
self._build_index()
# Prefetch BEFORE signaling done so Photos sees real cached files immediately
self._prefetch_recent(200)
# Now tell the GUI we're ready
if self._on_done:
self._on_done(getattr(self, "_index_count", 0))
def _prefetch_recent(self, count: int):
log.info(f"Prefetching {count} most recent photos…")
cmd = [
ICLOUDPD,
"--username", self._username,
"--password", self._password,
"--cookie-directory", self._cookie_dir,
"--library", self._library,
"--no-progress-bar",
"--directory", str(CACHE_DIR),
"--recent", str(count),
"--convert-all-heic-to-jpeg",
]
try:
subprocess.run(cmd, capture_output=True, timeout=600)
log.info("Prefetch done")
except Exception as e:
log.error(f"Prefetch error: {e}")
# ── Index ──────────────────────────────────────────────────────────────────
def _build_index(self):
log.info("Building index via icloudpd…")
count = 0
tmp_root = str(CACHE_DIR / "_index_root")
Path(tmp_root).mkdir(parents=True, exist_ok=True)
cmd = [
ICLOUDPD,
"--username", self._username,
"--password", self._password,
"--cookie-directory", self._cookie_dir,
"--library", self._library,
"--no-progress-bar",
"--directory", tmp_root,
"--only-print-filenames",
]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, bufsize=1)
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
rel = os.path.relpath(line, tmp_root).replace(os.sep, "/")
except ValueError:
continue
if rel.startswith("..") or not rel:
continue
parts = rel.split("/")
if len(parts) < 2:
continue
filename = parts[-1]
ext = os.path.splitext(filename)[1].lower()
# Skip video sidecars — Photos app doesn't want .MOV/.MP4 here
if ext in (".mov", ".mp4", ".m4v", ".avi"):
continue
# Present HEIC as .jpg (we serve converted JPEG bytes)
if ext in (".heic", ".heif"):
filename = os.path.splitext(filename)[0] + ".jpg"
parts = parts[:-1] + [filename]
rel = "/".join(parts)
fuse_path = "/" + rel
dir_key = "/".join(parts[:-1])
with self._lock:
if fuse_path not in self._files:
# Store original rel_path so _download knows the real date path
self._files[fuse_path] = FileEntry(fuse_path)
self._dirs[dir_key].add(filename)
for depth in range(1, len(parts)):
_ = self._dirs["/".join(parts[:depth])]
count += 1
if count % 200 == 0 and self._on_progress:
self._on_progress(count)
proc.wait()
except Exception as e:
log.error(f"Index failed: {e}")
log.info(f"Index done: {count}")
# Don't call on_done yet — prefetch runs first in _start_tasks
self._index_count = count
# ── FUSE ops ───────────────────────────────────────────────────────────────
def getattr(self, path):
st = MyStat()
t = int(self._mount_time)
if path == "/":
st.st_mode = stat.S_IFDIR | 0o555
st.st_nlink = 2
st.st_atime = st.st_mtime = st.st_ctime = t
return st
with self._lock:
if path in self._files:
entry = self._files[path]
st.st_mode = stat.S_IFREG | 0o444
st.st_nlink = 1
st.st_size = entry.size
st.st_atime = st.st_mtime = st.st_ctime = int(entry.mtime or t)
return st
dir_key = path.lstrip("/")
if dir_key in self._dirs:
st.st_mode = stat.S_IFDIR | 0o555
st.st_nlink = 2
st.st_atime = st.st_mtime = st.st_ctime = t
return st
return -errno.ENOENT
def readdir(self, path, offset):
yield fuse.Direntry(".")
yield fuse.Direntry("..")
dir_key = path.lstrip("/")
seen = set()
with self._lock:
# Sub-directories
for k in list(self._dirs.keys()):
if dir_key == "":
top = k.split("/")[0]
if top not in seen:
seen.add(top)
yield fuse.Direntry(top)
elif k.startswith(dir_key + "/"):
rest = k[len(dir_key) + 1:]
child = rest.split("/")[0]
if child not in seen:
seen.add(child)
yield fuse.Direntry(child)
# Files
for fp in list(self._files.keys()):
fp_dir = "/".join(fp.lstrip("/").split("/")[:-1])
if fp_dir == dir_key:
yield fuse.Direntry(fp.split("/")[-1])
# Per-date download locks so concurrent reads for same day don't double-download
_dl_locks: dict = {}
_dl_locks_mutex = threading.Lock()
_dl_dates_done: set = set() # dates already fully downloaded
def _get_dl_lock(self, key):
with self._dl_locks_mutex:
if key not in self._dl_locks:
self._dl_locks[key] = threading.Lock()
return self._dl_locks[key]
def _ensure_downloaded(self, path: str, entry: "FileEntry"):
"""Download the file's day-batch if not already cached."""
if entry.cached.exists():
return
parts = path.lstrip("/").split("/")
try:
date_str = f"{parts[0]}-{parts[1]}-{parts[2]}"
except IndexError:
date_str = None
lock_key = date_str or path
with self._get_dl_lock(lock_key):
if entry.cached.exists():
return # another thread beat us to it
if date_str and date_str in self._dl_dates_done:
return # already downloaded this day
self._download(path, entry, date_str)
if date_str:
self._dl_dates_done.add(date_str)
def open(self, path, flags):
with self._lock:
entry = self._files.get(path)
if entry is None:
return -errno.ENOENT
write_flags = os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_APPEND
if flags & write_flags:
return -errno.EROFS
if not entry.cached.exists():
log.info(f"Downloading on open: {path}")
self._ensure_downloaded(path, entry)
return 0
def read(self, path, size, offset):
with self._lock:
entry = self._files.get(path)
if entry is None:
return -errno.ENOENT
cp = entry.cached
if not cp.exists():
self._ensure_downloaded(path, entry)
if not cp.exists():
return b""
try:
with open(cp, "rb") as f:
f.seek(offset)
return f.read(size)
except Exception:
return b""
def _download(self, fuse_path: str, entry: "FileEntry", date_str=None):
entry.cached.parent.mkdir(parents=True, exist_ok=True)
cmd = [
ICLOUDPD,
"--username", self._username,
"--password", self._password,
"--cookie-directory", self._cookie_dir,
"--library", self._library,
"--no-progress-bar",
"--directory", str(CACHE_DIR),
"--convert-all-heic-to-jpeg",
]
if date_str:
cmd += ["--skip-created-before", date_str,
"--skip-created-after", date_str]
try:
subprocess.run(cmd, capture_output=True, timeout=120)
except Exception as e:
log.error(f"Download error: {e}")
# Block writes
def write(self, *_): return -errno.EROFS
def mknod(self, *_): return -errno.EROFS
def unlink(self, *_): return -errno.EROFS
def mkdir(self, *_): return -errno.EROFS
def rmdir(self, *_): return -errno.EROFS
def rename(self, *_): return -errno.EROFS
def symlink(self, *_): return -errno.EROFS
def link(self, *_): return -errno.EROFS
def chmod(self, *_): return -errno.EROFS
def chown(self, *_): return -errno.EROFS
def truncate(self, *_):return -errno.EROFS
def utime(self, *_): return 0
def statfs(self):
st = fuse.StatVfs()
st.f_bsize = 4096
st.f_blocks = 0
st.f_bfree = 0
st.f_bavail = 0
return st
def mount(username, password, cookie_dir, mountpoint, library="PrimarySync",
on_progress=None, on_done=None):
if os.path.ismount(mountpoint):
# -z = lazy unmount (detaches even if busy)
os.system(f"fusermount -uz {mountpoint!r}")
time.sleep(1)
os.makedirs(mountpoint, exist_ok=True)
server = ICloudPhotosFS(
username, password, cookie_dir, library,
on_progress=on_progress, on_done=on_done,
version="%prog 0.1",
usage="iCloud Photos FUSE",
dash_s_do="setsingle",
)
server.parse(args=[mountpoint, "-f"], errex=1)
server.main()
def unmount(mountpoint: str):
os.system(f"fusermount -u {mountpoint!r}")
Executable
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# iCloud Drive for Linux — installer
# Installs icloud-linux (backend) + icloud-gui (GTK3 frontend) on ElementaryOS / Ubuntu / Debian.
set -euo pipefail
REPO_URL="https://github.com/IsmaeelAkram/icloud-linux"
INSTALL_DIR="$HOME/.local/lib/icloud-linux"
GUI_INSTALL="$HOME/.local/lib/icloud-gui"
BIN_DIR="$HOME/.local/bin"
DESKTOP_DIR="$HOME/.local/share/applications"
ICONS_DIR="$HOME/.local/share/icons/hicolor/scalable/apps"
BOLD="\033[1m"
GREEN="\033[0;32m"
YELLOW="\033[0;33m"
CYAN="\033[0;36m"
RED="\033[0;31m"
RESET="\033[0m"
info() { echo -e "${CYAN} →${RESET} $*"; }
success() { echo -e "${GREEN} ✓${RESET} $*"; }
warn() { echo -e "${YELLOW} !${RESET} $*"; }
die() { echo -e "${RED} ✗ ERROR:${RESET} $*" >&2; exit 1; }
header() { echo -e "\n${BOLD}$*${RESET}"; }
# ── Detect distro ─────────────────────────────────────────────────────────────
if ! command -v apt-get &>/dev/null; then
die "This installer requires apt (Debian/Ubuntu/ElementaryOS). For other distros, install deps manually."
fi
header "iCloud Drive for Linux — Installer"
echo " Target install: $INSTALL_DIR"
echo " GUI install: $GUI_INSTALL"
echo ""
# ── System dependencies ───────────────────────────────────────────────────────
header "1/5 Installing system dependencies"
PKGS=(
python3
python3-venv
python3-pip
python3-gi
python3-gi-cairo
gir1.2-gtk-3.0
python3-yaml
fuse
git
libfuse2
heif-gdk-pixbuf # HEIC/HEIF support for GNOME Photos
)
# Elementary/Ubuntu 22+ ships libfuse3 but icloud-linux may need libfuse2
sudo apt-get update -qq
sudo apt-get install -y "${PKGS[@]}" 2>&1 | grep -E "(install|already)" || true
success "Dependencies installed"
# ── Clone / update icloud-linux backend ───────────────────────────────────────
header "2/5 Installing icloud-linux backend"
if [ -d "$INSTALL_DIR/.git" ]; then
info "Already cloned — pulling latest"
git -C "$INSTALL_DIR" pull --ff-only
else
info "Cloning $REPO_URL"
git clone "$REPO_URL" "$INSTALL_DIR"
fi
# Set up Python venv and install requirements
info "Setting up Python environment"
python3 -m venv "$INSTALL_DIR/venv"
"$INSTALL_DIR/venv/bin/pip" install -q --upgrade pip
"$INSTALL_DIR/venv/bin/pip" install -q -r "$INSTALL_DIR/requirements.txt"
success "Backend ready at $INSTALL_DIR"
# ── Install the GUI ───────────────────────────────────────────────────────────
header "3/5 Installing iCloud Drive GUI"
mkdir -p "$GUI_INSTALL"
# Copy the GUI script alongside this installer script
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$SCRIPT_DIR/icloud_gui.py" ]; then
cp "$SCRIPT_DIR/icloud_gui.py" "$GUI_INSTALL/icloud_gui.py"
success "Copied GUI from $SCRIPT_DIR"
else
die "icloud_gui.py not found next to install.sh — make sure you run install.sh from the icloud-gui directory."
fi
# ── Wrapper script in PATH ────────────────────────────────────────────────────
header "4/5 Creating launcher"
mkdir -p "$BIN_DIR"
cat > "$BIN_DIR/icloud-drive" <<EOF
#!/usr/bin/env bash
# Auto-generated by icloud-gui installer
exec python3 "$GUI_INSTALL/icloud_gui.py" "\$@"
EOF
chmod +x "$BIN_DIR/icloud-drive"
success "Launcher: $BIN_DIR/icloud-drive"
# Ensure ~/.local/bin is in PATH
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
warn "~/.local/bin is not in your PATH."
echo " Add this to ~/.bashrc or ~/.profile and re-login:"
echo ' export PATH="$HOME/.local/bin:$PATH"'
fi
# ── Desktop entry & icon ──────────────────────────────────────────────────────
mkdir -p "$DESKTOP_DIR" "$ICONS_DIR"
# Minimal cloud SVG icon (no external deps)
cat > "$ICONS_DIR/com.elementary.icloud-linux.svg" <<'SVGEOF'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#64a6e0"/>
<stop offset="100%" stop-color="#1a6eb0"/>
</linearGradient>
</defs>
<path fill="url(#g)"
d="M50 30a14 14 0 0 0-26.5-6A10 10 0 1 0 14 44h36a10 10 0 0 0 0-14z"/>
</svg>
SVGEOF
cat > "$DESKTOP_DIR/com.elementary.icloud-linux.desktop" <<EOF
[Desktop Entry]
Name=iCloud Drive
Comment=Sync iCloud Drive on Linux
Exec=$BIN_DIR/icloud-drive
Icon=com.elementary.icloud-linux
Terminal=false
Type=Application
Categories=Network;FileManager;
Keywords=iCloud;Apple;Sync;Drive;Cloud;
StartupNotify=true
StartupWMClass=icloud_gui
EOF
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
success "App menu entry installed"
# ── Update GUI config to point at correct icloudctl ──────────────────────────
# Patch the ICLOUDCTL path in the installed GUI script
ICLOUDCTL_PATH="$INSTALL_DIR/icloudctl"
sed -i "s|ICLOUDCTL = .*|ICLOUDCTL = \"$ICLOUDCTL_PATH\"|" "$GUI_INSTALL/icloud_gui.py"
success "GUI configured to use $ICLOUDCTL_PATH"
# ── Done ──────────────────────────────────────────────────────────────────────
header "Installation complete!"
echo ""
echo -e " ${BOLD}Next steps:${RESET}"
echo " 1. Open ${BOLD}iCloud Drive${RESET} from your app menu (Slingshot)"
echo " or run: icloud-drive"
echo ""
echo " 2. Go to ${BOLD}Settings${RESET} and enter your Apple ID + password"
echo ""
echo " 3. Go to ${BOLD}Sign In${RESET} and authenticate (handles 2FA)"
echo ""
echo " 4. On ${BOLD}Dashboard${RESET}, press ${BOLD}Start${RESET} to begin syncing"
echo ""
echo " To uninstall, run: $SCRIPT_DIR/uninstall.sh"
echo ""
Executable
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# iCloud Drive for Linux — uninstaller
set -euo pipefail
INSTALL_DIR="$HOME/.local/lib/icloud-linux"
GUI_INSTALL="$HOME/.local/lib/icloud-gui"
BIN_DIR="$HOME/.local/bin"
DESKTOP_DIR="$HOME/.local/share/applications"
ICONS_DIR="$HOME/.local/share/icons/hicolor/scalable/apps"
echo "This will remove:"
echo " $INSTALL_DIR (backend)"
echo " $GUI_INSTALL (GUI)"
echo " $BIN_DIR/icloud-drive"
echo " $DESKTOP_DIR/com.elementary.icloud-linux.desktop"
echo ""
read -rp "Continue? [y/N] " ans
[[ "$ans" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
# Stop the service first if running
if command -v icloudctl &>/dev/null || [ -f "$INSTALL_DIR/icloudctl" ]; then
"$INSTALL_DIR/icloudctl" stop 2>/dev/null || true
fi
rm -rf "$INSTALL_DIR" "$GUI_INSTALL"
rm -f "$BIN_DIR/icloud-drive"
rm -f "$DESKTOP_DIR/com.elementary.icloud-linux.desktop"
rm -f "$ICONS_DIR/com.elementary.icloud-linux.svg"
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
echo "Uninstalled. Your config (~/.config/icloud-linux) and cache (~/.cache/icloud-linux) were left intact."
echo "Remove them manually if you want a clean slate:"
echo " rm -rf ~/.config/icloud-linux ~/.cache/icloud-linux"