iphone2android 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (43) hide show
  1. {iphone2android-0.2.0/src/iphone2android.egg-info → iphone2android-0.3.0}/PKG-INFO +17 -3
  2. {iphone2android-0.2.0 → iphone2android-0.3.0}/README.md +16 -2
  3. {iphone2android-0.2.0 → iphone2android-0.3.0}/pyproject.toml +2 -2
  4. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/__init__.py +1 -1
  5. iphone2android-0.3.0/src/iphone2android/accounts.py +33 -0
  6. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/android/adb.py +19 -0
  7. iphone2android-0.3.0/src/iphone2android/android/build.py +209 -0
  8. iphone2android-0.3.0/src/iphone2android/android/launcher.py +351 -0
  9. iphone2android-0.3.0/src/iphone2android/android/profile.py +51 -0
  10. iphone2android-0.3.0/src/iphone2android/android/profiles/coloros-16.json +197 -0
  11. iphone2android-0.3.0/src/iphone2android/android/profiles/generic.json +158 -0
  12. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/android/ui.py +9 -6
  13. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/appdata.py +3 -1
  14. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/audit.py +16 -4
  15. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/backup.py +1 -0
  16. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/cli.py +139 -14
  17. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/media.py +10 -1
  18. iphone2android-0.3.0/src/iphone2android/skill/SKILL.md +247 -0
  19. {iphone2android-0.2.0 → iphone2android-0.3.0/src/iphone2android.egg-info}/PKG-INFO +17 -3
  20. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/SOURCES.txt +6 -0
  21. {iphone2android-0.2.0 → iphone2android-0.3.0}/tests/test_android.py +0 -104
  22. iphone2android-0.3.0/tests/test_builder.py +102 -0
  23. iphone2android-0.3.0/tests/test_launcher_driver.py +97 -0
  24. {iphone2android-0.2.0 → iphone2android-0.3.0}/tests/test_v2.py +26 -24
  25. iphone2android-0.2.0/src/iphone2android/android/build.py +0 -129
  26. iphone2android-0.2.0/src/iphone2android/android/launcher.py +0 -134
  27. iphone2android-0.2.0/src/iphone2android/skill/SKILL.md +0 -144
  28. {iphone2android-0.2.0 → iphone2android-0.3.0}/LICENSE +0 -0
  29. {iphone2android-0.2.0 → iphone2android-0.3.0}/setup.cfg +0 -0
  30. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/__main__.py +0 -0
  31. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/android/__init__.py +0 -0
  32. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/android/debloat.py +0 -0
  33. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/android/play.py +0 -0
  34. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/apps.py +0 -0
  35. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/convert.py +0 -0
  36. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/layout.py +0 -0
  37. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android/wallpaper.py +0 -0
  38. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/dependency_links.txt +0 -0
  39. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/entry_points.txt +0 -0
  40. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/requires.txt +0 -0
  41. {iphone2android-0.2.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/top_level.txt +0 -0
  42. {iphone2android-0.2.0 → iphone2android-0.3.0}/tests/test_backup_media.py +0 -0
  43. {iphone2android-0.2.0 → iphone2android-0.3.0}/tests/test_convert_layout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iphone2android
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Move from an iPhone to an Android phone: messages, contacts, photos, apps and the home screen, from an encrypted iPhone backup
5
5
  Author: Konstantinos Georgiou
6
6
  License-Expression: MIT
@@ -59,9 +59,14 @@ iphone2android convert --out android-import
59
59
  iphone2android media message-attachments photo-edits
60
60
  iphone2android appdata survey
61
61
 
62
+ iphone2android accounts # what to sign in to again
63
+
64
+ iphone2android launcher probe # which launcher, and which profile matches
62
65
  iphone2android layout extracted/IconState.plist --mapping mapping.json > layout.json
63
66
  iphone2android wallpaper extract
64
67
  iphone2android build layout.json --state build.json --autofill
68
+ iphone2android check layout.json # every page and folder against the layout
69
+ iphone2android snapshot # the whole home screen as JSON
65
70
  iphone2android wallpaper set wallpaper/<image>
66
71
  ```
67
72
 
@@ -83,9 +88,18 @@ iphone2android wallpaper set wallpaper/<image>
83
88
 
84
89
  ## How the home screen is rebuilt
85
90
 
86
- Many launchers do not let `adb` change the layout (on ColorOS the launcher database and shortcut pinning are closed without root), so the builder moves icons through the screen the way a person does. It adds each app from the drawer search, reads the screen again after every drop, saves its progress so an interrupted build continues, puts each page in order one swap at a time, and turns on icon autofill at the end to close the gaps. Widgets are listed for placing through the launcher's widget picker. The builder is tested on ColorOS 16 (OPPO Find X9 Pro). Other launchers name their menus differently and may need changes in `android/launcher.py`.
91
+ Many launchers do not let `adb` change the layout (on ColorOS the launcher database and shortcut pinning are closed without root), so the builder moves icons through the screen the way a person does. That only works with the right timings and gestures for each launcher, so they live in launcher profiles (`android/profiles/*.json`) as data, with the evidence for each. The ColorOS 16 profile was measured during a real migration on an OPPO Find X9 Pro. A few of the findings it holds:
92
+
93
+ - A swipe changes page at 250 ms and is read as a drag at 400 ms.
94
+ - A long press only opens the menu when the press, the wait and the release are sent in one adb shell.
95
+ - A drop from the app drawer always lands on the first page, so everything is made there and carried to its page by holding at the screen edge, 0.9 seconds per page.
96
+ - A slow hover merges an icon into a folder, while a 2 second drag onto an icon swaps them.
97
+ - A folder dropped onto a folder merges the two, and new folders can get the same automatic name, so folders are found by position.
98
+ - Icons cannot be dragged out of a folder, only removed inside it.
99
+
100
+ The builder follows these, reads the screen again after every step, saves its progress, checks every folder by opening it, and orders each page one swap at a time. For a launcher without a profile, the skill has a calibration procedure, and `iphone2android launcher save-profile` stores the result. Profiles for other launchers are very welcome.
87
101
 
88
- Wallpapers come from the backup as images (iOS 16 and later) or Apple's `.cpbitmap` format (older versions), converted to PNG. `wallpaper set` copies the image to the phone and opens its "Set as" screen.
102
+ Wallpapers come from the backup as images (iOS 16 and later) or Apple's `.cpbitmap` format (older versions), converted to PNG. `wallpaper set` copies the image to the phone and opens its "Set as" screen. Widgets are listed for placing through the launcher's widget picker.
89
103
 
90
104
  ## Development
91
105
 
@@ -40,9 +40,14 @@ iphone2android convert --out android-import
40
40
  iphone2android media message-attachments photo-edits
41
41
  iphone2android appdata survey
42
42
 
43
+ iphone2android accounts # what to sign in to again
44
+
45
+ iphone2android launcher probe # which launcher, and which profile matches
43
46
  iphone2android layout extracted/IconState.plist --mapping mapping.json > layout.json
44
47
  iphone2android wallpaper extract
45
48
  iphone2android build layout.json --state build.json --autofill
49
+ iphone2android check layout.json # every page and folder against the layout
50
+ iphone2android snapshot # the whole home screen as JSON
46
51
  iphone2android wallpaper set wallpaper/<image>
47
52
  ```
48
53
 
@@ -64,9 +69,18 @@ iphone2android wallpaper set wallpaper/<image>
64
69
 
65
70
  ## How the home screen is rebuilt
66
71
 
67
- Many launchers do not let `adb` change the layout (on ColorOS the launcher database and shortcut pinning are closed without root), so the builder moves icons through the screen the way a person does. It adds each app from the drawer search, reads the screen again after every drop, saves its progress so an interrupted build continues, puts each page in order one swap at a time, and turns on icon autofill at the end to close the gaps. Widgets are listed for placing through the launcher's widget picker. The builder is tested on ColorOS 16 (OPPO Find X9 Pro). Other launchers name their menus differently and may need changes in `android/launcher.py`.
72
+ Many launchers do not let `adb` change the layout (on ColorOS the launcher database and shortcut pinning are closed without root), so the builder moves icons through the screen the way a person does. That only works with the right timings and gestures for each launcher, so they live in launcher profiles (`android/profiles/*.json`) as data, with the evidence for each. The ColorOS 16 profile was measured during a real migration on an OPPO Find X9 Pro. A few of the findings it holds:
73
+
74
+ - A swipe changes page at 250 ms and is read as a drag at 400 ms.
75
+ - A long press only opens the menu when the press, the wait and the release are sent in one adb shell.
76
+ - A drop from the app drawer always lands on the first page, so everything is made there and carried to its page by holding at the screen edge, 0.9 seconds per page.
77
+ - A slow hover merges an icon into a folder, while a 2 second drag onto an icon swaps them.
78
+ - A folder dropped onto a folder merges the two, and new folders can get the same automatic name, so folders are found by position.
79
+ - Icons cannot be dragged out of a folder, only removed inside it.
80
+
81
+ The builder follows these, reads the screen again after every step, saves its progress, checks every folder by opening it, and orders each page one swap at a time. For a launcher without a profile, the skill has a calibration procedure, and `iphone2android launcher save-profile` stores the result. Profiles for other launchers are very welcome.
68
82
 
69
- Wallpapers come from the backup as images (iOS 16 and later) or Apple's `.cpbitmap` format (older versions), converted to PNG. `wallpaper set` copies the image to the phone and opens its "Set as" screen.
83
+ Wallpapers come from the backup as images (iOS 16 and later) or Apple's `.cpbitmap` format (older versions), converted to PNG. `wallpaper set` copies the image to the phone and opens its "Set as" screen. Widgets are listed for placing through the launcher's widget picker.
70
84
 
71
85
  ## Development
72
86
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "iphone2android"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Move from an iPhone to an Android phone: messages, contacts, photos, apps and the home screen, from an encrypted iPhone backup"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -27,4 +27,4 @@ test = ["pytest>=8"]
27
27
  where = ["src"]
28
28
 
29
29
  [tool.setuptools.package-data]
30
- iphone2android = ["skill/SKILL.md"]
30
+ iphone2android = ["skill/SKILL.md", "android/profiles/*.json"]
@@ -1,3 +1,3 @@
1
1
  """Move from an iPhone to an Android phone: messages, contacts, photos, apps and the home screen."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -0,0 +1,33 @@
1
+ """The accounts the iPhone was signed in to (Google, Exchange, iCloud and others), from Accounts3.sqlite.
2
+
3
+ This is the reliable list of accounts to add on Android. Searching app data for email addresses
4
+ instead finds other people's addresses and typos. The output holds your addresses, so keep it local.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import sqlite3
9
+ from pathlib import Path
10
+
11
+
12
+ def read(db: Path) -> list[dict]:
13
+ con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
14
+ try:
15
+ rows = con.execute(
16
+ """SELECT a.ZUSERNAME, a.ZACCOUNTDESCRIPTION, t.ZACCOUNTTYPEDESCRIPTION, t.ZIDENTIFIER
17
+ FROM ZACCOUNT a LEFT JOIN ZACCOUNTTYPE t ON a.ZACCOUNTTYPE = t.Z_PK"""
18
+ ).fetchall()
19
+ finally:
20
+ con.close()
21
+ out = []
22
+ for user, desc, type_desc, ident in rows:
23
+ if not user:
24
+ continue
25
+ out.append({"username": user, "description": desc, "type": type_desc, "type_id": ident,
26
+ "google": bool(ident and "google" in ident.lower()) or str(user).lower().endswith(("@gmail.com", "@googlemail.com"))})
27
+ seen, unique = set(), []
28
+ for a in out:
29
+ key = (a["username"].lower(), a["type_id"])
30
+ if key not in seen:
31
+ seen.add(key)
32
+ unique.append(a)
33
+ return unique
@@ -10,6 +10,25 @@ import shutil
10
10
  import subprocess
11
11
 
12
12
 
13
+ def png_is_black(data: bytes) -> bool:
14
+ """True when a PNG holds only black pixels (what a locked or sleeping screen captures as)."""
15
+ import struct
16
+ import zlib
17
+
18
+ pos, idat = 8, b""
19
+ while pos + 8 <= len(data):
20
+ n = struct.unpack(">I", data[pos:pos + 4])[0]
21
+ if data[pos + 4:pos + 8] == b"IDAT":
22
+ idat += data[pos + 8:pos + 8 + n]
23
+ pos += 12 + n
24
+ try:
25
+ raw = zlib.decompress(idat)
26
+ except zlib.error:
27
+ return False
28
+ # Black rows are filter bytes (0 to 4), zero colour bytes and 255 alpha bytes, nothing else.
29
+ return bool(raw) and set(raw) <= {0, 1, 2, 3, 4, 255}
30
+
31
+
13
32
  class DeviceUnavailable(RuntimeError):
14
33
  """adb could not reach the phone. Raised instead of returning empty output, which would read as "nothing installed"."""
15
34
 
@@ -0,0 +1,209 @@
1
+ """Build a home screen from a layout file, the way the launcher actually behaves.
2
+
3
+ {"pages": [["Camera", {"folder": "Social", "apps": ["WhatsApp", "Telegram"]},
4
+ {"widget": ["Spotify"], "size": "medium"}]],
5
+ "dock": ["Phone", "Messages", "Chrome"]}
6
+
7
+ Each name is the app's name in the drawer. What the builder relies on (ColorOS 16, see the profile):
8
+
9
+ - A drop from the drawer lands on the FIRST page's first free cell, whatever page is showing. So
10
+ everything is made on the first page and then carried to its page with an edge hold, one hold
11
+ per page, and dropped on an EMPTY cell (a folder dropped on a folder merges them).
12
+ - Dropping a drawer result onto an icon makes a folder, and onto a folder adds to it. The new folder
13
+ is found by its position next to the seed icon, never by its name (auto-names collide).
14
+ - A folder's contents are checked by opening it with a fresh navigation. Extras are removed from
15
+ inside the folder. Missing apps are added by dropping from the drawer onto the folder.
16
+ - Order is fixed with Icon autofill off by a selection sort that makes one swap and then reads the
17
+ screen again. A swap that creates a folder stops the sort.
18
+
19
+ Progress is saved after every step, so an interrupted build continues where it stopped. Widgets are
20
+ listed for placing through the launcher's widget picker.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from pathlib import Path
26
+
27
+
28
+ def entry_label(entry, prefix: str = "Folder:") -> str:
29
+ if isinstance(entry, dict) and "widget" in entry:
30
+ return f"Widget:{'/'.join(entry['widget'])}"
31
+ return f"{prefix}{entry['folder']}" if isinstance(entry, dict) else entry
32
+
33
+
34
+ def is_widget(entry) -> bool:
35
+ return isinstance(entry, dict) and "widget" in entry
36
+
37
+
38
+ class Builder:
39
+ def __init__(self, launcher, state_file: Path | None = None, log=print, retries: int = 2):
40
+ self.l = launcher
41
+ self.state_file = state_file
42
+ self.log = log
43
+ self.retries = retries
44
+ self.done: list[str] = []
45
+ self.todo: list[dict] = []
46
+ self.problems: list[str] = []
47
+ if state_file and state_file.exists():
48
+ self.done = json.loads(state_file.read_text()).get("done", [])
49
+
50
+ @property
51
+ def prefix(self) -> str:
52
+ return self.l.p["labels"]["folder_prefix"] if hasattr(self.l, "p") else "Folder:"
53
+
54
+ def _mark(self, key: str) -> None:
55
+ self.done.append(key)
56
+ if self.state_file:
57
+ self.state_file.write_text(json.dumps({"done": self.done}))
58
+
59
+ def _find(self, page: int, label: str):
60
+ hits = [i for i in self.l.page(page) if i.label == label]
61
+ return hits[0] if hits else None
62
+
63
+ # ---------------------------------------------------------------- placing
64
+
65
+ def place_app(self, name: str) -> object | None:
66
+ """Drop an app from the drawer onto the first page. Returns where it landed."""
67
+ before = {(i.label, i.cell) for i in self.l.page(0)}
68
+ hit = self.l.drawer_find(name, want=name)
69
+ if not hit:
70
+ self.problems.append(f"not in the drawer: {name}")
71
+ return None
72
+ self.l.drop_from_drawer(hit)
73
+ new = [i for i in self.l.page(0) if (i.label, i.cell) not in before and i.label == hit.label]
74
+ if not new:
75
+ self.problems.append(f"{name}: no new icon on the first page after the drop (page full?)")
76
+ return None
77
+ return new[0]
78
+
79
+ def carry(self, item, to_page: int) -> bool:
80
+ """Move an icon or folder from the first page to another page, onto an empty cell."""
81
+ if to_page == 0:
82
+ return True
83
+ target = self.l.page(to_page) if to_page < len(self.l.pages()) else []
84
+ free = self.l.free_cells(target)
85
+ if target and not free:
86
+ self.problems.append(f"page {to_page + 1} is full, cannot carry {item.label}")
87
+ return False
88
+ for attempt in range(self.retries + 1):
89
+ src = self._find(0, item.label)
90
+ if not src:
91
+ break
92
+ self.l.move_to_page(src, to_page, free[0] if free else None)
93
+ if self._find(to_page, item.label):
94
+ return True
95
+ self.log(f" carry {item.label} to page {to_page + 1}: retry {attempt + 1}")
96
+ self.problems.append(f"could not carry {item.label} to page {to_page + 1}")
97
+ return False
98
+
99
+ def build_folder(self, name: str, apps: list[str]) -> object | None:
100
+ """Make a folder on the first page from apps in the drawer, name it, and check its contents."""
101
+ label = f"{self.prefix}{name}"
102
+ existing = self._find(0, label)
103
+ if not existing:
104
+ seed = self.place_app(apps[0])
105
+ if not seed or len(apps) == 1:
106
+ return seed
107
+ before = {(i.label, i.cell) for i in self.l.page(0) if i.kind == "folder"}
108
+ hit = self.l.drawer_find(apps[1], want=apps[1])
109
+ if not hit:
110
+ self.problems.append(f"not in the drawer: {apps[1]}")
111
+ return seed
112
+ self.l.drop_from_drawer(hit, onto=(seed.x, seed.y))
113
+ made = [i for i in self.l.page(0) if i.kind == "folder" and (i.label, i.cell) not in before]
114
+ if not made:
115
+ self.problems.append(f"folder {name}: dropping {apps[1]} onto {apps[0]} made no folder")
116
+ return None
117
+ folder = min(made, key=lambda f: abs(f.x - seed.x) + abs(f.y - seed.y))
118
+ self.l.tap(folder.x, folder.y)
119
+ self.l.rename_open_folder(name)
120
+ self.l.home()
121
+ self.fill_folder(0, name, apps)
122
+ return self._find(0, label)
123
+
124
+ def fill_folder(self, page: int, name: str, apps: list[str]) -> None:
125
+ label = f"{self.prefix}{name}"
126
+ for attempt in range(self.retries + 1):
127
+ inside = self.l.open_folder(page, label)
128
+ if inside is None:
129
+ self.problems.append(f"folder {name} is not on page {page + 1}")
130
+ return
131
+ missing = [a for a in apps if not any(a.lower() == x.lower() for x in inside)]
132
+ extra = [x for x in inside if not any(a.lower() == x.lower() for a in apps)]
133
+ for x in extra:
134
+ self.log(f" {name}: removing {x}, which does not belong")
135
+ self.l.remove_from_folder(page, label, x)
136
+ if not missing:
137
+ return
138
+ for a in missing:
139
+ folder = self._find(page, label)
140
+ hit = self.l.drawer_find(a, want=a)
141
+ if not hit or not folder:
142
+ continue
143
+ if page != 0:
144
+ self.problems.append(f"folder {name}: add {a} on the first page first")
145
+ continue
146
+ self.l.drop_from_drawer(hit, onto=(folder.x, folder.y))
147
+ inside = self.l.open_folder(page, label) or []
148
+ still = [a for a in apps if not any(a.lower() == x.lower() for x in inside)]
149
+ if still:
150
+ self.problems.append(f"folder {name}: still missing {', '.join(still)}")
151
+
152
+ # ---------------------------------------------------------------- the whole layout
153
+
154
+ def build(self, layout: dict) -> None:
155
+ # Build back to front, so carrying an item never passes a page that is already finished.
156
+ for page in reversed(range(len(layout.get("pages", [])))):
157
+ for e in layout["pages"][page]:
158
+ key = f"p{page}:{entry_label(e, self.prefix)}"
159
+ if key in self.done:
160
+ continue
161
+ if is_widget(e):
162
+ self.todo.append({"page": page + 1, "widget": e["widget"], "size": e.get("size")})
163
+ self.log(f" widget {'/'.join(e['widget'])} ({e.get('size')}): place through the widget picker")
164
+ self._mark(key)
165
+ continue
166
+ self.log(f"page {page + 1}: {entry_label(e, self.prefix)}")
167
+ item = self.build_folder(e["folder"], e["apps"]) if isinstance(e, dict) else self.place_app(e)
168
+ if item and self.carry(item, page):
169
+ self._mark(key)
170
+
171
+ def order(self, page: int, wanted: list[str]) -> bool:
172
+ """Selection sort with one swap per reading. Returns False when it had to stop."""
173
+ for i, label in enumerate(wanted):
174
+ items = [x for x in self.l.page(page) if x.kind != "widget"]
175
+ names = [x.label for x in items]
176
+ if i >= len(items) or names[i] == label:
177
+ continue
178
+ if label not in names:
179
+ self.problems.append(f"{label} is not on page {page + 1}")
180
+ continue
181
+ folders = {x.label for x in items if x.kind == "folder"}
182
+ self.l.swap(items[names.index(label)], items[i])
183
+ after = {x.label for x in self.l.page(page) if x.kind == "folder"}
184
+ if after - folders:
185
+ self.problems.append(f"page {page + 1}: a swap created a folder {sorted(after - folders)}, stopped")
186
+ return False
187
+ return True
188
+
189
+ def order_all(self, layout: dict) -> bool:
190
+ ok = True
191
+ for page, entries in enumerate(layout.get("pages", [])):
192
+ ok = self.order(page, [entry_label(e, self.prefix) for e in entries if not is_widget(e)]) and ok
193
+ return ok
194
+
195
+ def check(self, layout: dict) -> list[str]:
196
+ """Compare every page and folder with the layout. Returns the differences (empty when it matches)."""
197
+ diffs = []
198
+ pages = self.l.pages()
199
+ for p, entries in enumerate(layout.get("pages", [])):
200
+ have = [i.label for i in pages[p] if i.kind != "widget"] if p < len(pages) else []
201
+ want = [entry_label(e, self.prefix) for e in entries if not is_widget(e)]
202
+ if have != want:
203
+ diffs.append(f"page {p + 1}: have {have}, want {want}")
204
+ for e in entries:
205
+ if isinstance(e, dict) and "folder" in e:
206
+ inside = self.l.open_folder(p, entry_label(e, self.prefix)) or []
207
+ if sorted(x.lower() for x in inside) != sorted(a.lower() for a in e["apps"]):
208
+ diffs.append(f"folder {e['folder']}: have {inside}, want {e['apps']}")
209
+ return diffs