# iCloud Vault Access on Linux (via pyicloud)

Session reference — iCloud-backed Obsidian vaults on a headless Linux machine.

## Prerequisites

- `pip3 install pyicloud`
- User's actual Apple ID password (not an App-Specific Password)
- Apple ID email stored in `~/.hermes/.env` as `APPLE_EMAIL`
- Apple ID password stored in `~/.hermes/.env` as `APPLE_ID_PASSWORD`

## Pitfalls

1. **App-Specific Passwords don't work** — pyicloud authenticates as the full iCloud web user, and Apple rejects App-Specific Passwords for that flow. You must use the actual Apple ID password.
2. **Parsing `.env` with inline `-c` commands breaks** — passwords containing quotes or `***` placeholders are hard to inline correctly. Write a Python script file instead (e.g., `/tmp/icloud_login.py`) and run that.
3. **`.env` line may have a `***` placeholder prefix** — If the `.env` line looks like `APPLE_ID_PASSWORD=*** Have A Dog With 4 Paws"`, strip the leading `*** ` before using the password.
4. **Wrong `DriveNode` API** — pyicloud's `DriveNode` uses `get_children()` (not `.dir()`/`.files()`). Iterate `for item in root.get_children():`.
5. **Session persistence** — After 2FA validation, the session cookie is cached in `~/.pyicloud/`. Subsequent runs within the same session usually skip 2FA, but re-authenticate if the cookie expires.
6. **Directory creation lag** — Newly created directories on iCloud may not be immediately visible via `get_children()`. Cache created directory nodes in a local dict and reuse them instead of re-querying the parent.
7. **Upload object needs `.name`** — pyicloud's `.upload()` requires an object with a `.name` attribute. Wrap bytes in a `NamedBytesIO` (subclass of `io.BytesIO` with `.name` set).
8. **Overwriting files throws 412** — Deleting an existing file before uploading the replacement avoids `Precondition Failed (412)` errors.
9. **Existing directory nodes may return dicts** — When re-scanning iCloud after a prior sync, existing directory nodes can be stale dict objects without `.upload()` or `.mkdir()`. Always verify the node has the required method before using it. If not, re-create the directory or use the parent node directly.
10. **Never inline `.env` password parsing in shell one-liners** — Passwords with quotes, spaces, or `***` placeholders break `export PASS=$(grep ...)` constructions. Write a dedicated Python script to read `.env` and authenticate.

## Working recipe

```python
from pyicloud import PyiCloudService
import os

# Load credentials from .env
env_path = os.path.expanduser('~/.hermes/.env')
email = None
password = None
with open(env_path, 'r') as f:
    for line in f:
        line = line.strip()
        if line.startswith('APPLE_EMAIL='):
            email = line.split('=', 1)[1].strip().strip('"').strip("'")
        elif line.startswith('APPLE_ID_PASSWORD='):
            raw = line.split('=', 1)[1].strip().strip('"').strip("'")
            # Strip leading placeholder marker if present
            if raw.startswith('*** '):
                raw = raw[4:]
            password = raw

api = PyiCloudService(email, password)

# Handle 2FA interactively
if api.requires_2fa:
    code = input("Apple 2FA code: ")
    if not api.validate_2fa_code(code):
        raise RuntimeError("Invalid 2FA code")

# Access iCloud Drive
root = api.drive.root
for item in root.get_children():
    print(f"  [{item.type}] {item.name}")
```

## Finding the Obsidian vault

The Obsidian app library appears under iCloud Drive root as `[app_library] Obsidian`. Its children are the individual vault folders (e.g., `DND - Joris`, `Pirate Campain`, `Work`).

## Downloading files

```python
resp = item.open()
data = resp.content        # bytes
with open(local_path, "wb") as f:
    f.write(data)
```

## Uploading files back to iCloud (sync changes)

pyicloud's `.upload()` is finicky. Two known pitfalls:

1. **The upload object needs a `.name` attribute.** Raw `bytes` fail with `AttributeError: 'bytes' object has no attribute 'name'`. Wrap in a `NamedBytesIO`.
2. **Overwriting an existing file throws 412 Precondition Failed.** Delete the old file first, then upload the new one.

### Working upload recipe

```python
import io

class NamedBytesIO(io.BytesIO):
    def __init__(self, initial_bytes, name):
        super().__init__(initial_bytes)
        self.name = name

# campaign = obsidian["Pirate Campain"]  # DriveNode for the vault folder

# 1. Delete old file
old = campaign["Ideas.md"]
old.delete()

# 2. Upload new file
with open("/local/path/Ideas.md", "rb") as f:
    data = f.read()
file_obj = NamedBytesIO(data, "Ideas.md")
campaign.upload(file_obj)

# 3. Verify by re-reading
children = campaign.get_children()
for child in children:
    if child.name == "Ideas.md":
        print(f"Verified size: {child.size}")
```

> `.upload()` returns `None` on success. Re-list the parent folder to verify the new size/date.

## Full bidirectional sync workflow

1. **Download** — list vault children, download each file locally
2. **Work** — use standard file tools (`read_file`, `write_file`, `patch`) on the local copy
3. **Upload** — delete-then-upload each modified file back to iCloud
4. **Verify** — re-list the iCloud folder and check sizes match

For bulk download, `icloudpd` (iCloud Photos Downloader) may be easier for simple photo sync, but for structured vault files the pyicloud script approach gives full control.
