feat(sync-alfred): Add WebDAV preference sync
This commit is contained in:
Executable
+472
@@ -0,0 +1,472 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create and restore a marked ZIP backup of Alfred preferences over WebDAV."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
PACKAGE_NAME = "Alfred.alfredpreferences"
|
||||
ARCHIVE_NAME = f"{PACKAGE_NAME}.sync-backup.zip"
|
||||
MARKER_NAME = ".alfred-sync-backup"
|
||||
FORMAT_VERSION = 1
|
||||
DEFAULT_WEBDAV_URL = (
|
||||
"https://u201686:T6672ICVoWAedECH@u201686.your-storagebox.de/"
|
||||
)
|
||||
DEFAULT_REMOTE_PATH = "backup/macs/alfred"
|
||||
WEBDAV_TIMEOUT = 30
|
||||
LOGGER = logging.getLogger("sync-alfred")
|
||||
|
||||
|
||||
class SyncError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ProgressIO:
|
||||
"""Wrap a readable binary file and report transfer progress once per second."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_obj,
|
||||
*,
|
||||
total: int | None = None,
|
||||
label: str = "Progress",
|
||||
) -> None:
|
||||
self.file_obj = file_obj
|
||||
self.total = total
|
||||
self.label = label
|
||||
self.transferred = 0
|
||||
self.started_at = time.monotonic()
|
||||
self.last_report_at = self.started_at - 1
|
||||
self.finished = False
|
||||
self._report(force=True)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.file_obj, name)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.total or 0
|
||||
|
||||
def read(self, size: int = -1):
|
||||
chunk = self.file_obj.read(size)
|
||||
if chunk:
|
||||
self.transferred += len(chunk)
|
||||
self._report()
|
||||
else:
|
||||
self._report(force=True, complete=True)
|
||||
return chunk
|
||||
|
||||
def finish(self) -> None:
|
||||
self._report(force=True, complete=True)
|
||||
|
||||
@staticmethod
|
||||
def _format_bytes(value: float) -> str:
|
||||
units = ("B", "KiB", "MiB", "GiB")
|
||||
for unit in units:
|
||||
if value < 1024 or unit == units[-1]:
|
||||
return f"{value:.1f} {unit}"
|
||||
value /= 1024
|
||||
return f"{value:.1f} GiB"
|
||||
|
||||
def _report(self, *, force: bool = False, complete: bool = False) -> None:
|
||||
if self.finished:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if not force and now - self.last_report_at < 1:
|
||||
return
|
||||
|
||||
self.last_report_at = now
|
||||
elapsed = max(now - self.started_at, 0.001)
|
||||
transferred = self._format_bytes(self.transferred)
|
||||
rate = self._format_bytes(self.transferred / elapsed)
|
||||
if self.total is None:
|
||||
message = f"{self.label}: {transferred} at {rate}/s"
|
||||
else:
|
||||
percent = 100 * self.transferred / self.total if self.total else 100
|
||||
total = self._format_bytes(self.total)
|
||||
message = f"{self.label}: {transferred} / {total} ({percent:.1f}%) at {rate}/s"
|
||||
|
||||
LOGGER.info(message)
|
||||
if complete:
|
||||
self.finished = True
|
||||
|
||||
|
||||
class WebDAVClient:
|
||||
def __init__(self, base_url: str, remote_path: str) -> None:
|
||||
parsed = urllib.parse.urlsplit(base_url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise SyncError("invalid WebDAV URL")
|
||||
if parsed.query or parsed.fragment:
|
||||
raise SyncError("WebDAV URL must not contain a query or fragment")
|
||||
|
||||
username = urllib.parse.unquote(parsed.username or "")
|
||||
password = urllib.parse.unquote(parsed.password or "")
|
||||
if not username:
|
||||
username = os.environ.get("ALFRED_WEBDAV_USER", "")
|
||||
if not password:
|
||||
password = os.environ.get("ALFRED_WEBDAV_PASSWORD", "")
|
||||
|
||||
# Strip credentials from request URLs and diagnostic output.
|
||||
netloc = parsed.netloc.rsplit("@", 1)[-1]
|
||||
base_path = parsed.path.rstrip("/")
|
||||
self.base_url = urllib.parse.urlunsplit(
|
||||
(parsed.scheme, netloc, f"{base_path}/", "", "")
|
||||
)
|
||||
self.remote_parts = self._parse_remote_path(remote_path)
|
||||
self.auth_header = None
|
||||
|
||||
password_manager = urllib.request.HTTPPasswordMgrWithDefaultRealm()
|
||||
if username and password:
|
||||
encoded_credentials = base64.b64encode(
|
||||
f"{username}:{password}".encode("utf-8")
|
||||
).decode("ascii")
|
||||
self.auth_header = f"Basic {encoded_credentials}"
|
||||
origin = urllib.parse.urlunsplit(
|
||||
(parsed.scheme, netloc, "/", "", "")
|
||||
)
|
||||
password_manager.add_password(None, origin, username, password)
|
||||
self.opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPBasicAuthHandler(password_manager)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_remote_path(remote_path: str) -> tuple[str, ...]:
|
||||
parts = tuple(remote_path.strip("/").split("/"))
|
||||
if not parts or any(not part or part in (".", "..") for part in parts):
|
||||
raise SyncError(f"invalid WebDAV remote path: {remote_path!r}")
|
||||
return parts
|
||||
|
||||
def _url_for(self, parts: tuple[str, ...]) -> str:
|
||||
encoded = "/".join(urllib.parse.quote(part, safe="") for part in parts)
|
||||
return f"{self.base_url.rstrip('/')}/{encoded}"
|
||||
|
||||
@property
|
||||
def archive_url(self) -> str:
|
||||
return self._url_for(self.remote_parts + (ARCHIVE_NAME,))
|
||||
|
||||
@property
|
||||
def display_location(self) -> str:
|
||||
return self.archive_url
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
data: object | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> urllib.response.addinfourl:
|
||||
request_headers = dict(headers or {})
|
||||
if self.auth_header:
|
||||
request_headers.setdefault("Authorization", self.auth_header)
|
||||
request = urllib.request.Request(
|
||||
url, data=data, headers=request_headers, method=method
|
||||
)
|
||||
try:
|
||||
return self.opener.open(request, timeout=WEBDAV_TIMEOUT)
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = f"HTTP {exc.code} {exc.reason}"
|
||||
raise SyncError(
|
||||
f"WebDAV {method} failed for remote archive: {detail}"
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise SyncError(f"WebDAV {method} failed: {exc.reason}") from exc
|
||||
|
||||
def ensure_remote_directory(self) -> None:
|
||||
for index in range(1, len(self.remote_parts) + 1):
|
||||
# WebDAV collections are directory-like resources and this server
|
||||
# redirects collection URLs without their trailing slash.
|
||||
url = f"{self._url_for(self.remote_parts[:index])}/"
|
||||
headers = {"Authorization": self.auth_header} if self.auth_header else {}
|
||||
request = urllib.request.Request(url, headers=headers, method="MKCOL")
|
||||
try:
|
||||
response = self.opener.open(request, timeout=WEBDAV_TIMEOUT)
|
||||
except urllib.error.HTTPError as exc:
|
||||
# WebDAV uses 405 when MKCOL is sent for an existing collection.
|
||||
if exc.code != 405:
|
||||
detail = f"HTTP {exc.code} {exc.reason}"
|
||||
raise SyncError(
|
||||
f"WebDAV MKCOL failed for remote directory: {detail}"
|
||||
) from exc
|
||||
exc.close()
|
||||
except urllib.error.URLError as exc:
|
||||
raise SyncError(f"WebDAV MKCOL failed: {exc.reason}") from exc
|
||||
else:
|
||||
response.close()
|
||||
|
||||
def upload(self, archive_path: Path) -> None:
|
||||
self.ensure_remote_directory()
|
||||
try:
|
||||
total = archive_path.stat().st_size
|
||||
source = archive_path.open("rb")
|
||||
except OSError as exc:
|
||||
raise SyncError(f"could not open archive {archive_path}: {exc}") from exc
|
||||
|
||||
progress = ProgressIO(source, total=total, label="Uploading")
|
||||
try:
|
||||
with source:
|
||||
with self._request(
|
||||
"PUT",
|
||||
self.archive_url,
|
||||
data=progress,
|
||||
headers={
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Length": str(total),
|
||||
},
|
||||
) as response:
|
||||
response.read()
|
||||
finally:
|
||||
progress.finish()
|
||||
|
||||
def download(self, archive_path: Path) -> None:
|
||||
with self._request("GET", self.archive_url) as response:
|
||||
try:
|
||||
with archive_path.open("wb") as output:
|
||||
shutil.copyfileobj(response, output)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
except OSError as exc:
|
||||
raise SyncError(
|
||||
f"could not write archive {archive_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def configured_paths() -> tuple[Path, WebDAVClient]:
|
||||
local_dir = Path(
|
||||
os.environ.get("ALFRED_LOCAL_DIR", "~/Documents/alfred")
|
||||
).expanduser()
|
||||
webdav = WebDAVClient(
|
||||
os.environ.get("ALFRED_WEBDAV_URL", DEFAULT_WEBDAV_URL),
|
||||
os.environ.get("ALFRED_REMOTE_PATH", DEFAULT_REMOTE_PATH),
|
||||
)
|
||||
return local_dir, webdav
|
||||
|
||||
|
||||
def marker(kind: str) -> bytes:
|
||||
contents = {
|
||||
"format": FORMAT_VERSION,
|
||||
"package": PACKAGE_NAME,
|
||||
"kind": kind,
|
||||
"created_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"source_host": socket.gethostname(),
|
||||
}
|
||||
return (json.dumps(contents, sort_keys=True) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def create_archive(source_dir: Path, archive_path: Path, kind: str) -> None:
|
||||
if not source_dir.is_dir():
|
||||
raise SyncError(f"local preferences directory not found: {source_dir}")
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(
|
||||
archive_path, "w", compression=zipfile.ZIP_DEFLATED
|
||||
) as archive:
|
||||
archive.write(source_dir, PACKAGE_NAME)
|
||||
for path in sorted(source_dir.rglob("*"), key=lambda item: item.as_posix()):
|
||||
if path.is_symlink():
|
||||
raise SyncError(f"symlinks are not supported in preferences: {path}")
|
||||
relative = path.relative_to(source_dir)
|
||||
archive.write(
|
||||
path,
|
||||
PurePosixPath(PACKAGE_NAME, *relative.parts).as_posix(),
|
||||
)
|
||||
archive.writestr(MARKER_NAME, marker(kind))
|
||||
except OSError as exc:
|
||||
raise SyncError(f"could not create archive {archive_path}: {exc}") from exc
|
||||
|
||||
|
||||
def validate_archive(archive_path: Path) -> None:
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
broken_file = archive.testzip()
|
||||
if broken_file is not None:
|
||||
raise SyncError(f"backup archive is corrupt: {broken_file}")
|
||||
|
||||
names = archive.namelist()
|
||||
if names.count(MARKER_NAME) != 1:
|
||||
raise SyncError(f"backup archive must contain one {MARKER_NAME} marker")
|
||||
|
||||
try:
|
||||
metadata = json.loads(archive.read(MARKER_NAME).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, KeyError) as exc:
|
||||
raise SyncError("backup marker is invalid") from exc
|
||||
|
||||
if metadata.get("format") != FORMAT_VERSION:
|
||||
raise SyncError("unsupported Alfred backup format")
|
||||
if metadata.get("package") != PACKAGE_NAME:
|
||||
raise SyncError("backup marker is for a different package")
|
||||
if metadata.get("kind") != "backup":
|
||||
raise SyncError("archive is not a primary Alfred backup")
|
||||
|
||||
seen: set[str] = set()
|
||||
has_package = False
|
||||
for name in names:
|
||||
if name in seen:
|
||||
raise SyncError(f"duplicate file in backup archive: {name}")
|
||||
seen.add(name)
|
||||
|
||||
if "\\" in name:
|
||||
raise SyncError(f"unsafe path in backup archive: {name}")
|
||||
path = PurePosixPath(name)
|
||||
if path.is_absolute() or ".." in path.parts:
|
||||
raise SyncError(f"unsafe path in backup archive: {name}")
|
||||
|
||||
if name == MARKER_NAME:
|
||||
continue
|
||||
if name == PACKAGE_NAME or name.startswith(f"{PACKAGE_NAME}/"):
|
||||
has_package = True
|
||||
continue
|
||||
raise SyncError(f"unexpected file in backup archive: {name}")
|
||||
|
||||
if not has_package:
|
||||
raise SyncError(f"backup archive does not contain {PACKAGE_NAME}")
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise SyncError(f"backup archive is not a valid ZIP: {archive_path}") from exc
|
||||
|
||||
|
||||
def extract_archive(archive_path: Path, destination: Path) -> Path:
|
||||
package_destination = destination / PACKAGE_NAME
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
for info in archive.infolist():
|
||||
if info.filename == MARKER_NAME:
|
||||
continue
|
||||
|
||||
relative = PurePosixPath(info.filename)
|
||||
target = destination.joinpath(*relative.parts)
|
||||
if info.is_dir() or info.filename.endswith("/"):
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
|
||||
mode = (info.external_attr >> 16) & 0xFFFF
|
||||
if stat.S_ISLNK(mode):
|
||||
raise SyncError(f"symlinks are not supported in backup archives: {info.filename}")
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(info) as source, target.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
permissions = mode & 0o777
|
||||
if permissions:
|
||||
target.chmod(permissions)
|
||||
|
||||
if not package_destination.is_dir():
|
||||
raise SyncError(f"backup archive does not contain {PACKAGE_NAME}")
|
||||
return package_destination
|
||||
|
||||
|
||||
def unique_backup_path(local_dir: Path) -> Path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
candidate = local_dir / f"{PACKAGE_NAME}.before-restore-{timestamp}.zip"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
return local_dir / f"{PACKAGE_NAME}.before-restore-{timestamp}-{os.getpid()}.zip"
|
||||
|
||||
|
||||
def backup_current_preferences(local_dir: Path, temporary_archive: Path) -> Path | None:
|
||||
package = local_dir / PACKAGE_NAME
|
||||
if not package.exists():
|
||||
return None
|
||||
if not package.is_dir():
|
||||
raise SyncError(f"local preferences path is not a directory: {package}")
|
||||
|
||||
backup_path = unique_backup_path(local_dir)
|
||||
create_archive(package, temporary_archive, "pre-restore")
|
||||
os.replace(temporary_archive, backup_path)
|
||||
return backup_path
|
||||
|
||||
|
||||
def backup(local_dir: Path, webdav: WebDAVClient) -> None:
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=".alfred-backup-", dir=local_dir) as temp:
|
||||
archive = Path(temp) / ARCHIVE_NAME
|
||||
create_archive(local_dir / PACKAGE_NAME, archive, "backup")
|
||||
webdav.upload(archive)
|
||||
|
||||
LOGGER.info("Backed up %s to %s", PACKAGE_NAME, webdav.display_location)
|
||||
|
||||
|
||||
def restore(local_dir: Path, webdav: WebDAVClient) -> None:
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix=".alfred-restore-", dir=local_dir) as temp:
|
||||
temp_dir = Path(temp)
|
||||
archive = temp_dir / ARCHIVE_NAME
|
||||
webdav.download(archive)
|
||||
validate_archive(archive)
|
||||
restored_package = extract_archive(archive, temp_dir)
|
||||
previous_backup = backup_current_preferences(
|
||||
local_dir, temp_dir / "current-pre-restore.zip"
|
||||
)
|
||||
if previous_backup is not None:
|
||||
LOGGER.info("Backed up current preferences to %s", previous_backup)
|
||||
|
||||
old_container = Path(
|
||||
tempfile.mkdtemp(prefix=".alfred-old-", dir=local_dir)
|
||||
)
|
||||
old_package = old_container / PACKAGE_NAME
|
||||
current_package = local_dir / PACKAGE_NAME
|
||||
try:
|
||||
if current_package.exists():
|
||||
os.replace(current_package, old_package)
|
||||
os.replace(restored_package, current_package)
|
||||
except OSError as exc:
|
||||
if old_package.exists() and not current_package.exists():
|
||||
os.replace(old_package, current_package)
|
||||
raise SyncError(f"could not install restored preferences: {exc}") from exc
|
||||
else:
|
||||
shutil.rmtree(old_container)
|
||||
|
||||
LOGGER.info("Restored %s from %s", PACKAGE_NAME, webdav.display_location)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Back up or restore Alfred preferences using a marked ZIP archive."
|
||||
)
|
||||
parser.add_argument(
|
||||
"mode",
|
||||
nargs="?",
|
||||
default="backup",
|
||||
choices=("backup", "push", "restore", "pull", "apply"),
|
||||
help="backup (default) or restore; push/pull/apply are aliases",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
try:
|
||||
local_dir, webdav = configured_paths()
|
||||
if args.mode in ("backup", "push"):
|
||||
backup(local_dir, webdav)
|
||||
else:
|
||||
restore(local_dir, webdav)
|
||||
except SyncError as exc:
|
||||
LOGGER.error("sync-alfred: %s", exc)
|
||||
return 1
|
||||
except OSError as exc:
|
||||
LOGGER.error("sync-alfred: filesystem error: %s", exc)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user