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:
commit
21be796265
6 files changed
+1935
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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"
|
||||||
Reference in new issue
Block a user