Files
rclone/backend/iclouddrive/api/client.go
Yakov Till d0c469c3c0 iclouddrive: add read only iCloud Photos support and SRP authentication
Add read-only iCloud Photos support to the existing iclouddrive
backend via `service = photos` config option.

Also includes auth improvements on top of #9209's SRP authentication.

**Photos features:**
- 3-level hierarchy: libraries (Personal + Shared Photo Library) →
  albums → photos/videos
- server-side smart albums (All Photos, Videos, Favorites,
  Screenshots, Live, Bursts, Panoramas, Slo-mo, Time-lapse, Portrait,
  Long Exposure, Animated, Hidden, Recently Deleted)
- User-created albums and nested album folders
- Live Photo `.MOV` companions as first-class entries
- Edited photo versions (`-edited` suffix) and RAW alternatives
- Duplicate filename dedup for camera counter wrap collisions
- Parallel cold listing for large albums
- Delta sync via CloudKit `changes/zone` - warm listings near-instant from disk cache
- Disk cache (libraries, albums, photos) with atomic writes for crash safety
- `ChangeNotify` support for FUSE mounts via `changes/zone` polling
- `ListR` support for `--fast-list` and recursive operations
- `--metadata` support - width, height, added-time, favorite, hidden
- Fresh download URLs per file - no stale URL failures on long copies
- FUSE mount documentation with recommended flags

**Auth improvements over #9209:**
- SMS 2FA fallback for users without trusted Apple devices
- Explicit push notification request - fixes iOS/macOS 26.4+ where 409
  no longer auto-pushes
- Thread safety for concurrent FUSE callers (mutexes on session and client state)
- Session endpoint caching - skips ~5s `/validate` round-trip on warm start
- `Disconnect` support - clears auth state + disk cache
- PCS cookie support for Advanced Data Protection accounts, including
  trusted-device approval for PCS cookies

