iphone2android 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Move from an iPhone to an Android phone: messages, contacts, photos, apps and the home screen."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,45 @@
1
+ """A small adb wrapper.
2
+
3
+ stdin is always closed. `adb shell` reads stdin, so inside a shell `while read` loop it would eat the
4
+ rest of the list and only the first item would be processed.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+
12
+
13
+ class DeviceUnavailable(RuntimeError):
14
+ """adb could not reach the phone. Raised instead of returning empty output, which would read as "nothing installed"."""
15
+
16
+
17
+ class Adb:
18
+ def __init__(self, serial: str | None = None, binary: str | None = None):
19
+ self.serial = serial
20
+ self.binary = binary or os.environ.get("IPHONE2ANDROID_ADB") or shutil.which("adb") or "adb"
21
+
22
+ def run(self, *args: str, timeout: float = 90) -> subprocess.CompletedProcess:
23
+ cmd = [self.binary] + (["-s", self.serial] if self.serial else []) + list(args)
24
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
25
+
26
+ def shell(self, cmd: str, timeout: float = 90) -> str:
27
+ try:
28
+ r = self.run("shell", cmd, timeout=timeout)
29
+ except subprocess.TimeoutExpired as e:
30
+ raise DeviceUnavailable(f"adb shell timed out after {timeout}s") from e
31
+ # adb's own failures start with "adb:" or "error:". A failing command on the phone does not.
32
+ if r.returncode != 0 and r.stderr.lstrip().startswith(("adb:", "error:")):
33
+ raise DeviceUnavailable(r.stderr.strip()[:200])
34
+ return r.stdout
35
+
36
+ def installed(self, package: str) -> bool:
37
+ return f"package:{package}\n" in self.shell(f"pm list packages {package}") + "\n"
38
+
39
+ def screen_size(self) -> tuple[int, int]:
40
+ """The size touches are measured in: the override size when one is set, else the physical size."""
41
+ out = self.shell("wm size").strip()
42
+ if "x" not in out:
43
+ raise DeviceUnavailable(f"cannot read the screen size: {out[:80]!r}")
44
+ w, h = out.splitlines()[-1].split(":")[-1].strip().split("x")
45
+ return int(w), int(h)
@@ -0,0 +1,116 @@
1
+ """Build a home screen from a layout file, and put each page in order.
2
+
3
+ {"pages": [["Camera", "Photos", {"folder": "Social", "apps": ["WhatsApp", "Telegram"]}]],
4
+ "dock": ["Phone", "Messages", "Chrome"]}
5
+
6
+ Each name is what to type in the app drawer search. Building never assumes where a drop landed: it
7
+ reads the screen again after every drop and uses the real position. Progress is saved after every
8
+ step, so an interrupted build continues where it stopped.
9
+
10
+ Order is fixed afterwards by a selection sort that makes one swap and then reads the screen again.
11
+ Several swaps from one reading do not converge, because every move shifts the grid. A swap that
12
+ creates a folder by accident stops the sort.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+
19
+
20
+ def _label(entry) -> str:
21
+ return f"Folder:{entry['folder']}" if isinstance(entry, dict) else entry
22
+
23
+
24
+ class Builder:
25
+ def __init__(self, launcher, state_file: Path | None = None, log=print):
26
+ self.l = launcher
27
+ self.state_file = state_file
28
+ self.log = log
29
+ self.done: list[str] = []
30
+ if state_file and state_file.exists():
31
+ self.done = json.loads(state_file.read_text()).get("done", [])
32
+
33
+ def _mark(self, key: str) -> None:
34
+ self.done.append(key)
35
+ if self.state_file:
36
+ self.state_file.write_text(json.dumps({"done": self.done}))
37
+
38
+ def add(self, name: str, page: int, onto: tuple[int, int] | None = None):
39
+ """Drag an app from the drawer to the page (onto a position to merge). Returns where it landed."""
40
+ hit = self.l.drawer_search(name)
41
+ if not hit:
42
+ self.log(f" not in the drawer: {name}")
43
+ return None
44
+ tx, ty = onto if onto else self.l.at(0.5, 0.71)
45
+ self.l.drag(hit.x, hit.y, tx, ty, 1800)
46
+ landed = [n for n in self.l.icons(page) if n.label == hit.label]
47
+ return landed[0] if landed else None
48
+
49
+ def build_page(self, page: int, entries: list) -> None:
50
+ for e in entries:
51
+ key = f"p{page}:{_label(e)}"
52
+ if key in self.done:
53
+ continue
54
+ if isinstance(e, str):
55
+ r = self.add(e, page)
56
+ self.log(f" {e}: {'placed' if r else 'FAILED'}")
57
+ else:
58
+ self._build_folder(page, e["folder"], e["apps"])
59
+ self._mark(key)
60
+
61
+ def _build_folder(self, page: int, name: str, apps: list[str]) -> None:
62
+ if not apps:
63
+ return
64
+ before = {n.label for n in self.l.icons(page) if n.folder}
65
+ first = self.add(apps[0], page)
66
+ if not first:
67
+ self.log(f" folder {name}: cannot place {apps[0]}")
68
+ return
69
+ if len(apps) == 1:
70
+ self.log(f" folder {name} has one app, placed it loose")
71
+ return
72
+ self.add(apps[1], page, onto=(first.x, first.y))
73
+ new = [n for n in self.l.icons(page) if n.folder and n.label not in before]
74
+ if not new:
75
+ self.log(f" folder {name}: no folder formed")
76
+ return
77
+ # Identify the new folder by being new, never by its auto-name (two can share one).
78
+ self.l.tap(new[0].x, new[0].y)
79
+ self.l.rename_open_folder(name)
80
+ for app in apps[2:]:
81
+ target = [n for n in self.l.icons(page) if n.label == f"Folder:{name}"]
82
+ if not target:
83
+ self.log(f" folder {name}: lost it after renaming")
84
+ return
85
+ self.add(app, page, onto=(target[0].x, target[0].y))
86
+ self.log(f" folder {name}: {len(apps)} apps")
87
+
88
+ def build(self, layout: dict) -> None:
89
+ for page, entries in enumerate(layout.get("pages", [])):
90
+ self.log(f"page {page + 1}")
91
+ self.build_page(page, entries)
92
+
93
+ def order(self, page: int, wanted: list[str]) -> bool:
94
+ """Selection sort with one swap per reading. Returns False when it had to stop."""
95
+ for i, label in enumerate(wanted):
96
+ items = self.l.icons(page)
97
+ names = [n.label for n in items]
98
+ if i >= len(items) or names[i] == label:
99
+ continue
100
+ if label not in names:
101
+ self.log(f" {label} is not on page {page + 1}")
102
+ continue
103
+ folders = {n.label for n in items if n.folder}
104
+ j = names.index(label)
105
+ self.l.drag(items[j].x, items[j].y, items[i].x, items[i].y, 2000)
106
+ after = {n.label for n in self.l.icons(page) if n.folder}
107
+ if after - folders:
108
+ self.log(f" stopped: the swap created a folder {sorted(after - folders)}")
109
+ return False
110
+ return True
111
+
112
+ def order_all(self, layout: dict) -> bool:
113
+ ok = True
114
+ for page, entries in enumerate(layout.get("pages", [])):
115
+ ok = self.order(page, [_label(e) for e in entries]) and ok
116
+ return ok
@@ -0,0 +1,48 @@
1
+ """Remove preinstalled apps for the current user, without root.
2
+
3
+ `pm uninstall --user 0` keeps the app on the system partition, so it can come back with
4
+ `cmd package install-existing <package>`. Some system parts refuse and are disabled instead.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from .adb import Adb
9
+
10
+ # Never removed, whatever the list says.
11
+ PROTECTED = {
12
+ "com.google.android.euicc", # eSIM
13
+ "com.android.vending", # Play Store
14
+ "com.google.android.gms", # Play services
15
+ "com.android.phone",
16
+ "com.android.settings",
17
+ "com.android.systemui",
18
+ "com.google.android.dialer",
19
+ "com.android.dialer",
20
+ "com.google.android.apps.messaging",
21
+ "com.android.mms",
22
+ "com.android.launcher",
23
+ }
24
+
25
+
26
+ def remove(adb: Adb, packages: list[str], keep: set[str] = frozenset(), dry_run: bool = False) -> dict[str, str]:
27
+ out: dict[str, str] = {}
28
+ for pkg in packages:
29
+ if pkg in PROTECTED or pkg in keep:
30
+ out[pkg] = "kept"
31
+ continue
32
+ if not adb.installed(pkg):
33
+ out[pkg] = "absent"
34
+ continue
35
+ if dry_run:
36
+ out[pkg] = "would remove"
37
+ continue
38
+ r = adb.shell(f"pm uninstall --user 0 {pkg}")
39
+ if "Success" in r:
40
+ out[pkg] = "removed"
41
+ continue
42
+ r2 = adb.shell(f"pm disable-user --user 0 {pkg}")
43
+ out[pkg] = "disabled" if "disabled" in r2.lower() else f"failed: {r.strip()[:80]}"
44
+ return out
45
+
46
+
47
+ def restore(adb: Adb, package: str) -> bool:
48
+ return "installed" in adb.shell(f"cmd package install-existing {package}").lower()
@@ -0,0 +1,134 @@
1
+ """Drive a stock home screen through its UI, which is the only way on locked-down launchers.
2
+
3
+ On ColorOS (OPPO, OnePlus) the launcher database, its content provider and shortcut pinning are all
4
+ closed to adb without root, so icons are moved the way a person moves them. Positions scale with
5
+ the screen size. Tested on ColorOS 16 (Android 16). Other launchers need their own tuning.
6
+
7
+ Rules learned the hard way:
8
+ - Re-read the screen before every drag. The grid moves after each change.
9
+ - Dropping an icon onto another makes a folder. Dropping onto a folder adds to it.
10
+ - A folder is renamed by tapping its title, selecting all, typing and pressing BACK (not ENTER).
11
+ - Icons cannot be dragged out of a folder. Long-press inside the open folder and choose Remove.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import time
16
+
17
+ from . import ui
18
+ from .adb import Adb
19
+
20
+
21
+ class Launcher:
22
+ def __init__(self, adb: Adb, pause: float = 1.0):
23
+ self.adb = adb
24
+ self.pause = pause
25
+ self.w, self.h = adb.screen_size()
26
+
27
+ def _sleep(self, s: float) -> None:
28
+ time.sleep(s * self.pause)
29
+
30
+ # positions as fractions of the screen, measured on a 1264x2780 screen
31
+ def at(self, fx: float, fy: float) -> tuple[int, int]:
32
+ return int(self.w * fx), int(self.h * fy)
33
+
34
+ def dock_top(self) -> int:
35
+ return int(self.h * 0.845)
36
+
37
+ def in_dock(self, n: ui.Node) -> bool:
38
+ return n.y > self.dock_top()
39
+
40
+ def dump(self) -> list[ui.Node]:
41
+ return ui.dump(self.adb)
42
+
43
+ def home(self) -> None:
44
+ for _ in range(2):
45
+ self.adb.shell("input keyevent KEYCODE_HOME")
46
+ self._sleep(1.6)
47
+
48
+ def page_next(self) -> None:
49
+ (x1, y), (x2, _) = self.at(0.87, 0.58), self.at(0.16, 0.58)
50
+ # 250 ms. A slower swipe is read as a drag and does not change page.
51
+ self.adb.shell(f"input swipe {x1} {y} {x2} {y} 250")
52
+ self._sleep(1.8)
53
+
54
+ def goto(self, page: int) -> None:
55
+ self.home() # HOME is page 0; swiping left from it opens the feed, not a page
56
+ for _ in range(page):
57
+ self.page_next()
58
+
59
+ def drag(self, x1: int, y1: int, x2: int, y2: int, ms: int = 600) -> None:
60
+ # About 600 ms merges reliably. A long hover opens the folder under the finger instead.
61
+ self.adb.shell(f"input draganddrop {x1} {y1} {x2} {y2} {ms}")
62
+ self._sleep(2.5)
63
+
64
+ def tap(self, x: int, y: int) -> None:
65
+ self.adb.shell(f"input tap {x} {y}")
66
+ self._sleep(1.8)
67
+
68
+ def icons(self, page: int) -> list[ui.Node]:
69
+ """Icons and folders on a page, in reading order, without the dock and the label nodes of folders."""
70
+ self.goto(page)
71
+ nodes = self.dump()
72
+ folder_names = {n.label for n in nodes if n.folder}
73
+ items = [n for n in nodes if n.clickable and n.cls in ("TextView", "FrameLayout")
74
+ and not self.in_dock(n) and f"Folder:{n.label}" not in folder_names]
75
+ return sorted(items, key=lambda n: (n.y, n.x))
76
+
77
+ def drawer_search(self, term: str) -> ui.Node | None:
78
+ """Open the app drawer, search, and return the first result whose name contains the first word."""
79
+ self.home()
80
+ (x, y1), (_, y2) = self.at(0.5, 0.83), self.at(0.5, 0.32)
81
+ self.adb.shell(f"input swipe {x} {y1} {x} {y2} 300")
82
+ self._sleep(2.5)
83
+ nodes = self.dump()
84
+ field = [n for n in nodes if n.cls == "EditText"] or [n for n in nodes if n.label.lower().startswith("search")]
85
+ if not field:
86
+ return None
87
+ self.tap(field[0].x, field[0].y)
88
+ self.adb.shell("input keycombination 113 29") # ctrl+a
89
+ self.adb.shell("input text " + term.replace(" ", "%s"))
90
+ self._sleep(2.2)
91
+ first = term.split()[0].lower()
92
+ for n in self.dump():
93
+ if n.cls == "TextView" and n.y < self.dock_top() and first in n.label.lower():
94
+ return n
95
+ return None
96
+
97
+ def rename_open_folder(self, name: str) -> bool:
98
+ field = [n for n in self.dump() if n.cls == "EditText"]
99
+ if not field:
100
+ return False
101
+ self.tap(field[0].x, field[0].y)
102
+ self.adb.shell("input keycombination 113 29")
103
+ self.adb.shell("input text " + name.replace(" ", "%s"))
104
+ self._sleep(1.2)
105
+ self.adb.shell("input keyevent KEYCODE_BACK") # ENTER does not commit the name, BACK does
106
+ self._sleep(2.0)
107
+ return True
108
+
109
+ def long_press(self, x: int, y: int, seconds: float = 1.4) -> None:
110
+ # One shell, so the finger stays down for the whole gesture.
111
+ self.adb.shell(f"input motionevent DOWN {x} {y}; sleep {seconds}; input motionevent UP {x} {y}")
112
+ self._sleep(1.5)
113
+
114
+ def set_autofill(self, on: bool) -> str:
115
+ """Toggle Home screen settings > Icon autofill. Build with it off, then turn it on once to pack the grid."""
116
+ self.home()
117
+ self.long_press(*self.at(0.5, 0.54))
118
+ settings = [n for n in self.dump() if n.label == "Home screen settings" and n.clickable]
119
+ if not settings:
120
+ self.home()
121
+ return "not found"
122
+ self.tap(settings[0].x, settings[0].y)
123
+ nodes = self.dump()
124
+ row = [n for n in nodes if n.label == "Icon autofill"]
125
+ switch = [n for n in nodes if n.cls == "Switch" and row and abs(n.y - row[0].y) < 60]
126
+ state = "not found"
127
+ if switch:
128
+ current = switch[0].label.lower() == "on"
129
+ if current != on:
130
+ self.tap(switch[0].x, switch[0].y)
131
+ state = "on" if on else "off"
132
+ self.adb.shell("input keyevent KEYCODE_BACK")
133
+ self.home()
134
+ return state
@@ -0,0 +1,61 @@
1
+ """Play Store checks and installs.
2
+
3
+ Guessed package names are often wrong (a bank's Android package rarely matches its iOS bundle id),
4
+ so every mapping is checked against the live Play Store page before anything is installed.
5
+ Installs are free apps only, by opening the store page and pressing Install, like a person would.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ import urllib.error
11
+ import urllib.request
12
+
13
+ from .adb import Adb
14
+ from .ui import find_button
15
+
16
+ PLAY_URL = "https://play.google.com/store/apps/details?id={package}&hl=en"
17
+ INSTALL_LABELS = {"Install", "Εγκατάσταση", "Instalar", "Installer", "Installieren", "Installa"}
18
+ DONE_LABELS = {"Open", "Play", "Uninstall", "Update", "Άνοιγμα", "Abrir", "Ouvrir", "Öffnen", "Apri"}
19
+
20
+
21
+ def listed(package: str, url: str = PLAY_URL, timeout: float = 15.0) -> bool | None:
22
+ """True when the Play Store has a page for the package, False when it does not, None on network errors."""
23
+ req = urllib.request.Request(url.format(package=package), headers={"user-agent": "Mozilla/5.0"})
24
+ try:
25
+ with urllib.request.urlopen(req, timeout=timeout) as r:
26
+ return r.status == 200
27
+ except urllib.error.HTTPError as e:
28
+ return False if e.code == 404 else None
29
+ except (urllib.error.URLError, TimeoutError):
30
+ return None
31
+
32
+
33
+ def install(adb: Adb, package: str, log=print, wait_s: float = 240, poll_s: float = 6) -> str:
34
+ """Install one free app through the Play Store app. Returns installed, present, no-button or timeout."""
35
+ if adb.installed(package):
36
+ return "present"
37
+ # market:// can open an OEM store chooser, so the Play Store is named explicitly.
38
+ adb.run("shell", "am", "start", "-a", "android.intent.action.VIEW",
39
+ "-d", f"https://play.google.com/store/apps/details?id={package}", "-p", "com.android.vending")
40
+ button = None
41
+ for _ in range(10): # the store page renders slowly under load
42
+ time.sleep(2.5)
43
+ button = find_button(adb, INSTALL_LABELS | DONE_LABELS)
44
+ if button:
45
+ break
46
+ if not button:
47
+ adb.shell("input keyevent KEYCODE_BACK")
48
+ return "no-button"
49
+ label, (x, y) = button
50
+ if label in DONE_LABELS:
51
+ adb.shell("input keyevent KEYCODE_BACK")
52
+ return "present"
53
+ adb.shell(f"input tap {x} {y}")
54
+ deadline = time.time() + wait_s
55
+ while time.time() < deadline:
56
+ time.sleep(poll_s)
57
+ if adb.installed(package):
58
+ adb.shell("input keyevent KEYCODE_BACK")
59
+ return "installed"
60
+ adb.shell("input keyevent KEYCODE_BACK")
61
+ return "timeout"
@@ -0,0 +1,54 @@
1
+ """Read the phone's screen through uiautomator."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import xml.etree.ElementTree as ET
6
+ from dataclasses import dataclass
7
+
8
+ from .adb import Adb
9
+
10
+
11
+ @dataclass
12
+ class Node:
13
+ label: str
14
+ x: int
15
+ y: int
16
+ cls: str
17
+ clickable: bool
18
+
19
+ @property
20
+ def folder(self) -> bool:
21
+ return self.label.startswith("Folder:")
22
+
23
+
24
+ def _center(bounds: str) -> tuple[int, int]:
25
+ m = [int(v) for v in re.findall(r"-?\d+", bounds)]
26
+ return (m[0] + m[2]) // 2, (m[1] + m[3]) // 2
27
+
28
+
29
+ def parse(xml: str) -> list[Node]:
30
+ try:
31
+ root = ET.fromstring(xml)
32
+ except ET.ParseError:
33
+ return []
34
+ out = []
35
+ for n in root.iter("node"):
36
+ label = (n.get("content-desc") or "").strip() or (n.get("text") or "").strip()
37
+ bounds = n.get("bounds", "")
38
+ if not label or not bounds:
39
+ continue
40
+ x, y = _center(bounds)
41
+ out.append(Node(label, x, y, n.get("class", "").split(".")[-1], n.get("clickable") == "true"))
42
+ return out
43
+
44
+
45
+ def dump(adb: Adb) -> list[Node]:
46
+ adb.shell("uiautomator dump /sdcard/window.xml")
47
+ return parse(adb.shell("cat /sdcard/window.xml"))
48
+
49
+
50
+ def find_button(adb: Adb, labels: set[str]):
51
+ for n in dump(adb):
52
+ if n.label in labels:
53
+ return n.label, (n.x, n.y)
54
+ return None
@@ -0,0 +1,143 @@
1
+ """Open an encrypted iPhone backup made by Finder or iTunes, and copy files out of it.
2
+
3
+ Backups live in ~/Library/Application Support/MobileSync/Backup/<UDID>/ on macOS and in
4
+ %APPDATA%\\Apple Computer\\MobileSync\\Backup on Windows. Info.plist and Manifest.plist can be read
5
+ without the password. Everything else needs the backup password.
6
+
7
+ A file is named by its domain and its relative path. The same relative path can exist in several
8
+ domains, so files are looked up in the decrypted manifest by both, and the manifest row is handed
9
+ to the decryptor (it has no domain parameter).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import plistlib
15
+ import shutil
16
+ import sqlite3
17
+ from collections import Counter, defaultdict
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ DEFAULT_ROOT = Path.home() / "Library/Application Support/MobileSync/Backup"
22
+
23
+ # The files worth pulling, by what they are and where each can go on Android.
24
+ KNOWN = [
25
+ ("HomeDomain", "Library/SMS/sms.db", "sms.db", "Messages", "SMS Backup & Restore XML"),
26
+ ("HomeDomain", "Library/CallHistoryDB/CallHistory.storedata", "CallHistory.storedata", "Call history", "SMS Backup & Restore XML"),
27
+ ("HomeDomain", "Library/AddressBook/AddressBook.sqlitedb", "AddressBook.sqlitedb", "Contacts", "vCard"),
28
+ ("HomeDomain", "Library/Calendar/Calendar.sqlitedb", "Calendar.sqlitedb", "Calendars", "ICS"),
29
+ ("HomeDomain", "Library/Safari/Bookmarks.db", "Safari_Bookmarks.db", "Safari bookmarks", "bookmarks HTML"),
30
+ ("AppDomainGroup-group.com.apple.notes", "NoteStore.sqlite", "NoteStore.sqlite", "Apple Notes", "read with an Apple Notes parser"),
31
+ ("AppDomainGroup-group.net.whatsapp.WhatsApp.shared", "ChatStorage.sqlite", "ChatStorage.sqlite", "WhatsApp chats", "a WhatsApp iOS-to-Android migrator"),
32
+ ("HomeDomain", "Library/SpringBoard/IconState.plist", "IconState.plist", "Home screen layout", "iphone2android layout"),
33
+ ]
34
+
35
+
36
+ def backups(root: Path = DEFAULT_ROOT) -> list[dict]:
37
+ """Every backup under root, with what its Info.plist says (no password needed)."""
38
+ out = []
39
+ if not root.is_dir():
40
+ return out
41
+ for d in sorted(root.iterdir()):
42
+ info = d / "Info.plist"
43
+ if not info.is_file():
44
+ continue
45
+ with open(info, "rb") as f:
46
+ p = plistlib.load(f)
47
+ out.append({
48
+ "udid": d.name,
49
+ "name": p.get("Device Name", ""),
50
+ "model": p.get("Product Type", ""),
51
+ "ios": p.get("Product Version", ""),
52
+ "date": str(p.get("Last Backup Date", "")),
53
+ "apps": len(p.get("Installed Applications", []) or []),
54
+ })
55
+ return out
56
+
57
+
58
+ def installed_apps(udid: str, root: Path = DEFAULT_ROOT) -> list[str]:
59
+ """Bundle ids of the apps installed when the backup was made (no password needed)."""
60
+ with open(root / udid / "Info.plist", "rb") as f:
61
+ return list(plistlib.load(f).get("Installed Applications", []) or [])
62
+
63
+
64
+ @dataclass
65
+ class Backup:
66
+ udid: str
67
+ impl: object # the iOSbackup object
68
+ manifest: Path # durable copy of the decrypted Manifest.db
69
+
70
+ def _row(self, domain: str, relative_path: str) -> dict | None:
71
+ con = sqlite3.connect(self.manifest)
72
+ con.row_factory = sqlite3.Row
73
+ try:
74
+ r = con.execute("SELECT * FROM Files WHERE domain=? AND relativePath=?", (domain, relative_path)).fetchone()
75
+ finally:
76
+ con.close()
77
+ return dict(r) if r else None
78
+
79
+ def rows(self, sql_where: str, params: tuple = ()) -> list[dict]:
80
+ con = sqlite3.connect(self.manifest)
81
+ con.row_factory = sqlite3.Row
82
+ try:
83
+ return [dict(r) for r in con.execute(f"SELECT * FROM Files WHERE {sql_where}", params)]
84
+ finally:
85
+ con.close()
86
+
87
+ def copy_entry(self, row: dict, target_folder: Path, target_name: str) -> Path:
88
+ entry = dict(row)
89
+ entry["manifest"] = entry.pop("file") # the library wants the blob under this key
90
+ target_folder.mkdir(parents=True, exist_ok=True)
91
+ self.impl.getFileDecryptedCopy(manifestEntry=entry, targetName=target_name, targetFolder=str(target_folder))
92
+ return target_folder / target_name
93
+
94
+ def copy(self, domain: str, relative_path: str, target_folder: Path, target_name: str) -> Path | None:
95
+ row = self._row(domain, relative_path)
96
+ if not row:
97
+ return None
98
+ return self.copy_entry(row, target_folder, target_name)
99
+
100
+ def inventory(self) -> dict:
101
+ """Files and bytes per domain, read from the manifest's own size field."""
102
+ from iOSbackup import iOSbackup
103
+
104
+ files, size = Counter(), defaultdict(int)
105
+ for r in self.rows("flags=1"):
106
+ files[r["domain"]] += 1
107
+ try:
108
+ size[r["domain"]] += int(iOSbackup.getFileInfo(r["file"]).get("size") or 0)
109
+ except Exception: # noqa: BLE001 - an unreadable blob only loses its size
110
+ pass
111
+ return {d: {"files": n, "bytes": size[d]} for d, n in files.most_common()}
112
+
113
+
114
+ def open_backup(udid: str, password: str, workdir: Path, root: Path = DEFAULT_ROOT) -> Backup:
115
+ from iOSbackup import iOSbackup
116
+
117
+ impl = iOSbackup(udid=udid, cleartextpassword=password, backuproot=str(root))
118
+ workdir.mkdir(parents=True, exist_ok=True)
119
+ # The decrypted manifest lives in a temp folder that is cleaned up, so keep a copy.
120
+ durable = workdir / "Manifest.decrypted.db"
121
+ shutil.copyfile(impl.manifestDB, durable)
122
+ return Backup(udid=udid, impl=impl, manifest=durable)
123
+
124
+
125
+ def password_from_env_or_prompt() -> str:
126
+ pw = os.environ.get("IPHONE_BACKUP_PASSWORD")
127
+ if pw:
128
+ return pw
129
+ import getpass
130
+
131
+ return getpass.getpass("Backup password: ")
132
+
133
+
134
+ def extract_known(b: Backup, out: Path) -> list[tuple[str, str, Path | None]]:
135
+ """Copy every known database out. Returns (label, destination format, path or None)."""
136
+ got = []
137
+ for domain, rel, name, label, dest in KNOWN:
138
+ try:
139
+ p = b.copy(domain, rel, out, name)
140
+ except Exception: # noqa: BLE001 - a file that fails to decrypt is reported as missing
141
+ p = None
142
+ got.append((label, dest, p))
143
+ return got