Grant Alexander / Documentation / / 0 Comments

Was this useful to you? Please consider a donation https://ko-fi.com/xanderphotoau

iCloud Photo Backup + Recycle Bin

Backup iCloud Photos to NAS

Using Docker image boredazfck/icloudpd, you can mirror iCloud Photo Library to Download and Mirror your photos to your self-hosted NAS system, creating a local backup.

icloudpd also supports auto_delete, and with this function turned on, deleting a photo on your phone will also hard-delete it from the NAS. This creates a problem as there is no redundancy or fall-back to recover a deleted item on the NAS.

This enhancement adds a self-purging recycle bin that runs alongside icloudpd. The ETL uses hardlinks, enabling a 30-day recovery that mirrors how iCloud Photos Recently Deleted operates.

Image: boredazfcuk/icloudpd NAS: QNAP · Portainer Retention: 30 days, matches iCloud
photo.jpg one file · one inode Library/…/photo.jpg the iCloud mirror link .shadow/…/photo.jpg hidden hardlink (the seed) deleted in iCloud reconcile deleted/2026-06-20/ …/photo.jpg archive? yes · keep iCloud/Archive manual copy / move syncs Immich personal cloud for the archive no left in bin purged after 30 days
The trick: a second hardlink keeps the bytes alive after icloudpd unlinks the Library copy.
01

The problem

Photo-cleaning apps like Cully, This Time Last Year, and most other library tidy-up tools only let you delete photos from iCloud. None of them let you move the ones worth keeping into an album instead. That left me with no safe way to clear out my iCloud library without risking photos I might still want.

This recycle bin is my workaround: it keeps a backup of everything those apps delete, so I can review it on the NAS and manually copy the keepers into my Archive folder. From there, a separate Docker service, like Immich, turns that archive into a personal cloud: a searchable photo library of everything I’ve pulled out of iCloud.

02

The workflow (ETL)

The recycle bin isn’t a standalone gadget — it’s one stage in a small pipeline built from two boredazfcuk/icloudpd containers on a QNAP NAS (Portainer via Container Station), plus the sidecar. The pipeline exists to make culling safe: clean up iCloud from your phone, but keep a copy on the NAS long enough to triage into a permanent archive.

Most iOS culling apps — Cully, This Time Last Year, and the like — only let you delete photos; they can’t move them to an album. So there’s no clean “archive this, don’t lose it” path. This pipeline works around that: the delete becomes the signal, and the recycle bin becomes the staging area you copy keepers out of.

  1. Cull · iOS

    Delete in iCloud

    Use Cully or This Time Last Year to clear out the iCloud library. They can only delete — which is exactly the gap the rest of this fills.

  2. Extract · daily

    Library mirror

    icloudpd-library downloads everything in iCloud to ~/iCloud/Library on the NAS, once a day.

  3. Replicate · delete

    Sync the deletions

    Once the download completes, Library removes anything you deleted in iCloud, keeping the folder an exact mirror of your phone.

  4. Capture · 30 days

    Recycle bin

    The sidecar catches each of those deletions into .recyclebin/deleted/<date> and retains it for 30 days — the same window as iCloud’s own Recently Deleted.

  5. Load · manual

    Copy keepers to Archive

    Browse the bin and copy anything worth keeping into ~/iCloud/Archive — a deliberate, manual triage rather than an automatic dump.

The Archive instance — runs in parallel

A second container, icloudpd-albums, mirrors the same download process into ~/iCloud/Archive — but it only pulls from the iCloud Archive album, and it never deletes. Anything in that album (or copied into the folder by hand from the recycle bin) stays put even after it’s gone from iCloud, giving you a permanent, offline archive that outlives the Library mirror.

03

How the recycle bin works

A tiny companion container watches your auto-delete folder and runs three steps each pass. The whole thing rests on one Unix fact: a hardlink is a second name for the same data, so a file isn’t truly gone until its last name is removed.

Seed

Link every file

Walk the live folder and create a hardlink to each file inside a hidden .shadow tree. Same bytes, second name — costs essentially no disk.

Reconcile

Catch the deletes

For each shadow link whose live file is gone, the data survives via that link. Move it into deleted/<date>/ — your recoverable copy.

Purge

Age it out

Delete anything in the bin older than the retention window (default 30 days). Only then are those bytes actually freed.