Built on @coughlanio's Photos PoC (Closes #8734) and @mikegillan's SRP auth (#9209).

Fixes #7982
Co-authored-by: Chris Coughlan <chris@coughlan.io>
2026-04-27 16:55:31 +01:00

208 lines
5.9 KiB
Go

// Package api provides functionality for interacting with the iCloud API
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/fshttp"
"github.com/rclone/rclone/lib/rest"
)
const (
baseEndpoint = "https://www.icloud.com"
setupEndpoint = "https://setup.icloud.com/setup/ws/1"
authEndpoint = "https://idmsa.apple.com/appleauth/auth"
)
type sessionSave func(*Session)
// Client defines the client configuration
type Client struct {
appleID string
password string
remoteName string // rclone remote name, used for cache namespacing
srv *rest.Client
Session *Session
sessionSaveCallback sessionSave
drive *DriveService
mu sync.Mutex // protects drive and Authenticate
}
// New creates a new iCloud API client and initializes its HTTP session
func New(appleID, password, trustToken string, clientID string, cookies []*http.Cookie, sessionSaveCallback sessionSave, remoteName string) (*Client, error) {
icloud := &Client{
appleID: strings.ToLower(appleID), // Apple SRP requires lowercase in client-side proof
password: password,
remoteName: filepath.Base(remoteName),
srv: rest.NewClient(fshttp.NewClient(context.Background())),
Session: NewSession(),
sessionSaveCallback: sessionSaveCallback,
}
icloud.Session.TrustToken = trustToken
icloud.Session.Cookies = cookies
icloud.Session.ClientID = clientID
return icloud, nil
}
// DriveService returns the DriveService instance, creating it on first call
func (c *Client) DriveService() (*DriveService, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.drive == nil {
var err error
c.drive, err = NewDriveService(c)
if err != nil {
return nil, err
}
}
return c.drive, nil
}
// Request makes a request to the iCloud API, re-authenticating on 401/421
func (c *Client) Request(ctx context.Context, opts rest.Opts, request any, response any) (resp *http.Response, err error) {
resp, err = c.Session.Request(ctx, opts, request, response)
if err != nil && resp != nil {
// try to reauth
if resp.StatusCode == 401 || resp.StatusCode == 421 {
err = c.Authenticate(ctx)
if err != nil {
return nil, err
}
if c.Session.Requires2FA() {
return nil, errors.New("trust token expired, please reauth")
}
return c.Session.Request(ctx, opts, request, response)
}
}
return resp, err
}
// Authenticate authenticates the client, reusing existing session if valid
func (c *Client) Authenticate(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
// Skip /validate round-trip when saved session has cookies + service endpoints
// Native client behavior: use cached session, reauth lazily on 401/421
if c.Session.Cookies != nil && len(c.Session.AccountInfo.Webservices) > 0 {
fs.Debugf(nil, "iclouddrive: reusing saved session")
return nil
}
// Try loading cached service endpoints to avoid /validate round-trip (~5s)
if c.Session.Cookies != nil && c.loadCachedWebservices() {
fs.Debugf(nil, "iclouddrive: reusing session with cached endpoints")
return nil
}
if c.Session.Cookies != nil {
if err := c.Session.ValidateSession(ctx); err == nil {
fs.Debugf(nil, "iclouddrive: valid session, no need to reauth")
c.saveCachedWebservices()
return nil
}
c.Session.Cookies = nil
}
fs.Debugf(nil, "iclouddrive: authenticating")
err := c.Session.SignIn(ctx, c.appleID, c.password)
if err != nil {
return err
}
// If 2FA is required, skip AuthWithToken - caller must complete 2FA first
if c.Session.Requires2FA() {
return nil
}
err = c.Session.AuthWithToken(ctx)
if err == nil {
c.saveCachedWebservices()
if c.sessionSaveCallback != nil {
c.sessionSaveCallback(c.Session)
}
}
return err
}
// loadCachedWebservices loads service endpoints from disk cache
func (c *Client) loadCachedWebservices() bool {
data, err := os.ReadFile(filepath.Join(config.GetCacheDir(), cacheSubdir, c.remoteName, "webservices.json"))
if err != nil {
return false
}
var ws map[string]*webService
if err := json.Unmarshal(data, &ws); err != nil {
return false
}
if len(ws) == 0 {
return false
}
c.Session.AccountInfo.Webservices = ws
return true
}
// saveCachedWebservices persists service endpoints to disk
func (c *Client) saveCachedWebservices() {
if len(c.Session.AccountInfo.Webservices) == 0 {
return
}
saveJSONCache(filepath.Join(config.GetCacheDir(), cacheSubdir, c.remoteName), "webservices.json", c.Session.AccountInfo.Webservices)
}
// CacheDir returns the disk cache directory for this remote
func (c *Client) CacheDir() string {
return filepath.Join(config.GetCacheDir(), cacheSubdir, c.remoteName)
}
// ClearCacheDir removes all disk cache files for a remote
func ClearCacheDir(remoteName string) {
dir := filepath.Join(config.GetCacheDir(), cacheSubdir, filepath.Base(remoteName))
if err := os.RemoveAll(dir); err != nil {
fs.Debugf(nil, "iclouddrive: failed to clear cache: %v", err)
}
}
// IntoReader marshals the provided values into a JSON encoded reader
func IntoReader(values any) (*bytes.Reader, error) {
m, err := json.Marshal(values)
if err != nil {
return nil, err
}
return bytes.NewReader(m), nil
}
// RequestError holds info on a result state, icloud can return a 200 but the result is unknown
type RequestError struct {
Status string
Text string
}
// Error satisfies the error interface
func (e *RequestError) Error() string {
return fmt.Sprintf("%s: %s", e.Text, e.Status)
}
func newRequestError(status string, text string) *RequestError {
return &RequestError{
Status: strings.ToLower(status),
Text: text,
}
}
// newRequestErrorf makes a new error from sprintf parameters
func newRequestErrorf(status string, text string, params ...any) *RequestError {
return newRequestError(status, fmt.Sprintf(text, params...))
}