A zero-setup CLI that downloads full-resolution images from cloud photo shares
The missing link between your iPhone screenshots and remote AI coding sessions.
Share an image via iCloud, paste the link into your SSH terminal, and your AI assistant can see it instantly.
Single-file bash script with embedded Node.js extractor. Auto-installs all dependencies.
Supports single photos, entire albums, JSON metadata output, and base64 encoding.
curl -fsSL "https://raw.githubusercontent.com/Dicklesworthstone/giil/main/install.sh?v=3.0.0" | bashThe scenario: You're SSH'd into a remote server running Claude Code, Codex, or another AI coding assistant. You need to debug a UI issue on your iPhone, but how do you get that screenshot to your remote terminal session?
βββββββββββββββ βββββββββββββββ βββββββββββββββ βββββββββββββββ
β iPhone ββββββΆβ iCloud ββββββΆβ Photos.app ββββββΆβ Share Link β
β Screenshot β β Sync β β (Mac) β β (Copy) β
βββββββββββββββ βββββββββββββββ βββββββββββββββ βββββββββββββββ
β
βΌ
βββββββββββββββ βββββββββββββββ βββββββββββββββ βββββββββββββββ
β AI Agent βββββββ giil βββββββ Paste βββββββ Remote SSH β
β Analyzes β β Downloads β β URL β β Terminal β
βββββββββββββββ βββββββββββββββ βββββββββββββββ βββββββββββββββ
The workflow:
- Screenshot the UI bug on your iPhone
- Wait a moment for iCloud to sync to your Mac
- Right-click the image in Photos.app β Share β Copy iCloud Link
- Paste the link into your remote terminal session
- Run giil and the image is now local to your remote machine
# On your remote server (SSH session with Claude Code/Codex)
giil "https://share.icloud.com/photos/0a1Abc_xYz..." --format json
# AI assistant can now analyze the screenshot directly
# {"path": "/tmp/icloud_20240115_143022.jpg", "width": 1170, "height": 2532, ...}Comparison:
| Without giil | With giil |
|---|---|
| Download image locally, SCP to server, tell AI the path | One command, AI sees it instantly |
| Email yourself, download on server, hope it works | Paste link, done |
| Set up complex file sync between devices | Just use iCloud's built-in sharing |
| Break your flow to context-switch between devices | Stay in your terminal |
This bridges your Apple devices and remote AI coding sessions. No file transfers, no context switching, no friction.
- The Primary Use Case
- Why giil Exists
- Highlights
- Quickstart
- Usage
- Output Modes
- Album Mode
- How It Works
- Browser Emulation
- Capture Strategies in Detail
- Platform-Specific Optimizations
- Image Processing Pipeline
- Download Verification
- Design Principles
- Architecture
- Testing & Quality Assurance
- File Locations
- Performance
- Troubleshooting
- Exit Codes
- Terminal Styling
- Environment Variables
- Dependencies
- Security & Privacy
- Uninstallation
- Contributing
- License
Cloud photo sharing services present unique challenges for automation:
| Problem | Why It's Hard | How giil Solves It |
|---|---|---|
| JavaScript-heavy SPAs | Standard curl/wget can't execute JS or render pages |
Headless Chromium via Playwright (or direct download for Dropbox) |
| Dynamic image loading | Images load asynchronously from CDN after page render | Network interception captures CDN responses |
| No direct download links | URLs are session-specific and expire quickly | Clicks native Download button or intercepts live requests |
| Copy/paste loses quality | Manual screenshots result in compressed/cropped images | Captures original resolution from source |
| HEIC format on Apple devices | Many tools can't process Apple's HEIC/HEIF format | Platform-aware conversion (sips/heif-convert) |
| Platform fragmentation | Each service has different URL patterns and APIs | Automatic platform detection with optimized strategies |
giil lets you programmatically download full-resolution images from iCloud, Dropbox, Google Photos, and Google Drive share linksβwhich is otherwise impossible without manual browser interaction.
Typical workflow: Debugging a UI issue with Claude Code or Codex on a remote server? Screenshot on iPhone β iCloud syncs β Share link from Photos.app β Paste into SSH terminal β giil fetches it β AI analyzes the image. No SCP, no email, no friction.
|
One-liner installer handles everything:
|
Maximum reliability through intelligent fallbacks:
|
|
Download entire shared albums with
|
Multiple output modes for any workflow:
|
|
EXIF-aware datetime stamping:
|
MozJPEG compression by default:
|
One-liner (recommended):
curl -fsSL "https://raw.githubusercontent.com/Dicklesworthstone/giil/main/install.sh?v=3.0.0" | bashManual installation
# Download script
curl -fsSL https://raw.githubusercontent.com/Dicklesworthstone/giil/main/giil -o ~/.local/bin/giil
chmod +x ~/.local/bin/giil
# Ensure ~/.local/bin is in PATH
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # or ~/.bashrc
source ~/.zshrcInstallation options
# Custom install directory
DEST=/opt/bin curl -fsSL .../install.sh | bash
# System-wide installation (requires sudo)
GIIL_SYSTEM=1 curl -fsSL .../install.sh | bash
# Skip PATH modification
GIIL_NO_ALIAS=1 curl -fsSL .../install.sh | bash
# Verified installation with SHA256 checksum
GIIL_VERIFY=1 curl -fsSL .../install.sh | bash
# Install specific version
GIIL_VERSION=2.1.0 curl -fsSL .../install.sh | bashChecksum verification
For security-conscious installations, giil supports SHA256 checksum verification:
GIIL_VERIFY=1 curl -fsSL https://raw.githubusercontent.com/Dicklesworthstone/giil/main/install.sh | bashHow it works:
- Downloads
giilscript from the release - Fetches
giil.sha256checksum file from the same release - Computes SHA256 of downloaded file
- Compares against expected checksum
- Aborts installation if mismatch detected
Requirements:
- Either
sha256sum(Linux) orshasum(macOS) - GitHub release must include
giil.sha256file
Output on success:
β Checksum verified: a1b2c3d4e5f6...
giil "https://share.icloud.com/photos/02cD9okNHvVd-uuDnPCH3ZEEA"Note: First run downloads Playwright Chromium (~200MB). This is cached for future runs in
~/.cache/giil/.
giil <icloud-photo-url> [options]
| Flag | Default | Description |
|---|---|---|
--output DIR |
. |
Output directory for saved images |
--preserve |
off | Preserve original bytes (skip MozJPEG compression) |
--convert FMT |
β | Convert to format: jpeg, png, webp |
--quality N |
85 |
JPEG quality 1-100 |
--format FMT |
β | Structured output format: json or toon |
--base64 |
off | Output base64 to stdout instead of saving file |
--json |
off | Output JSON metadata (alias for --format json) |
--all |
off | Download all photos from a shared album |
--timeout N |
60 |
Page load timeout in seconds |
--debug |
off | Save debug artifacts (screenshot + HTML) on failure |
--verbose |
off | Show detailed progress (implies --debug) |
--trace |
off | Enable Playwright tracing for deep debugging |
--print-url |
off | Output resolved direct URL instead of downloading |
--debug-dir DIR |
. |
Directory for debug artifacts |
--update |
off | Force reinstall of Playwright and dependencies |
--version |
β | Print version and exit |
--help |
β | Show help message |
Default Behavior: Images are compressed with MozJPEG for optimal size/quality balance. Use
--preserveto keep original bytes without recompression.
giil automatically detects the sharing platform and uses the optimal download strategy:
| Platform | URL Patterns | Method | Browser Required |
|---|---|---|---|
| iCloud | share.icloud.com/photos/*icloud.com/photos/#* |
4-tier capture strategy | Yes |
| Dropbox | dropbox.com/s/*dropbox.com/scl/fi/* |
Direct curl download (raw=1) |
No |
| Google Photos | photos.app.goo.gl/*photos.google.com/share/* |
URL extraction + =s0 modifier |
Yes |
| Google Drive | drive.google.com/file/d/*drive.google.com/open?id=* |
Multi-tier with auth detection | Yes |
Dropbox Fast Path: Dropbox links are downloaded directly via curl with no browser overheadβtypically completes in under 2 seconds.
Google Photos Full-Resolution: giil automatically appends =s0 to CDN URLs to request maximum resolution (the s0 modifier bypasses size restrictions).
iCloud (both formats automatically normalized):
https://share.icloud.com/photos/02cD9okNHvVd-uuDnPCH3ZEEA
https://www.icloud.com/photos/#02cD9okNHvVd-uuDnPCH3ZEEA
Dropbox:
https://www.dropbox.com/s/abc123/photo.jpg
https://www.dropbox.com/scl/fi/xyz789/image.png
Google Photos:
https://photos.app.goo.gl/abc123xyz
https://photos.google.com/share/AF1QipN...
Google Drive:
https://drive.google.com/file/d/1ABC.../view
https://drive.google.com/open?id=1ABC...
Returns the absolute path to the saved image on stdout.
giil "https://share.icloud.com/photos/XXX"
# stdout: /current/dir/icloud_20240115_143245.jpg
# stderr: [giil] Saved: /current/dir/icloud_20240115_143245.jpg (234.5 KB, network)Use in scripts:
IMAGE_PATH=$(giil "https://share.icloud.com/photos/XXX" --output ~/Downloads 2>/dev/null)
echo "Downloaded: $IMAGE_PATH"Returns structured metadata for programmatic use. The JSON schema includes metadata for reliable scripting.
giil "https://share.icloud.com/photos/XXX" --format jsonSuccess response:
{
"ok": true,
"schema_version": "1",
"platform": "icloud",
"path": "/absolute/path/to/icloud_20240115_143245.jpg",
"datetime": "2024-01-15T14:32:45.000Z",
"sourceUrl": "https://cvws.icloud-content.com/...",
"method": "network",
"size": 245678,
"width": 4032,
"height": 3024
}Error response:
{
"ok": false,
"schema_version": "1",
"platform": "icloud",
"error": {
"code": "AUTH_REQUIRED",
"message": "Login required - link is not publicly shared",
"remediation": "The file is not publicly shared. The owner must enable public access."
}
}| Field | Description |
|---|---|
ok |
Boolean success indicator (true or false) |
schema_version |
JSON schema version (currently "1") |
platform |
Detected platform: icloud, dropbox, gphotos, gdrive |
path |
Absolute path to saved file |
datetime |
ISO 8601 timestamp (from EXIF or capture time) |
sourceUrl |
CDN URL where image was obtained |
method |
Capture strategy: download, network, element-screenshot, viewport-screenshot, direct |
size |
File size in bytes |
width |
Image width in pixels |
height |
Image height in pixels |
error.code |
Error code (see Exit Codes) |
error.message |
Human-readable error description |
error.remediation |
Suggested fix for the error |
Parse with jq:
# Get path (if successful)
giil "https://share.icloud.com/photos/XXX" --format json | jq -r 'if .ok then .path else .error.message end'
# Check success
giil "..." --format json | jq -e '.ok' && echo "Success" || echo "Failed"Outputs the same metadata envelope as JSON, but encoded as TOON (Token-Optimized Object Notation).
Requires the tru binary from toon_rust (set TOON_TRU_BIN or TOON_BIN if not on PATH).
giil "https://share.icloud.com/photos/XXX" --format toonAlbum mode: --format toon emits one TOON document per image (separated by a blank line).
Outputs the image as a base64-encoded string (no file saved).
# Decode to file
giil "https://share.icloud.com/photos/XXX" --base64 | base64 -d > image.jpg
# Create data URI
echo "data:image/jpeg;base64,$(giil '...' --base64)" > uri.txt
# Pipe to API
giil "https://share.icloud.com/photos/XXX" --base64 | \
curl -X POST -d @- https://api.example.com/uploadCombined with JSON:
giil "https://share.icloud.com/photos/XXX" --base64 --format json{
"base64": "/9j/4AAQSkZJRg...",
"datetime": "2024-01-15T14:32:45.000Z",
"method": "network"
}Extracts and outputs the resolved CDN URL without downloading the image. Useful for debugging, external downloaders, or caching strategies.
giil "https://share.icloud.com/photos/XXX" --print-url
# stdout: https://cvws.icloud-content.com/B/...Use cases:
- Debugging: See what CDN URL giil would capture
- External tools: Pass URL to
curl,wget, or another downloader - Caching: Store URLs for later batch download
- Inspection: Verify which CDN is serving the image
Example with curl:
CDN_URL=$(giil "https://share.icloud.com/photos/XXX" --print-url 2>/dev/null)
curl -o photo.jpg "$CDN_URL"Download every photo from a shared iCloud album with --all.
giil "https://share.icloud.com/photos/XXX" --all --output ~/album1. Load album page
2. Detect thumbnail grid (11 selector strategies)
3. For each thumbnail:
a. Click to open full-size view
b. Wait for image to load
c. Capture using 4-tier strategy
d. Process and save with index suffix
e. Close viewer (button or Escape key)
f. Continue to next thumbnail
4. Output one path/JSON per photo
Default output:
/path/to/album/icloud_20240115_143245_001.jpg
/path/to/album/icloud_20240115_143246_002.jpg
/path/to/album/icloud_20240115_143247_003.jpg
With --format json (or --json):
{"path": "...001.jpg", "method": "download", "width": 4032, ...}
{"path": "...002.jpg", "method": "network", "width": 3024, ...}
{"path": "...003.jpg", "method": "network", "width": 4032, ...}With --format toon:
ok: true
path: ...001.jpg
method: download
width: 4032
ok: true
path: ...002.jpg
method: network
width: 3024
- Resilient: Continues to next photo if one fails
- Indexed filenames:
_001,_002, etc. for ordering - Collision-free: Appends counter if filename exists
- Progress feedback: Shows
Photo 1/N...on stderr
Album mode implements respectful rate limiting to avoid overwhelming cloud services:
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β Photo 1 ββββββΆβ 1 second ββββββΆβ Photo 2 ββββββΆ ...
β Download β β delay β β Download β
βββββββββββββββ βββββββββββββββ βββββββββββββββ
Fixed delays:
- 1 second between album photos β Prevents overwhelming iCloud servers
- Applies after each successful or failed download
Exponential backoff:
- If the server returns rate-limiting signals, giil backs off exponentially
- Base multiplier of 2 (1s β 2s β 4s β 8s β ...)
- Automatic retry with increasing delays
Why this matters:
- Reduces risk of IP-based rate limiting or temporary bans
- Prevents triggering anti-abuse measures
- Allows iCloud to serve other users fairly
- Improves overall reliability of large album downloads
βββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β User Input ββββββΆβ Bash Wrapper ββββββΆβ Node.js Core β
β (URL + flags) β β (giil script) β β (extractor.mjs) β
βββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββ
β β
βΌ βΌ
ββββββββββββββββ ββββββββββββββββ
β Dependency β β Playwright β
β Management β β (Chromium) β
ββββββββββββββββ ββββββββββββββββ
β
ββββββββββββββββββββββββββΌβββββββββββββββββββββββββ
βΌ βΌ βΌ
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β Network β β Download β β Screenshot β
β Interception β β Button β β Fallback β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β β β
ββββββββββββββββββββββββββΌβββββββββββββββββββββββββ
βΌ
ββββββββββββββββ
β Sharp β
β Processing β
ββββββββββββββββ
β
βΌ
ββββββββββββββββ
β Output β
β (file/json/ β
β base64) β
ββββββββββββββββ
-
URL Normalization
- Converts
share.icloud.com/photos/XXXtowww.icloud.com/photos/#XXX - Both formats load the same iCloud photo viewer
- Converts
-
Dependency Bootstrap
- Checks for Node.js β₯18 (installs if missing)
- Ensures Playwright + Chromium in cache
- Generates
extractor.mjsfrom embedded template
-
Browser Launch
- Spawns headless Chromium via Playwright
- Sets realistic viewport (1920Γ1080) and user-agent
- Enables download interception
-
Page Navigation
- Loads iCloud URL with configurable timeout
- Auto-dismisses cookie banners and overlays
- Waits for network idle state
-
Image Capture
- Executes 4-tier fallback strategy (see below)
- Selects highest-quality capture method that succeeds
-
Image Processing
- Extracts EXIF datetime for filename
- Converts HEIC/HEIF if necessary
- Compresses with MozJPEG (or
--preserveto keep original bytes)
-
Output Generation
- Writes file to disk (or base64 to stdout)
- Returns path/JSON on stdout
giil uses Playwright to drive a headless Chromium browser that appears indistinguishable from a real user's browser. This is essential for bypassing bot detection on cloud services.
// Browser context settings
{
viewport: { width: 1920, height: 1080 },
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' +
'AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/122.0.0.0 Safari/537.36'
}| Setting | Value | Purpose |
|---|---|---|
| Viewport | 1920Γ1080 | Standard desktop resolution |
| User-Agent | Chrome 122 on macOS | Modern, common browser fingerprint |
| Headless | true | No visible window (server-compatible) |
Cloud services often show cookie consent banners, subscription prompts, or other overlays that can block image capture. giil automatically dismisses these:
// Button texts that trigger auto-click
['Accept', 'Allow', 'OK', 'Continue', 'Not Now', 'Close', 'Dismiss', 'Got it']How it works:
- After page load, giil scans for visible buttons with these labels
- Buttons are clicked in sequence with brief delays
- Continues silently if no overlays 802E are found
giil waits for pages to fully stabilize before capturing:
await page.waitForLoadState('networkidle', { timeout: settleTimeout });What "network idle" means:
- No network requests for 500ms
- All images, scripts, and assets loaded
- Dynamic content has finished rendering
This ensures high-resolution images are fully loaded before capture attempts begin.
giil implements a four-tier fallback strategy to maximize reliability across different iCloud page states and configurations.
// Selectors tried in order:
'button[aria-label="Download"]'
'button[title="Download"]'
'a[aria-label="Download"]'
'[data-testid*="download"]'
'button:has-text("Download")'
'.download-button'
'[class*="download"]'How it works:
- Locate visible Download button using selector cascade
- Click and wait for browser download event
- Save to temporary file, read into memory
- Clean up temp file after processing
Advantages:
- Obtains original file (no re-encoding losses)
- Works with HEIC/HEIF originals
- Highest possible quality
When it fails:
- Download button not visible or doesn't exist
- Click doesn't trigger download event within 10s
// CDN detection patterns:
url.includes('cvws.icloud-content.com') ||
url.includes('icloud-content.com') ||
url.includes('lh3.googleusercontent.com/pw/') // Google Photos
// Content-type filtering:
'image/jpeg', 'image/png', 'image/heic', 'image/heif', 'image/webp'How it works:
- Install response handler before page navigation
- Monitor all HTTP responses for CDN patterns
- Filter by content-type (image formats only)
- Capture image buffers, track largest (>10KB threshold)
- Use captured buffer if download button fails
The 10KB threshold:
- Thumbnails and icons are typically <10KB
- Full-resolution images are almost always >10KB
- This prevents capturing preview images instead of originals
CDN selection algorithm:
for each HTTP response:
if URL matches CDN pattern AND content-type is image:
if buffer.size > currentLargest.size AND buffer.size > 10KB:
currentLargest = buffer
Advantages:
- Captures full-resolution CDN images
- No screenshot quality loss
- Works even if UI elements are obscured
When it fails:
- CDN domain structure changes
- Image loads before handler installed
- All captured images below size threshold
// Selectors tried in order:
'img[src*="cvws.icloud-content"]'
'img[src*="icloud-content"]'
'.photo-viewer img'
'.media-viewer img'
'[data-testid="photo"] img'
'main img'
'picture img'
'[role="img"]'How it works:
- Query for image elements using selector cascade
- Verify element is visible and β₯100Γ100 pixels
- Take PNG screenshot of the element
- Convert to JPEG during processing
Advantages:
- Captures rendered image as displayed
- Works when network capture misses
When it fails:
- No matching visible image element
- Element too small (<100px)
await page.screenshot({ type: 'png', fullPage: false });How it works:
- Capture visible viewport (1920Γ1080)
- Include entire visible area
- Convert to JPEG during processing
Advantages:
- Always succeeds if page loads
- Useful for debugging page state
Limitations:
- May include UI chrome
- Quality depends on viewport size
- Not ideal for production use
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Start Capture β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββ
β Try Download Button β
β (9 selectors) β
βββββββββββββββββββββββ
β
βββββββββββ΄ββββββββββ
β β
Success Fail
β β
βΌ βΌ
ββββββββββββ βββββββββββββββββββββββ
β Done! β β Check CDN Capture β
β (method: β β (buffer >10KB?) β
β download)β βββββββββββββββββββββββ
ββββββββββββ β
βββββββββββ΄ββββββββββ
β β
Success Fail
β β
βΌ βΌ
ββββββββββββ βββββββββββββββββββββββ
β Done! β β Try Element Screenshotβ
β (method: β β (10 selectors) β
β network) β βββββββββββββββββββββββ
ββββββββββββ β
βββββββββββ΄ββββββββββ
β β
Success Fail
β β
βΌ βΌ
ββββββββββββ βββββββββββββββββββββββ
β Done! β β Viewport Screenshot β
β (method: β β (always works) β
β element- β βββββββββββββββββββββββ
βscreenshotβ β
ββββββββββββ βΌ
ββββββββββββ
β Done! β
β (method: β
β viewport-β
βscreenshotβ
ββββββββββββ
Each supported platform has custom handling for optimal results:
- 4-tier capture strategy as described above
- Cookie banner auto-dismissal
- Album detection and iteration
- HEIC/HEIF format handling
Dropbox provides a fast path that bypasses Playwright entirely:
# URL transformation:
https://www.dropbox.com/s/abc123/photo.jpg?dl=0
β https://www.dropbox.com/s/abc123/photo.jpg?raw=1- Direct
curldownload (no browser overhead) - Typically completes in 1-2 seconds
- Full original quality preserved
- Works with any Dropbox shared link format
Google Photos uses URL modifiers for resolution control:
Original CDN URL:
https://lh3.googleusercontent.com/pw/xxx=w1920-h1080
Full-resolution URL (giil applies =s0):
https://lh3.googleusercontent.com/pw/xxx=s0
=s0modifier requests maximum resolution- Network interception captures all CDN responses
- Collects unique base URLs for album mode
- Automatic full-res download attempt
Multi-tier approach with authentication detection:
- Direct download URL (
/uc?export=download) - Thumbnail extraction (high-res
sz=w4000) - Screenshot fallback
Auth detection: If the file requires login, giil detects the redirect and returns a meaningful error (AUTH_REQUIRED) instead of capturing a login page.
βββββββββββββββ βββββββββββββββ βββββββββββββββ βββββββββββββββ
β Raw ββββββΆβ EXIF ββββββΆβ HEIC ββββββΆβ Sharp β
β Buffer β β Datetime β β Conversion β β JPEG β
βββββββββββββββ βββββββββββββββ βββββββββββββββ βββββββββββββββ
β
βββββββββββββββ βββββββββββββββ β
β Output βββββββ Filename ββββββββββββββ
β Result β β Generation β
βββββββββββββββ βββββββββββββββ
Using the exifr library, giil extracts datetime metadata to create meaningful filenames:
// Priority order:
1. DateTimeOriginal // When photo was taken (most reliable)
2. CreateDate // File creation time
3. DateTimeDigitized // When digitized
4. ModifyDate // Last modification
5. Current time // Fallback if no EXIFApple devices often produce HEIC images. giil handles this with platform-aware tools:
| Platform | Tool | Notes |
|---|---|---|
| macOS | sips |
Built-in, always available |
| Linux | heif-convert |
Requires libheif-examples package |
# Install HEIC support on Linux:
sudo apt-get install libheif-examples # Debian/Ubuntu
sudo dnf install libheif-tools # FedoraBy default, giil compresses images with MozJPEG for optimal size/quality balance:
# MozJPEG compression (default)
giil "https://share.icloud.com/photos/..."
# Preserve original bytes (skip compression)
giil "https://share.icloud.com/photos/..." --preserve
# Convert to WebP format
giil "https://share.icloud.com/photos/..." --convert webpSharp applies MozJPEG compression:
sharp(buffer).jpeg({
quality: 85, // Configurable via --quality
mozjpeg: true, // Enable MozJPEG optimizer
chromaSubsampling: '4:2:0' // Standard JPEG subsampling
})Compression characteristics:
- 40-50% smaller than standard JPEG at equivalent quality
- 4:2:0 chroma subsampling reduces color data (imperceptible to human eye)
- Quality 85 provides excellent visual quality with significant size reduction
icloud_YYYYMMDD_HHMMSS[_NNN][_counter].jpg
β β β
β β βββ Collision counter (if file exists)
β βββ Album index (--all mode only)
βββ Date/time from EXIF or capture time
Examples:
icloud_20240115_143245.jpg # Single photo
icloud_20240115_143245_001.jpg # Album photo 1
icloud_20240115_143245_002.jpg # Album photo 2
icloud_20240115_143245_001_1.jpg # Collision (file existed)
giil implements a multi-layer content validation system to ensure downloads are valid images, not error pages or corrupted data.
Cloud services often return HTML error pages with 200 status codes, making it impossible to detect failures from HTTP status alone:
β Expected: JPEG image data
β Received: HTTP 200
β Actual content: <html><body>This link has expired</body></html>
Every downloaded file passes through three validation checks before being accepted:
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β Content-Type ββββββΆβ Magic Bytes ββββββΆβ HTML Error β
β Validation β β Detection β β Detection β
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β β β
βΌ βΌ βΌ
Check MIME type Verify file Reject HTML
matches image signature bytes error pages
Validates the HTTP Content-Type header matches expected image types:
// Accepted MIME types:
'image/jpeg'
'image/png'
'image/webp'
'image/heic'
'image/heif'
'image/gif'
'application/octet-stream' // Binary fallback (validated by magic bytes)Verifies the file's binary signature matches known image formats, regardless of what the server claimed:
| Format | Magic Bytes (hex) | Description |
|---|---|---|
| JPEG | FF D8 FF |
JFIF/Exif image |
| PNG | 89 50 4E 47 |
Portable Network Graphics |
| GIF | 47 49 46 38 |
Graphics Interchange Format |
| WebP | 52 49 46 46...57 45 42 50 |
RIFF container with WEBP |
| HEIC/HEIF | 00 00 00...66 74 79 70 |
ISO base media file (ftyp box) |
| BMP | 42 4D |
Windows Bitmap |
Why this matters: A server might claim Content-Type: image/jpeg but actually serve an HTML error page. Magic bytes catch this.
Scans the first bytes of content for HTML signatures that indicate an error page was returned instead of an image:
// Rejected patterns:
'<!DOCTYPE'
'<!doctype'
'<html'
'<HTML'
'<head'
'<HEAD'Edge case handling: Some valid images (especially JPEG) can contain embedded metadata that coincidentally matches HTML patterns. giil validates magic bytes first, so a valid JPEG with HTML-like EXIF comments passes validation.
A special case: Apple devices sometimes wrap HEIC data inside a JPEG container. giil detects this by scanning for the ftypheic signature after the JPEG header and triggers HEIC conversion:
// Detect HEIC hidden in JPEG wrapper
if (startsWithJPEG && containsHeicSignature) {
// Extract and convert the inner HEIC data
}# giil validates automatically - no flags needed
giil "https://share.icloud.com/photos/XXX"
# If validation fails, you'll see:
# [giil] Error: Downloaded content is not a valid image
# [giil] Received HTML error page instead of image datagiil automatically detects and installs missing components:
User runs giil
β
βββ Node.js missing?
β βββ Install via brew/apt/dnf/yum/pacman
β
βββ Playwright missing?
β βββ npm install in cache directory
β
βββ Chromium missing?
β βββ npx playwright install chromium
β
βββ All deps present β Run extractor
Every operation has fallbacks:
| Component | Primary | Fallback |
|---|---|---|
| Image capture | Download button | Network β Screenshot |
| HEIC conversion | Sharp native | System tools (sips/heif-convert) |
| EXIF datetime | DateTimeOriginal | Other fields β Current time |
| Album navigation | Close button | Escape key |
| CLI output styling | gum | ANSI escape codes |
The entire Node.js extractor is embedded in the bash script as a heredoc:
create_extractor_script() {
cat > "$script_path" << 'SCRIPT_EOF'
// ~560 lines of JavaScript
SCRIPT_EOF
}Benefits:
- No separate files to manage
- Easy to inspect and audit
- Simple installation (one file)
- Regenerated fresh each run
giil respects the XDG Base Directory Specification:
CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
GIIL_HOME="${GIIL_HOME:-$CACHE_HOME/giil}"βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Bash Layer β
β β’ CLI parsing and validation β
β β’ Dependency detection and installation β
β β’ URL normalization β
β β’ Process orchestration β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Node.js Layer β
β β’ Browser automation (Playwright) β
β β’ Network interception β
β β’ Image capture strategies β
β β’ Image processing (Sharp) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
giil (bash wrapper, ~1,150 LOC)
βββ CLI argument parsing
βββ OS detection (macOS/Linux)
βββ Node.js installation
βββ Playwright setup
βββ gum installation (optional)
βββ URL normalization
βββ Extractor script generation
βββ Node.js invocation
extractor.mjs (embedded JavaScript, ~1,450 LOC)
βββ Playwright browser management
βββ Response interception handler
βββ Download button detector
βββ Screenshot capture
βββ EXIF datetime extraction
βββ HEIC conversion
βββ Sharp image processing
βββ Filename generation
βββ Output formatting
giil
β
βββ Node.js β₯18
β βββ npm
β
βββ Playwright ^1.40.0
β βββ Chromium (headless browser)
β
βββ Sharp ^0.33.0
β βββ libvips (native image library)
β βββ MozJPEG encoder
β
βββ exifr ^7.1.3
β βββ EXIF/IPTC/XMP parser
β
βββ gum (optional)
βββ charmbracelet CLI styling
giil includes a comprehensive testing infrastructure to ensure reliability.
The test suite validates pure JavaScript functions extracted from the embedded extractor:
scripts/tests/
βββ run-tests.sh # Test runner
βββ extract-functions.mjs # Extracts pure functions from giil
βββ platform-detection.test.mjs # Platform URL detection tests
βββ (more test files...)
Running tests:
# Run all unit tests
./scripts/tests/run-tests.sh
# Output:
# === giil Unit Tests ===
# [1/2] Verifying function extraction...
# Extraction OK
# [2/2] Running unit tests...
# β detectPlatform > iCloud URLs > detects share.icloud.com/photos URLs
# ...
# Done!Test architecture:
- Tests use Node.js 18+ native test runner (
node:test) - Functions are extracted from giil at test time (no separate source files)
- Each test file independently extracts functions in its
before()hook
| Test Suite | Coverage |
|---|---|
| Platform Detection | URL pattern matching for iCloud, Dropbox, Google Photos, Google Drive |
| Domain Boundary Checks | Rejects fake domains (e.g., fakedropbox.com vs dropbox.com) |
| Case Insensitivity | URL matching works regardless of case |
| Edge Cases | Empty strings, malformed URLs, query parameters |
GitHub Actions runs quality checks on every push:
# .github/workflows/ci.yml
jobs:
unit-tests:
- Setup Node.js 18
- Run: ./scripts/tests/run-tests.sh
shellcheck:
- Lint giil and install.sh
- Fail on warning-level issues
syntax-validation:
- Verify bash syntax
- Verify embedded JS syntax
installation-test:
- Test curl-bash installer
- Verify giil runs after installFor testing against real iCloud links:
# Requires GIIL_REAL_TEST_URL environment variable
./scripts/real_link_test.shThis test:
- Downloads from a real iCloud share link
- Verifies the file matches expected SHA256 checksum
- Validates image dimensions and format
ShellCheck enforces bash best practices:
shellcheck -x giil install.shEmbedded JavaScript syntax validation:
# Extract and check JS syntax
node --check ~/.cache/giil/extractor.mjs| Path | Purpose |
|---|---|
~/.cache/giil/ |
Runtime directory (or $XDG_CACHE_HOME/giil) |
~/.cache/giil/node_modules/ |
Playwright, Sharp, exifr packages |
~/.cache/giil/extractor.mjs |
Generated Node.js extraction script |
~/.cache/giil/package.json |
npm package manifest |
~/.cache/giil/.installed |
Installation marker file |
~/.cache/giil/ms-playwright/ |
Chromium browser cache |
| Path | Purpose |
|---|---|
~/.local/bin/giil |
Main script (default install) |
/usr/local/bin/giil |
System install (GIIL_SYSTEM=1) |
When using --debug, on failure:
| File | Contents |
|---|---|
giil_debug_<timestamp>.png |
Full-page screenshot |
giil_debug_<timestamp>.html |
Page DOM content |
| Phase | First Run | Subsequent Runs |
|---|---|---|
| Dependency check | <1s | <1s |
| Chromium download | 30-60s | Skipped (cached) |
| Browser launch | 2-3s | 2-3s |
| Page load | 3-10s | 3-10s |
| Image capture | 1-5s | 1-5s |
| Image processing | <1s | <1s |
| Total | 40-80s | 5-15s |
| Resource | Typical Usage |
|---|---|
| Memory (during run) | 200-400 MB |
| Disk (Chromium cache) | ~500 MB |
| Disk (node_modules) | ~50 MB |
| Network (per image) | Original image size |
# Lower quality for faster processing and smaller files
giil "..." --quality 60
# Increase timeout for slow networks
giil "..." --timeout 120
# Force dependency update if issues occur
giil "..." --update"Node.js not found"
Cause: Node.js not installed or not in PATH.
Fix: giil auto-installs Node.js, but you can also install manually:
# macOS
brew install node
# Ubuntu/Debian
sudo apt-get install nodejs npm
# Fedora
sudo dnf install nodejs npmTimeout errors
Cause: Slow network or iCloud service issues.
Fixes:
- Increase timeout:
giil "..." --timeout 120 - Check if URL works in browser
- Try again later (iCloud may be slow)
"Failed to capture image"
Cause: All capture strategies failed.
Fixes:
- Run with
--debugto get screenshot and HTML - Check debug screenshot to see page state
- Open GitHub issue with debug artifacts
Small/wrong image captured
Cause: Captured thumbnail instead of full resolution.
Fixes:
- Should auto-select largest image
- Try with
--debugto investigate - Report if persistent (include URL)
HEIC conversion fails on Linux
Cause: heif-convert not installed.
Fix:
# Ubuntu/Debian
sudo apt-get install libheif-examples
# Fedora
sudo dnf install libheif-tools
# Arch
sudo pacman -S libheifChromium fails to launch
Cause: Missing system dependencies (common on headless servers).
Fix:
# Force reinstall with system deps
giil "..." --update
# Or manually:
cd ~/.cache/giil && npx playwright install --with-deps chromiumUse --debug to capture diagnostic information:
giil "https://share.icloud.com/photos/XXX" --debugOn failure, this saves:
giil_debug_<timestamp>.png- Screenshot of page stategiil_debug_<timestamp>.html- Full DOM for inspection
For deeper debugging:
# Verbose: detailed progress logging
giil "..." --verbose
# Trace: Playwright trace recording (generates trace.zip)
giil "..." --trace
# View trace in browser
npx playwright show-trace ~/.cache/giil/trace.zipgiil uses semantic exit codes for scripting and error handling:
| Code | Name | Description |
|---|---|---|
0 |
Success | Image captured and saved/output |
1 |
Capture Failure | All capture strategies failed |
2 |
Usage Error | Invalid arguments or missing URL |
3 |
Dependency Error | Node.js, Playwright, or Chromium issue |
10 |
Network Error | Timeout, DNS failure, unreachable host |
11 |
Auth Required | Login redirect, password required, not publicly shared |
12 |
Not Found | Expired link, deleted file, 404 |
13 |
Unsupported Type | Video, Google Doc, or non-image content |
20 |
Internal Error | Bug in giil (please report!) |
Scripting with exit codes:
giil "https://share.icloud.com/photos/XXX" 2>/dev/null
case $? in
0) echo "Success!" ;;
10) echo "Network issue - retry later" ;;
11) echo "Link not public - ask owner to share" ;;
12) echo "Link expired" ;;
*) echo "Failed with code $?" ;;
esacgiil analyzes error messages to provide meaningful exit codes and remediation hints. This happens automaticallyβno configuration needed.
Error message pattern matching:
| Error Pattern | Classified As | Exit Code |
|---|---|---|
timeout, net::err, dns |
Network Error | 10 |
404, not found, expired |
Not Found | 12 |
login, auth, password |
Auth Required | 11 |
video, unsupported |
Unsupported Type | 13 |
| HTML content with image content-type | Auth Required | 11 |
| HTML magic bytes in response | Auth Required | 11 |
Why this matters:
Cloud services often return HTTP 200 with an HTML login page when authentication is required. giil detects this through content validation (magic bytes, HTML detection) and correctly reports it as AUTH_REQUIRED rather than a generic capture failure.
JSON error responses include remediation hints:
{
"ok": false,
"error": {
"code": "AUTH_REQUIRED",
"message": "Redirect to login page detected",
"remediation": "The file is not publicly shared. The owner must enable public access."
}
}giil integrates with gum for beautiful terminal output when available:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β giil β β
β β Get Image [from] Internet Link v3.0.0 β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β β Launching browser... β
β β Download button clicked β
β β Image processed: 4032Γ3024, 1.2 MB β 456 KB β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Environment | Output Style |
|---|---|
| TTY with gum installed | Full gum styling (banners, spinners, colors) |
| TTY without gum | ANSI color codes |
| Non-TTY (piped) | Plain text |
CI environment ($CI set) |
Plain text, no gum |
GIIL_NO_GUM=1 |
Force ANSI fallback |
# macOS
brew install gum
# Linux (apt)
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list
sudo apt update && sudo apt install gum
# Arch
sudo pacman -S gum| Variable | Description | Default |
|---|---|---|
XDG_CACHE_HOME |
Base cache directory | ~/.cache |
GIIL_HOME |
giil runtime directory | $XDG_CACHE_HOME/giil |
PLAYWRIGHT_BROWSERS_PATH |
Custom Chromium cache | $GIIL_HOME/ms-playwright |
GIIL_NO_GUM |
Disable gum installation | unset |
GIIL_CHECK_UPDATES |
Enable update checking (set to 1) |
unset |
GIIL_OUTPUT_FORMAT |
Structured output override (json or toon) |
unset |
NODE_OPTIONS |
Node.js options | unset |
CI |
Detected CI environment (disables gum) | unset |
TOON_DEFAULT_FORMAT |
Global TOON default (json or toon) |
unset |
TOON_TRU_BIN |
Path to the tru binary (preferred) |
unset |
TOON_BIN |
Alternate path to the tru binary |
unset |
TOON_STATS |
Emit token stats on stderr (set to 1) |
unset |
| Variable | Description | Default |
|---|---|---|
DEST |
Custom install directory | ~/.local/bin |
GIIL_SYSTEM |
Install to /usr/local/bin (set to 1) |
unset |
GIIL_NO_ALIAS |
Skip adding directory to PATH | unset |
GIIL_VERIFY |
Verify SHA256 checksum (set to 1) |
unset |
GIIL_VERSION |
Install specific version | latest |
Example: Custom cache location
export XDG_CACHE_HOME=/var/cache/myapp
giil "https://share.icloud.com/photos/XXX"
# Uses /var/cache/myapp/giil/Example: Enable update checking
export GIIL_CHECK_UPDATES=1
giil "https://share.icloud.com/photos/XXX"
# Will notify if a newer version is available (once per day)Example: Verified installation
GIIL_VERIFY=1 curl -fsSL .../install.sh | bash
# Verifies SHA256 checksum against GitHub release| Package | Version | Purpose |
|---|---|---|
| Node.js | β₯18 | JavaScript runtime |
| Playwright | ^1.40.0 | Browser automation |
| Chromium | (via Playwright) | Headless browser |
| Sharp | ^0.33.0 | Image processing |
| exifr | ^7.1.3 | EXIF metadata parsing |
| gum | latest | CLI styling (optional) |
| Platform | Requirements |
|---|---|
| macOS | macOS 10.15+ (Catalina or later) |
| Linux | glibc 2.17+ (Ubuntu 18.04+, Debian 10+) |
| Node.js | v18 or later |
- Local execution: All processing happens on your machine
- No telemetry: No data sent anywhere except to iCloud
- No authentication stored: Uses iCloud's public share mechanism
- No cookies saved: Browser context is ephemeral
- Sandboxed browser: Chromium runs with
--no-sandboxfor compatibility but in headless mode with no persistent state - No code execution: Only loads iCloud URLs, no JavaScript injection
- Temp file cleanup: Downloaded files cleaned up after processing
The entire codebase is contained in a single bash script (~1,150 lines of bash wrapper) with an embedded JavaScript extractor (~1,450 lines):
less ~/.local/bin/giil# Remove script
rm ~/.local/bin/giil
# Remove runtime and cache
rm -rf ~/.cache/giil
# Remove Playwright browsers (if no other Playwright tools)
rm -rf ~/.cache/ms-playwrightAbout Contributions: Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via
ghand independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.
MIT License (with OpenAI/Anthropic Rider). See LICENSE for details.
Built with Playwright, Sharp, and a healthy disregard for iCloud's lack of an API.