It tells a real delete from a rename by checking the link count: a genuinely deleted file has only its shadow link left, while a moved file still has a live link elsewhere and is quietly skipped. The watched photos are never touched — the sidecar only ever reads them and acts on links it made itself.

04

Setup

A script on disk plus the stack. The full iCloud stack is shown below (anonymised); the recycle-bin is the third container. Paths are QNAP examples — adjust for your system. The one hard rule is the mount, called out below.

  1. Create a folder for the script and drop the Python file in it, e.g. /share/CACHEDEV1_DATA/Container/icloudpd-recycle/icloudpd_recyclebin.py
  2. Add the recycle-bin service (the third container in the stack below) to the stack that runs icloudpd, then redeploy — or deploy the whole stack if you’re starting fresh.
  3. Point WATCH_PATHS at whichever folder your auto_delete=true instance writes to (not your archive folders, which never delete).
  4. Deploy with DRY_RUN: "1" first to preview in the logs, then switch to "0".
The one hard rule

The live folder and the recycle bin must sit on a single shared bind mount. Hardlinks fail across separate Docker mounts (EXDEV) even on the same volume — so mount the common parent (here /share/CACHEDEV1_DATA/iCloud) as one volume and keep both the live folder and .recyclebin inside it.

The full stack

The complete setup is three containers: icloudpd-library mirrors all of iCloud and replicates deletions, icloudpd-albums archives chosen albums and never deletes, and icloudpd-recyclebin is the sidecar that catches Library’s deletions. Swap in your own apple_id and Pushover keys, and adjust the paths and timezone.

docker-compose.yml — full iCloud stack (anonymised)
version: "3.8"
services:

  # 1. LIBRARY — mirrors ALL of iCloud and replicates deletions (auto_delete=true).
  #    This is the folder the recycle-bin watches.
  icloudpd-library:
    image: boredazfcuk/icloudpd:latest
    container_name: icloudpd-library
    restart: always
    environment:
      - TZ=Australia/Melbourne
      - apple_id=your-apple-id@example.com
      - authentication_type=MFA
      - auto_delete=true
      - config_file=/config/icloudpd.conf
      - delete_empty_directories=true
      - delete_notifications=true
      - download_notifications=true
      - download_path=/iCloud/Library
      - folder_structure={:%Y/%Y-%m/%Y-%m-%d}
      - notification_type=Pushover
      - pushover_token=YOUR_PUSHOVER_APP_TOKEN
      - pushover_user=YOUR_PUSHOVER_USER_KEY
      - set_exif_datetime=false
      - skip_check=true
      - startup_notification=true
      - synchronisation_interval=86400
    volumes:
      - /share/CACHEDEV1_DATA/Container/icloudpd/config-library:/config
      - /share/CACHEDEV1_DATA/iCloud/Library:/iCloud/Library
    labels:
      - helper.command.initialise=sync-icloud.sh --Initialise
      - helper.command.reauth=reauth.sh

  # 2. ALBUMS — downloads only chosen iCloud albums and NEVER deletes
  #    (auto_delete=false): your permanent, offline archive.
  icloudpd-albums:
    image: boredazfcuk/icloudpd:latest
    container_name: icloudpd-albums
    restart: always
    environment:
      - TZ=Australia/Melbourne
      - apple_id=your-apple-id@example.com
      - authentication_type=MFA
      - auto_delete=false
      - config_file=/config/icloudpd.conf
      - delete_empty_directories=false
      - delete_notifications=false
      - download_notifications=true
      - download_path=/iCloud
      - folder_structure={:%Y/%Y-%m/%Y-%m-%d}
      - notification_type=Pushover
      - pushover_token=YOUR_PUSHOVER_APP_TOKEN
      - pushover_user=YOUR_PUSHOVER_USER_KEY
      - set_exif_datetime=false
      - skip_check=true
      - startup_notification=true
      - synchronisation_interval=87400
      - albums_with_dates=true
      - photo_album=Archive,Favourites,Content
    volumes:
      - /share/CACHEDEV1_DATA/Container/icloudpd/config-albums:/config
      - /share/CACHEDEV1_DATA/iCloud/Archive:/iCloud/Archive
      - /share/CACHEDEV1_DATA/iCloud/Favourites:/iCloud/Favourites
      - /share/CACHEDEV1_DATA/iCloud:/iCloud
    labels:
      - helper.command.initialise=sync-icloud.sh --Initialise
      - helper.command.reauth=reauth.sh

  # 3. RECYCLE-BIN — the sidecar. Watches Library only and preserves its
  #    deletions for 30 days, using the script published below.
  icloudpd-recyclebin:
    image: python:3.13-slim
    container_name: icloudpd-recyclebin
    restart: always
    environment:
      TZ: Australia/Melbourne
      WATCH_PATHS: /data/icloud/Library          # the auto_delete folder only
      RECYCLE_ROOT: /data/icloud/.recyclebin
      RETENTION_DAYS: "30"
      RUN_AT: "03:30"                             # after the Library sync finishes
      RUN_ON_START: "1"
      DRY_RUN: "0"                                # "1" to preview without changes
    volumes:
      # ONE shared bind mount holds BOTH the live folder and the recycle bin
      - /share/CACHEDEV1_DATA/iCloud:/data/icloud
      - /share/CACHEDEV1_DATA/Container/icloudpd-recycle:/app:ro
    command: ["python", "/app/icloudpd_recyclebin.py"]

