1
0
Fork 0
timelinize/datasources/iphone/iphone.go
2025-08-21 15:39:36 -06:00

261 lines
9.1 KiB
Go

/*
Timelinize
Copyright (c) 2013 Matthew Holt
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Package iphone implements a data source for iPhone backups. It supports
// the camera roll, messages, and address book, along with basic device info.
package iphone
import (
"context"
"database/sql"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"sync"
"github.com/timelinize/timelinize/datasources/imessage"
"github.com/timelinize/timelinize/timeline"
"go.uber.org/zap"
"howett.net/plist"
)
/*
TODO: Other options/ideas for this data source:
- Scan files for recognized types, like .vcf (vCards) and just import them
- Interesting file: CameraRollDomain: Media/PhotoData/Photos.sqlite (12b144c0bd44f2b3dffd9186d3f9c05b917cee25 in my sample) -- tons of granular details including face recognition
- Potentially useful files with telephone number:
bf01197b61bd101045f3421ec28b29e8cc0d3930 also <-- HomeDomain: Library/Preferences/com.apple.imservice.SMS.plist
bfecaa9c467e3acb085a5b312bd27bdd5cd7579a also <-- WirelessDomain: Library/Preferences/com.apple.commcenter.plist
*/
func init() {
err := timeline.RegisterDataSource(timeline.DataSource{
Name: "iphone",
Title: "iPhone",
Icon: "iphone.svg",
NewOptions: func() any { return new(Options) },
NewFileImporter: func() timeline.FileImporter { return &FileImporter{manifestMu: new(sync.Mutex)} },
})
if err != nil {
timeline.Log.Fatal("registering data source", zap.Error(err))
}
}
// FileImporter can import iPhone backups from a folder on disk.
type FileImporter struct {
opt timeline.ImportParams
dsOpt *Options
// these change as we iterate the list of input folders
root string
manifest *sql.DB
manifestMu *sync.Mutex // (this obviously doesn't change)
devicePhoneNumber string
owner *timeline.Entity
}
// Recognize returns whether the folder is recognized.
func (FileImporter) Recognize(_ context.Context, dirEntry timeline.DirEntry, _ timeline.RecognizeParams) (timeline.Recognition, error) {
var found, total int
for _, filename := range []string{
"Manifest.db",
"Manifest.plist",
"Info.plist",
"Status.plist",
} {
total++
if dirEntry.FileExists(filename) {
found++
} else if filename == "Manifest.db" {
return timeline.Recognition{}, nil // the manifest DB is absolutely required
}
}
return timeline.Recognition{Confidence: float64(found) / float64(total)}, nil
}
// FileImport imports data from the given folder.
func (fimp *FileImporter) FileImport(ctx context.Context, dirEntry timeline.DirEntry, params timeline.ImportParams) error {
fimp.dsOpt = params.DataSourceOptions.(*Options)
fimp.opt = params
fimp.root = dirEntry.FSRoot
// we can't open a SQLite DB using io/fs interfaces, so we have to just utilize disk directly.
dbPath := filepath.Join(fimp.root, filepath.FromSlash(dirEntry.Filename), "Manifest.db")
db, err := sql.Open("sqlite3", dbPath+"?mode=ro")
if err != nil {
return fmt.Errorf("opening manifest DB: %w", err)
}
defer db.Close()
fimp.manifest = db
// load device's current/latest phone number in case we need it (I haven't seen much need for it,
// but I already wrote the code before I discovered the easier source of the phone number)
commCenter, err := fimp.loadCommCenter(ctx)
if err != nil {
return err
}
fimp.devicePhoneNumber = imessage.NormalizePhoneNumber(ctx, commCenter.phoneNumber(), "")
if fimp.devicePhoneNumber != "" {
fimp.owner = &timeline.Entity{
Attributes: []timeline.Attribute{
{
Name: timeline.AttributePhoneNumber,
Value: fimp.devicePhoneNumber,
Identity: true,
},
},
}
}
if err = fimp.addressBook(ctx); err != nil {
return fmt.Errorf("importing iPhone address book: %w", err)
}
if err = fimp.messages(ctx); err != nil {
return fmt.Errorf("importing iPhone messages: %w", err)
}
// importing the camera roll directly just iterates the DCIM folder in the file system for photos and videos,
// whereas importing the photo library _almost_ (?) does the same thing but with more structure and metadata
if fimp.dsOpt.CameraRoll {
if err = fimp.cameraRoll(ctx); err != nil {
return fmt.Errorf("importing iPhone camera roll: %w", err)
}
} else {
if err = fimp.photosLibrary(ctx, dirEntry); err != nil {
return fmt.Errorf("importing iPhone Photos app library: %w", err)
}
}
return nil
}
// openFile opens a file given its domain and path. The manifest DB maps domain+path to file ID.
// We look up the file ID from the manifest and then use that to open the file and return it.
func (fimp *FileImporter) openFile(ctx context.Context, domain, relativePath string) (fs.File, error) {
fileID, err := fimp.findFileID(ctx, domain, relativePath)
if err != nil {
return nil, err
}
return os.Open(fimp.fileIDToPath(fileID))
}
// findFileID looks up the file ID in the manifest for the file given by its domain and path relative to that domain.
func (fimp *FileImporter) findFileID(ctx context.Context, domain, relativePath string) (string, error) {
fimp.manifestMu.Lock()
defer fimp.manifestMu.Unlock()
var id *string
err := fimp.manifest.QueryRowContext(ctx,
"SELECT fileID FROM Files WHERE domain=? AND relativePath=? LIMIT 1",
domain, relativePath).Scan(&id)
if err != nil {
return "", fmt.Errorf("looking up fileID for relative path '%s' in domain '%s': %w", relativePath, domain, err)
}
if id != nil {
return *id, nil
}
return "", nil
}
// fileIDToPath converts a fileID (the checksum by which it is organized in the backup) to
// the absolute path it can be accessed on disk. The return value uses the OS path separator.
func (fimp FileImporter) fileIDToPath(fileID string) string {
return filepath.Join(fimp.root, filepath.FromSlash(fimp.fileIDToRelativePath(fileID)))
}
// fileIDToRelativePath returns the path to the file relative to the root folder of the backup.
// The term "relative path" here is NOT the same as the "relativePath" field in the manifest DB.
// This relative path is relative to the backup root here, NOT the original path on the iPhone.
// The return value uses the forward slash ("/") as a path separator (as used with fs.FS).
func (fimp FileImporter) fileIDToRelativePath(fileID string) string {
const requiredLen = 2
if len(fileID) < requiredLen {
return fileID // I dunno; this is probably safer than an empty path element?
}
return path.Join(fileID[:2], fileID)
}
// loadCommCenter loads information about the device's wireless connectivity / cellular service.
func (fimp FileImporter) loadCommCenter(ctx context.Context) (commCenterInfo, error) {
commCenterPList, err := fimp.openFile(ctx, wirelessDomain, commCenterFile)
if err != nil {
return commCenterInfo{}, err
}
defer commCenterPList.Close()
commCenterPListSeeker, ok := commCenterPList.(io.ReadSeeker)
if !ok {
return commCenterInfo{}, fmt.Errorf("commcenter.plist file is not seekable :( %T", commCenterPList)
}
dec := plist.NewDecoder(commCenterPListSeeker)
var commCenter commCenterInfo
if err := dec.Decode(&commCenter); err != nil {
return commCenterInfo{}, fmt.Errorf("decoding commcenter.plist file: %w", err)
}
return commCenter, nil
}
// domainFS is a fs.FS and fs.StatFS for a specific domain in an iPhone backup.
type domainFS struct {
fi *FileImporter
domain string
}
func (dfs domainFS) Open(name string) (fs.File, error) {
return dfs.fi.openFile(context.Background(), dfs.domain, name)
}
func (dfs domainFS) Stat(name string) (fs.FileInfo, error) {
fileID, err := dfs.fi.findFileID(context.Background(), dfs.domain, name)
if err != nil {
return nil, err
}
return os.Stat(dfs.fi.fileIDToPath(fileID))
}
type commCenterInfo struct {
SIMPhoneNumber string `plist:"SIMPhoneNumber"`
PhoneNumber string `plist:"PhoneNumber"`
}
func (c commCenterInfo) phoneNumber() string {
phone := c.SIMPhoneNumber
if phone == "" {
phone = c.PhoneNumber
}
return phone
}
const (
homeDomain = "HomeDomain" // seems to be like the "default" or "system" domain
cameraRollDomain = "CameraRollDomain" // stores user photos/videos and adjustments, thumbnails
wirelessDomain = "WirelessDomain" // information related to cellular service/connectivity
mediaDomain = "MediaDomain" // contains message attachments
relativePathContactsDB = "Library/AddressBook/AddressBook.sqlitedb" // fileID for me: 31bb7ba8914766d4ba40d6dfb6113c8b614be442
relativePathMessagesDB = "Library/SMS/sms.db" // fileID for me: 3d0d7e5fb2ce288813306e4d4636395e047a3d28
commCenterFile = "Library/Preferences/com.apple.commcenter.plist" // fileID for me: bfecaa9c467e3acb085a5b312bd27bdd5cd7579a
)