Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Support ladder v0.3.0 iCloud-only asset export

Detect Automation permission errors from ladder and abort early instead
of retrying every batch. Update init to list all required permissions.
Update docs with AppleScript fallback and Automation permission setup.

Bump to v0.2.5.

+101 -12
+16
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## 0.2.5 4 + 5 + Support for ladder v0.3.0 — iCloud-only asset export via AppleScript fallback. 6 + 7 + - **Permission error detection** — when ladder reports a missing Automation 8 + permission, attic aborts immediately with a clear message instead of retrying 9 + every batch 10 + - **Updated init output** — `attic init` now lists all required permissions 11 + (Photos, Full Disk Access, Automation) 12 + - **iCloud-only error context** — export errors for iCloud-only assets now note 13 + that the AppleScript fallback was attempted 14 + - Updated architecture docs to reflect ladder's AppleScript fallback 15 + - Updated unattended backup guide with Automation permission setup 16 + 17 + Compatible with ladder v0.3.0. 18 + 3 19 ## 0.2.4 4 20 5 21 Review fixes: type safety, error handling, cleanup.
+1 -1
cli/mod.ts
··· 14 14 15 15 const main = new Command() 16 16 .name("attic") 17 - .version("0.2.4") 17 + .version("0.2.5") 18 18 .description("Back up your iCloud Photos library to S3-compatible storage") 19 19 .action(function (this: Command) { 20 20 this.showHelp();
+26 -2
cli/src/commands/backup.ts
··· 10 10 import type { Manifest, ManifestStore } from "../manifest/manifest.ts"; 11 11 import { isBackedUp, markBackedUp } from "../manifest/manifest.ts"; 12 12 import type { ExportBatchResult, Exporter } from "../export/exporter.ts"; 13 - import { isTimeoutError, removeStagedFile } from "../export/exporter.ts"; 13 + import { 14 + isPermissionError, 15 + isTimeoutError, 16 + removeStagedFile, 17 + } from "../export/exporter.ts"; 14 18 import type { S3Provider } from "../storage/s3-client.ts"; 15 19 import { formatBytes } from "../format.ts"; 16 20 import { startSpinner } from "../spinner.ts"; ··· 354 358 355 359 // Upload whatever succeeded from individual retries 356 360 await uploadExported(combined); 361 + } else if (isPermissionError(error)) { 362 + // Permission error: abort all remaining batches — retrying won't help 363 + const msg = error instanceof Error ? error.message : String(error); 364 + console.error(`\n ${msg}`); 365 + console.error( 366 + `\n Fix the permission issue and run \`attic backup\` again.\n`, 367 + ); 368 + for (const uuid of batchUuids) { 369 + report.errors.push({ uuid, message: msg }); 370 + report.failed++; 371 + logger.error(uuid, msg); 372 + } 373 + // Mark all remaining assets as failed too 374 + for ( 375 + const uuid of pending.slice(i + options.batchSize).map((a) => a.uuid) 376 + ) { 377 + report.errors.push({ uuid, message: msg }); 378 + report.failed++; 379 + } 380 + break; 357 381 } else { 358 382 // Non-timeout error: fail the whole batch 359 383 const msg = error instanceof Error ? error.message : String(error); ··· 461 485 function exportErrorDetail(asset: PhotoAsset, message: string): string { 462 486 const hints: string[] = []; 463 487 if (asset.cloudLocalState === CloudLocalState.ICLOUD_ONLY) { 464 - hints.push("asset is iCloud-only (original not downloaded locally)"); 488 + hints.push("iCloud-only asset (AppleScript fallback attempted)"); 465 489 } 466 490 if (!asset.originalFileSize) { 467 491 hints.push("no original file size recorded");
+6 -1
cli/src/commands/init.ts
··· 111 111 ); 112 112 console.log(" Done."); 113 113 114 + console.log("\n Setup complete.\n"); 115 + console.log(" Required permissions (System Settings > Privacy & Security):"); 116 + console.log(" - Photos: grant access to ladder"); 117 + console.log(" - Full Disk Access: enable for attic and ladder"); 114 118 console.log( 115 - '\n Setup complete. Run "attic scan" to see your Photos library.\n', 119 + " - Automation: grant ladder access to Photos (for iCloud-only assets)", 116 120 ); 121 + console.log('\n Run "attic scan" to see your Photos library.\n'); 117 122 }
+20 -4
cli/src/export/exporter.ts
··· 40 40 } 41 41 } 42 42 43 + /** Thrown when ladder reports a missing macOS permission (e.g. Automation). */ 44 + export class LadderPermissionError extends Error { 45 + constructor(message: string) { 46 + super(message); 47 + this.name = "LadderPermissionError"; 48 + } 49 + } 50 + 43 51 const DEFAULT_STAGING_DIR = join( 44 52 Deno.env.get("HOME") ?? "~", 45 53 ".attic", ··· 172 180 const result = await raceSubprocess(process, timeoutMs, signal); 173 181 174 182 if (result.code !== 0) { 175 - const err = new TextDecoder().decode(result.stderr); 176 - throw new Error( 177 - `ladder exited with code ${result.code}: ${err.trim()}`, 178 - ); 183 + const err = new TextDecoder().decode(result.stderr).trim(); 184 + if (err.includes("Automation permission")) { 185 + throw new LadderPermissionError( 186 + err.replace(/^ladder:\s*/, ""), 187 + ); 188 + } 189 + throw new Error(`ladder exited with code ${result.code}: ${err}`); 179 190 } 180 191 181 192 const output = new TextDecoder().decode(result.stdout); ··· 196 207 /** Check whether an error is a ladder timeout. */ 197 208 export function isTimeoutError(error: unknown): boolean { 198 209 return error instanceof LadderTimeoutError; 210 + } 211 + 212 + /** Check whether an error is a ladder permission issue. */ 213 + export function isPermissionError(error: unknown): boolean { 214 + return error instanceof LadderPermissionError; 199 215 } 200 216 201 217 /** Create an exporter that shells out to the ladder binary. */
+8 -1
docs/architecture.md
··· 88 88 Ladder output is validated at the trust boundary with 89 89 `assertExportBatchResult()`. 90 90 91 + When PhotoKit can't find an asset (typically iCloud-only with Optimize Storage), 92 + ladder falls back to AppleScript via Photos.app. Photos.app handles the iCloud 93 + download transparently. The AppleScript fallback runs sequentially after all 94 + PhotoKit exports complete. The response format is identical — attic sees no 95 + difference between PhotoKit and AppleScript exports. 96 + 91 97 ### 3. Upload 92 98 93 99 For each exported file, attic: ··· 166 172 ## What attic doesn't do 167 173 168 174 - **Modify Photos.sqlite** — read-only access, always 169 - - **Download from iCloud** — relies on Photos having local copies of originals 175 + - **Download from iCloud directly** — ladder handles iCloud-only assets via 176 + AppleScript fallback through Photos.app 170 177 - **Delete from S3** — the backup is append-only; there is no prune or cleanup 171 178 command 172 179 - **Back up thumbnails** — only original files and metadata
+24 -3
docs/unattended-backups.md
··· 26 26 If you skip this, backups will fail with a permission error when trying to read 27 27 the Photos database. 28 28 29 + ## Automation permission (for iCloud-only assets) 30 + 31 + When "Optimize Mac Storage" is enabled, some assets exist only in iCloud and are 32 + invisible to PhotoKit. Ladder uses an AppleScript fallback via Photos.app to 33 + export these. This requires **Automation permission**. 34 + 35 + Open **System Settings > Privacy & Security > Automation** and grant 36 + `/opt/homebrew/bin/ladder` access to **Photos**. 37 + 38 + The easiest way to trigger the permission prompt is to run a test backup 39 + interactively: 40 + 41 + ```bash 42 + attic backup --limit 1 43 + ``` 44 + 45 + If the permission is missing, attic will show a clear error message and abort 46 + before doing any work. For unattended LaunchAgent runs, the permission must be 47 + granted interactively once beforehand. 48 + 29 49 ## LaunchAgent setup 30 50 31 51 Create a LaunchAgent plist that runs `attic backup` daily. ··· 200 220 next run picks up where it left off. 201 221 - **Disk space**: Attic stages files temporarily in `~/.attic/staging/` during 202 222 export. Make sure there's enough free space for a batch (default 50 assets). 203 - - **iCloud-only assets**: If most of your library is iCloud-only, the first 204 - backup will download everything from iCloud. This can be slow on the initial 205 - run. 223 + - **iCloud-only assets**: Assets that only exist in iCloud are exported via 224 + AppleScript fallback (one at a time, sequentially). This is slower than the 225 + normal PhotoKit path since each asset is downloaded from iCloud. The first 226 + backup of a large iCloud-only library may take a while.