The script

Pure standard library — no pip install, no network. Save it as icloudpd_recyclebin.py in the folder from step 1.

icloudpd_recyclebin.py
#!/usr/bin/env python3
"""
icloudpd_recyclebin.py
----------------------
A safety net for iCloudpd's --auto-delete on a NAS.

iCloudpd hard-deletes (unlink) local files when the matching photo is removed
in iCloud. This script gives those deletions a recoverable "Deleted" folder,
WITHOUT doubling disk usage, by using hardlinks.

How it works each pass:
  1. SEED      - For every live file, ensure a hardlink to it exists in a
                 hidden .shadow tree. (Hardlink = same inode, ~0 extra bytes.)
  2. RECONCILE - Walk the .shadow tree. If a shadow entry's live counterpart
                 is gone AND the inode now has only this one link, iCloudpd
                 deleted it -> move the hardlink into deleted/<date>/...
                 If the inode still has other links, the file was just
                 moved/renamed in the live tree -> drop the stale shadow link.
  3. PURGE     - Delete recycle entries older than RETENTION_DAYS.

Pure standard library. No pip install needed.

REQUIREMENT: every WATCH_PATH and the RECYCLE_ROOT must sit on the SAME
filesystem / same Docker bind mount, or hardlinks (os.link) will fail with
EXDEV. Mount the common parent (e.g. /share/CACHEDEV1_DATA/iCloud) as ONE
volume and point both inside it.

Environment variables:
  WATCH_PATHS       Comma-separated dirs iCloudpd manages with --auto-delete.
                    e.g. "/data/icloud/Library,/data/icloud/Content"
                    Default: /data/icloud/Library
  RECYCLE_ROOT      Where .shadow and deleted/ live (hidden, NOT scanned).
                    Default: /data/icloud/.recyclebin
  RETENTION_DAYS    Days to keep deleted items. Default: 30
  RUN_AT            Daily run time "HH:MM" (24h, container local time).
                    Default: 03:30. Ignored if RUN_INTERVAL_SECONDS set.
  RUN_INTERVAL_SECONDS  Run every N seconds instead of a fixed time. Optional.
  RUN_ON_START      "1" to run one pass immediately on startup. Default: 1
  ONE_SHOT          "1" to run a single pass and exit (no loop). Default: 0
  DRY_RUN           "1" logs what it WOULD do, changes nothing. Default: 0
  EXCLUDE_DIRS      Comma-separated dir names to skip anywhere in the tree.
                    Default: @eaDir,@Recycle,.@__thumb,.recyclebin,#recycle
"""

import os
import sys
import shutil
import time
from datetime import datetime, timedelta

# ----------------------------- config -----------------------------

def env(key, default):
    v = os.environ.get(key)
    return v if v not in (None, "") else default

WATCH_PATHS = [p.strip() for p in env("WATCH_PATHS", "/data/icloud/Library").split(",") if p.strip()]
RECYCLE_ROOT = env("RECYCLE_ROOT", "/data/icloud/.recyclebin")
RETENTION_DAYS = int(env("RETENTION_DAYS", "30"))
RUN_AT = env("RUN_AT", "03:30")
RUN_INTERVAL_SECONDS = env("RUN_INTERVAL_SECONDS", "")
RUN_ON_START = env("RUN_ON_START", "1") == "1"
ONE_SHOT = env("ONE_SHOT", "0") == "1"
DRY_RUN = env("DRY_RUN", "0") == "1"
EXCLUDE_DIRS = set(
    d.strip() for d in env("EXCLUDE_DIRS", "@eaDir,@Recycle,.@__thumb,.recyclebin,#recycle").split(",") if d.strip()
)

SHADOW_ROOT = os.path.join(RECYCLE_ROOT, ".shadow")
DELETED_ROOT = os.path.join(RECYCLE_ROOT, "deleted")

# label -> absolute live root. Label = folder basename, used as a namespace
# inside the shadow/deleted trees so paths map back unambiguously.
def build_watches():
    watches = {}
    for p in WATCH_PATHS:
        p = os.path.abspath(p)
        label = os.path.basename(p.rstrip("/")) or "root"
        if label in watches:
            log(f"WARNING: duplicate watch label '{label}' for {p}; skipping")
            continue
        watches[label] = p
    return watches


def log(msg):
    print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}", flush=True)


def is_excluded(path):
    parts = path.split(os.sep)
    return any(part in EXCLUDE_DIRS for part in parts)


def iter_files(root):
    for dirpath, dirnames, filenames in os.walk(root):
        # prune excluded dirs in-place so we don't descend into them
        dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
        for name in filenames:
            full = os.path.join(dirpath, name)
            if os.path.islink(full):
                continue
            yield full


def same_inode(a, b):
    try:
        sa, sb = os.lstat(a), os.lstat(b)
    except FileNotFoundError:
        return False
    return (sa.st_dev, sa.st_ino) == (sb.st_dev, sb.st_ino)


# ----------------------------- phases -----------------------------

def seed(watches):
    """Ensure every live file has a current hardlink in .shadow."""
    created = refreshed = 0
    for label, root in watches.items():
        if not os.path.isdir(root):
            log(f"SEED: watch path missing, skipping: {root}")
            continue
        for live in iter_files(root):
            rel = os.path.relpath(live, root)
            shadow = os.path.join(SHADOW_ROOT, label, rel)
            if os.path.exists(shadow):
                if same_inode(live, shadow):
                    continue
                # live file was replaced (re-downloaded): refresh the link
                if not DRY_RUN:
                    os.unlink(shadow)
                    os.makedirs(os.path.dirname(shadow), exist_ok=True)
                    os.link(live, shadow)
                refreshed += 1
            else:
                if not DRY_RUN:
                    os.makedirs(os.path.dirname(shadow), exist_ok=True)
                    try:
                        os.link(live, shadow)
                    except OSError as e:
                        if e.errno == 18:  # EXDEV
                            log("FATAL: cross-device link. WATCH_PATHS and "
                                "RECYCLE_ROOT must be on the same bind mount/volume.")
                            sys.exit(2)
                        raise
                created += 1
    log(f"SEED: {created} new, {refreshed} refreshed{' (dry-run)' if DRY_RUN else ''}")


def reconcile(watches):
    """Move shadow entries whose live file is gone into deleted/<date>/."""
    today = datetime.now().strftime("%Y-%m-%d")
    deleted = moved_alias = 0
    if not os.path.isdir(SHADOW_ROOT):
        return
    for label in os.listdir(SHADOW_ROOT):
        label_root = os.path.join(SHADOW_ROOT, label)
        if not os.path.isdir(label_root):
            continue
        live_root = watches.get(label)
        for shadow in iter_files(label_root):
            rel = os.path.relpath(shadow, label_root)
            live = os.path.join(live_root, rel) if live_root else None
            if live and os.path.exists(live):
                continue  # still present, nothing to do
            # Live counterpart is gone.
            st = os.lstat(shadow)
            if st.st_nlink >= 2:
                # The data still exists via another hardlink (file was
                # moved/renamed in the live tree; SEED already linked the
                # new path). Drop this stale shadow link only.
                if not DRY_RUN:
                    os.unlink(shadow)
                moved_alias += 1
            else:
                # Sole remaining link -> genuine deletion. Preserve it.
                dest = os.path.join(DELETED_ROOT, today, label, rel)
                if not DRY_RUN:
                    os.makedirs(os.path.dirname(dest), exist_ok=True)
                    # avoid clobbering if same path deleted twice in one day
                    final = dest
                    n = 1
                    while os.path.exists(final):
                        base, ext = os.path.splitext(dest)
                        final = f"{base}.dup{n}{ext}"
                        n += 1
                    os.replace(shadow, final)
                deleted += 1
                log(f"  recycled: {label}/{rel}")
    log(f"RECONCILE: {deleted} recycled, {moved_alias} stale aliases pruned"
        f"{' (dry-run)' if DRY_RUN else ''}")


def purge():
    """Delete recycle date-folders older than RETENTION_DAYS; tidy empties."""
    removed = 0
    if os.path.isdir(DELETED_ROOT):
        cutoff = datetime.now().date() - timedelta(days=RETENTION_DAYS)
        for name in os.listdir(DELETED_ROOT):
            path = os.path.join(DELETED_ROOT, name)
            if not os.path.isdir(path):
                continue
            try:
                d = datetime.strptime(name, "%Y-%m-%d").date()
            except ValueError:
                continue
            if d < cutoff:
                if not DRY_RUN:
                    shutil.rmtree(path, ignore_errors=True)
                removed += 1
                log(f"  purged old recycle folder: {name}")
    # remove now-empty dirs in the shadow tree
    if os.path.isdir(SHADOW_ROOT) and not DRY_RUN:
        for dirpath, dirnames, filenames in os.walk(SHADOW_ROOT, topdown=False):
            if dirpath == SHADOW_ROOT:
                continue
            if not os.listdir(dirpath):
                try:
                    os.rmdir(dirpath)
                except OSError:
                    pass
    log(f"PURGE: {removed} old recycle folder(s) removed"
        f"{' (dry-run)' if DRY_RUN else ''}")


def run_pass():
    watches = build_watches()
    log(f"PASS start | watching: {', '.join(f'{k}={v}' for k, v in watches.items())}")
    log(f"recycle root: {RECYCLE_ROOT} | retention: {RETENTION_DAYS}d | dry_run: {DRY_RUN}")
    if not DRY_RUN:
        os.makedirs(SHADOW_ROOT, exist_ok=True)
        os.makedirs(DELETED_ROOT, exist_ok=True)
    seed(watches)        # protect new/changed files first
    reconcile(watches)   # then detect what iCloudpd removed
    purge()
    log("PASS done")


# ----------------------------- scheduling -----------------------------

def seconds_until(hhmm):
    now = datetime.now()
    try:
        hh, mm = (int(x) for x in hhmm.split(":"))
    except Exception:
        log(f"Bad RUN_AT '{hhmm}', defaulting to 03:30")
        hh, mm = 3, 30
    nxt = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
    if nxt <= now:
        nxt += timedelta(days=1)
    return (nxt - now).total_seconds(), nxt


def main():
    log("icloudpd recycle-bin starting")
    if RUN_ON_START or ONE_SHOT:
        run_pass()
    if ONE_SHOT:
        return
    while True:
        if RUN_INTERVAL_SECONDS:
            wait = int(RUN_INTERVAL_SECONDS)
            log(f"sleeping {wait}s until next pass")
            time.sleep(wait)
        else:
            wait, nxt = seconds_until(RUN_AT)
            log(f"next pass at {nxt.strftime('%Y-%m-%d %H:%M')} ({int(wait)}s)")
            time.sleep(wait)
        run_pass()


if __name__ == "__main__":
    main()
05

Operating it

Scheduled

It runs a full pass daily at RUN_AT (and once on container start). Set that time to land after your icloudpd sync finishes, so it always reconciles after the delete pass.

On demand

If you trigger syncs manually, run a pass on demand two ways:

  • Restart the container — with RUN_ON_START=1 it does a pass immediately, then resumes its schedule.
  • One-shot, without disturbing the loop:
shell
docker exec -e ONE_SHOT=1 icloudpd-recyclebin python /app/icloudpd_recyclebin.py

Order matters: run the recycle pass after the icloudpd sync has finished deleting, or there’s nothing yet to catch.

Where the recycled files live

Recovered files are organised by the date they were caught:

/share/CACHEDEV1_DATA/iCloud/.recyclebin/deleted/<date>/Library/…

The .recyclebin name is hidden, so enable Show hidden files in File Station (or your SMB client) to browse it. Rename RECYCLE_ROOT to something without the leading dot if you’d rather it show as a normal folder.

Restoring a file

Copy it out of the bin to somewhere safe — but not back into the watched folder. That folder is an iCloud mirror, so if the photo is no longer in iCloud, the next sync will just delete it again. Restore it elsewhere, or re-add it to iCloud first.

06

Limitations & notes

  • Seed before delete. A file is only recoverable if it was seeded on an earlier pass. A photo added and deleted within the same cycle, before any pass runs, won’t be caught. The daily pass covers normal use; only matters in tight back-to-back testing.
  • Deleted bytes do take space. While a file is alive, its shadow link is free. Once the live copy is gone, the bin holds the only copy, so it occupies real space until it’s purged — exactly as a recycle bin should.
  • Watch only auto-delete folders. Pointing it at folders that never delete just builds shadow links for no reason.
  • It never deletes your photos. The sidecar only reads the live folder and moves links it created. The actual deletes are icloudpd’s; this just catches what falls.
07

Customise it

Different NAS, different paths, different culling habits — the parts are generic, but the paths and timing are yours. Paste the prompt below into an AI assistant (Claude, ChatGPT, or similar) together with the script above, fill in the angle-bracket placeholders, and it’ll tailor the compose service and deploy steps to your environment — and check your paths against the one hard rule before you run anything.

prompt — adapt this setup to your own NAS
I want to add a "recycle bin" safety net to my icloudpd photo-backup setup. When
icloudpd hard-deletes a file locally (because I deleted the photo in iCloud), I
want a recoverable copy kept for a retention window instead of it being lost.

How the solution works: a small sidecar container (Python, standard library only,
no pip installs) watches the folder that auto-deletes. Each pass it:
  1. SEED      - makes a hardlink to every live file inside a hidden .shadow tree
                 (same bytes, second name -> ~0 extra disk).
  2. RECONCILE - any file now missing from the live folder but still present via
                 its shadow link was deleted -> it moves that link into
                 deleted/<date>/, i.e. the recycle bin.
  3. PURGE     - removes anything in the bin older than the retention window.

Critical constraints to respect:
  - The live folder and the recycle-bin folder MUST sit on a SINGLE shared bind
    mount. Hardlinks fail across separate Docker mounts (EXDEV) even on the same
    volume, so mount the common parent as one volume with both inside it.
  - Only watch the folder that actually auto-deletes. Do NOT watch archive /
    never-delete folders.
  - A file is only recoverable if it was seeded on an earlier pass, so the
    recycle pass must run AFTER the icloudpd sync, and the file must have existed
    through at least one prior pass.

(The reference script is the icloudpd_recyclebin.py published with this guide —
assume I can provide it; build the compose and steps around it.)

My setup (fill these in):
  - NAS / OS:                 <e.g. QNAP QTS, Synology DSM, generic Linux>
  - Container manager:        <e.g. Portainer via Container Station, compose CLI>
  - icloudpd image:           <e.g. boredazfcuk/icloudpd:latest>
  - Auto-delete host path:    <e.g. /share/CACHEDEV1_DATA/iCloud/Library>
  - Common parent to mount:   <e.g. /share/CACHEDEV1_DATA/iCloud>
  - Where I'll store the script: <e.g. /share/CACHEDEV1_DATA/Container/icloudpd-recycle>
  - Timezone:                 <e.g. Australia/Melbourne>
  - Retention (days):         <e.g. 30>
  - When my icloudpd sync finishes (to schedule the pass): <e.g. ~03:00>

Please:
  1. Give me the sidecar's docker-compose service tailored to my paths and timezone.
  2. Confirm my paths satisfy the single-mount hardlink rule, or tell me how to fix them.
  3. Give me the deploy steps for my container manager.
  4. Tell me how to trigger a pass on demand, and where to find and restore recycled files.
  5. Flag anything specific to my NAS (hidden-folder browsing, recycle-bin behaviour, permissions).
Ask me for anything missing before giving the final config.
icloudpd recycle bin · hardlink safety net · built with assistance from Claude AI to write the code & scripts · please use at your own risk.

Leave a Reply