From b8654e9ad0249c7090e035a62efb836be1b3e232 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 16 Aug 2026 19:01:17 +0200 Subject: [PATCH 1/3] Record that aligning a header means aligning the bytes, not the value Rule 11 said the identity has to be one identity and left "the same" undefined. It is not obvious, and getting it wrong produces nothing to notice: FindMy.py sends X-Apple-I-MD-LU as base64(uid) and uppercases X-Mme-Device-Id, while the Java ADI path sends what it is handed. Passing Java's string to Python therefore aligns one field and leaves the other as two different values - alignment that reads as alignment and is not. Nothing complains about that, on either side, ever. It was caught only because the upstream parameter docs said to check, and then only by reading what the library actually does with the value rather than what it is called. Also states where the six identity values now live, since "one source of truth" was a principle with no address attached. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a2dc6d07..519367d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,9 +180,13 @@ client look like several — and by [findmy-export §5.2](./docs/findmy-export/03-keychain-trust.md), serials are exactly what distinguishes devices there. -Three things follow: +Four things follow: - **One source of truth.** A path that needs the identity reads it; it does not compose its own. + `AdiDeviceIdentity.Hardware` is it — Python reads the six values across the bridge rather than + keeping a copy, because there is no single right answer to keep: an install from before the + choice existed must present the Mac its ADI was provisioned with, while a fresh one presents + the iPhone. - **The parts must agree with each other.** Model, OS version, build, CFNetwork and Darwin describe one real release ([findmy-export §2.2](./docs/findmy-export/01-authentication.md)). Claiming a Mac in one string and an iPhone in another is a contradiction Apple's own clients @@ -190,6 +194,14 @@ Three things follow: - **The serial is a label, and the only field here the user actually sees.** `0PENTAGVIEWR` is confirmed accepted and displayed. Without one, Apple omits the row entirely, leaving an entry with nothing to tell it apart from real hardware. +- **Sending the same value is not the same as sending the same bytes.** Two of these fields are + transformed on the way out, and only by one side. FindMy.py sends `X-Apple-I-MD-LU` as + `base64(uid)` and uppercases `X-Mme-Device-Id`; the Java ADI path sends what it is given. So + handing Python the string Java sends aligns one field and silently leaves the other as two + different values — **alignment that reads as alignment and is not**, which is worse than none, + because nothing ever complains. Java renders the header per hardware profile for exactly this + reason, and `IdentityBridgeTest` pins both directions. Check what actually goes on the wire + before believing two paths agree. **Changing it later adds an entry rather than renaming one**, and may require signing in again, so it is not a thing to adjust casually once shipped. Document what the app registers as, so a From 3260561cd562832a73756bfeff3a6ac6b8e0ea8b Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 16 Aug 2026 19:12:17 +0200 Subject: [PATCH 2/3] Read either kind of tag back, not only the Apple-paired one The fetch path deserialized every stored accessory with FindMyAccessory.from_json, which is the wrong class for a self-generated tag and asserts its own type tag - so a bundle carrying one could be imported and then never located. The two are sibling classes, not a base and a subclass. An Apple-paired accessory derives its keys from a master key and two secrets; an OpenHaystack-style one carries a plain list of pre-generated keys and derives nothing. FindMy.py has no factory that reads both, so the dispatch is ours, keyed by the library's own type tag - which each from_json asserts, so a mapping handed to the wrong class fails loudly rather than half-loading. **Everything else the fetch path does was already polymorphic.** keys_between, get_min_index, get_max_index, update_alignment and to_json are on both classes, so deserialization was the only place the difference mattered and the rest of the path never learns which kind it has. The annotations say StoredAccessory now - a union of the two, rather than their shared RollingKeyPairSource, because that base carries the key methods but not to_json. Checked before changing it that this is not a new strictness for anyone: FindMy's own from_json already asserts val["type"] == "accessory", so a stored row without one would be failing today. Existing rows have it, and there is a test that an Apple-paired tag still loads and still round-trips - which is the case with something to lose, since every beacon an existing user has is stored that way. Two test stubs monkeypatched FindMy.py's class to hand back a fake from a blob with no type at all. They now replace the dispatch instead, which is the seam those tests actually mean and stops them mutating a library class. Verified by swapping the two entries: three tests went red, one for the upgrade case and two for the new one. 94 Python tests pass, pyright and flake8 clean. Still to come for a self-generated tag: the importer's own directory matcher and somewhere to store it. This is the half that had no Java in it. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/python/main.py | 79 ++++++++++++-- app/src/test/python/test_main.py | 102 +++++++++++++++++- app/src/test/python/test_main_ranged_fetch.py | 9 +- 3 files changed, 171 insertions(+), 19 deletions(-) diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index eb09dda9..5f32b8b6 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any, NamedTuple +from typing import Any, NamedTuple, cast import json import time import traceback @@ -9,6 +9,7 @@ import NSKeyedUnArchiver from findmy import FindMyAccessory +from findmy.accessory import FixedRollingKeyPairAccessory from findmy.reports import ( RemoteAnisetteProvider, AppleAccount, @@ -541,7 +542,65 @@ def _filterReportsByTimeRange(reports, startMs, endMs): _RANGE_FETCH_RETRY_DELAY_SECONDS = 1 -def _isAlignmentWide(accessory: FindMyAccessory, start, end) -> int: +StoredAccessory = FindMyAccessory | FixedRollingKeyPairAccessory +""" +Either kind of tag this app can locate. + +A union rather than their shared base, because the base is not enough: `RollingKeyPairSource` +has the key methods but not `to_json`, which comes from `Serializable` further along a separate +line. Naming the two concrete classes says what is actually true - these two, and adding a +third is a deliberate edit here. +""" + +ACCESSORY_TYPES: dict[str, type[StoredAccessory]] = { + "accessory": FindMyAccessory, + "custom_rolling_key_accessory": FixedRollingKeyPairAccessory, +} +""" +Which class reads which stored accessory, keyed by FindMy.py's own `type` tag. + +**Two sibling classes, not a base and a subclass.** An Apple-paired accessory derives its keys +from a master key, a shared secret and a secondary secret; a self-generated one - OpenHaystack +style - carries a plain list of pre-generated keys and derives nothing. Neither is a special +case of the other, and FindMy.py has no factory that reads both, so the dispatch lives here. + +The tag is theirs, written by `to_json` and asserted by each `from_json`, so a mapping handed +to the wrong class fails loudly rather than half-loading. +""" + + +def accessoryFromJson(accessoryJson: str) -> StoredAccessory: + """ + Rebuild a stored accessory, whichever kind it is. + + Everything the fetch path does to an accessory - `keys_between`, `get_min_index`, + `get_max_index`, `update_alignment`, `to_json` - is on `RollingKeyPairSource`, which both + kinds implement. So this is the **only** place the difference matters, and the rest of the + fetch path never learns which it has. + + :raises ValueError: if the stored JSON names a type this version cannot read. Deliberately + loud: the alternative is guessing, and guessing wrong means fetching against keys that + belong to a different derivation - which finds nothing, and looks like a tag out of range + rather than like a bug. + """ + mapping: Any = json.loads(accessoryJson) + kind = mapping.get("type") if isinstance(mapping, dict) else None + + accessoryType = ACCESSORY_TYPES.get(kind) if isinstance(kind, str) else None + if accessoryType is None: + raise ValueError( + f"stored accessory has type {kind!r}, which this version cannot read - " + f"known types are {sorted(ACCESSORY_TYPES)}" + ) + + # Cast because the two from_json signatures each want their own TypedDict, and this is + # untyped JSON off disk. Validating it is precisely what from_json does - it asserts the + # type tag and raises on a missing field - so re-describing the shape here would be a + # second implementation of a check the library already owns. + return accessoryType.from_json(cast(Any, mapping)) + + +def _isAlignmentWide(accessory: StoredAccessory, start, end) -> int: """Width of the key-index range a history fetch would search, or 0 if unknown.""" try: return accessory.get_max_index(end) - accessory.get_min_index(start) @@ -563,7 +622,7 @@ class AccessoryFetch(NamedTuple): bounded_to_window: bool -def _fetchReportsForAccessory(account: AppleAccount, accessory: FindMyAccessory, start, end): +def _fetchReportsForAccessory(account: AppleAccount, accessory: StoredAccessory, start, end): """ Fetch reports for one accessory, avoiding a full-history key search when possible. @@ -613,7 +672,7 @@ def _chunk(items, size): return [items[i:i + size] for i in range(0, len(items), size)] -def _fetchReportsInRange(account: AppleAccount, accessory: FindMyAccessory, start, end): +def _fetchReportsInRange(account: AppleAccount, accessory: StoredAccessory, start, end): """ Fetch the reports an accessory produced inside a specific time window. @@ -679,7 +738,7 @@ def _fetchReportsInRange(account: AppleAccount, accessory: FindMyAccessory, star return reports -def _alignBeforeRangedFetch(account: AppleAccount, accessory: FindMyAccessory, start, end) -> bool: +def _alignBeforeRangedFetch(account: AppleAccount, accessory: StoredAccessory, start, end) -> bool: """ Narrow the key index range before generating keys for it. @@ -707,7 +766,7 @@ def _alignBeforeRangedFetch(account: AppleAccount, accessory: FindMyAccessory, s return True -def _keysForRange(accessory: FindMyAccessory, start, end): +def _keysForRange(accessory: StoredAccessory, start, end): """The `(index, key)` pairs covering a time window, capped at what one fetch may search.""" try: indexed_keys = list(accessory.keys_between(start, end)) @@ -734,7 +793,7 @@ class _ChunkFetchError(Exception): """One request of a ranged fetch failed, after its retries.""" -def _fetchChunkAndAlign(account: AppleAccount, accessory: FindMyAccessory, chunk, index_by_key): +def _fetchChunkAndAlign(account: AppleAccount, accessory: StoredAccessory, chunk, index_by_key): """ Fetch one request's worth of keys and feed what comes back into the accessory's alignment. @@ -772,7 +831,7 @@ def _fetchChunkAndAlign(account: AppleAccount, accessory: FindMyAccessory, chunk return reports -def _updateAlignment(accessory: FindMyAccessory, report, index): +def _updateAlignment(accessory: StoredAccessory, report, index): if index is None: return try: @@ -860,7 +919,7 @@ def getLastReports( print(f"Fetching report for {beaconId} for the last {hoursBack} hours...") - airtag = FindMyAccessory.from_json(json.loads(accessoryJson)) + airtag = accessoryFromJson(accessoryJson) start_dt = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc) now_dt = datetime.fromtimestamp(now_ms / 1000, tz=timezone.utc) @@ -931,7 +990,7 @@ def getReports( print(f"Fetching report for {beaconId} in time range {unixStartMs}-{unixEndMs}...") - airtag = FindMyAccessory.from_json(json.loads(accessoryJson)) + airtag = accessoryFromJson(accessoryJson) start_dt = datetime.fromtimestamp(unixStartMs / 1000, tz=timezone.utc) end_dt = datetime.fromtimestamp(unixEndMs / 1000, tz=timezone.utc) diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py index e01634b5..85c1c48c 100644 --- a/app/src/test/python/test_main.py +++ b/app/src/test/python/test_main.py @@ -66,6 +66,102 @@ def test_convertPlistToJson_produces_restorable_json(plist_path): assert restored is not None +# -------------------------------------------------------------------------- +# accessoryFromJson +# +# Two kinds of tag now reach the fetch path, and they are sibling classes rather than +# a base and a subclass: an Apple-paired accessory derives its keys from a master key +# and two secrets, a self-generated one carries a plain list and derives nothing. +# FindMy.py has no factory that reads both, so the dispatch is ours - which makes +# "does an existing user's stored tag still load" a question worth asking out loud. +# -------------------------------------------------------------------------- + +_CUSTOM_ACCESSORY = { + "type": "custom_rolling_key_accessory", + # Two 28-byte private keys, which is the size FindMy.py's KeyPair expects. + "private_keys": ["11" * 28, "22" * 28], + "name": "A tag nobody paired", + "identifier": "openhaystack-1", +} + + +@pytest.mark.skipif(not BEACON_PLISTS, reason="no redacted beacon fixture available") +def test_an_apple_paired_tag_still_loads(): + """ + The upgrade case, and the one with something to lose. + + Every beacon an existing user has is stored as this. If the dispatch got it wrong they + would all stop locating at once, on a build that installed cleanly. + """ + stored = main.convertPlistToJson(_read_plist_xml(BEACON_PLISTS[0])) + + accessory = main.accessoryFromJson(stored) + + assert isinstance(accessory, main.FindMyAccessory) + # Round-trips through the same door it came out of, which is what the fetch path does + # after every call to persist the updated alignment. + assert main.accessoryFromJson(json.dumps(accessory.to_json())) is not None + + +def test_a_self_generated_tag_loads_as_the_other_kind(): + accessory = main.accessoryFromJson(json.dumps(_CUSTOM_ACCESSORY)) + + assert isinstance(accessory, main.FixedRollingKeyPairAccessory) + assert accessory.identifier == "openhaystack-1" + assert accessory.name == "A tag nobody paired" + + +def test_a_self_generated_tag_survives_the_round_trip_the_fetch_path_makes(): + """ + `getLastReports` writes `to_json()` back to the database after every fetch, so a tag that + reads but does not re-read would work once and then be unreadable - a failure that only + appears on the *second* refresh. + """ + once = main.accessoryFromJson(json.dumps(_CUSTOM_ACCESSORY)) + twice = main.accessoryFromJson(json.dumps(once.to_json())) + + assert twice.identifier == once.identifier + assert list(twice.keys_between( + datetime(2026, 1, 1, tzinfo=timezone.utc), + datetime(2026, 1, 2, tzinfo=timezone.utc), + )) == list(once.keys_between( + datetime(2026, 1, 1, tzinfo=timezone.utc), + datetime(2026, 1, 2, tzinfo=timezone.utc), + )) + + +def test_both_kinds_offer_everything_the_fetch_path_uses(): + """ + The reason the rest of the fetch path never learns which kind it has. + + If a future library version moved one of these off one of the two classes, the fetch would + fail at runtime for that kind only - and only for whoever owns that kind of tag. + """ + for accessoryType in main.ACCESSORY_TYPES.values(): + for method in ("keys_between", "get_min_index", "get_max_index", + "update_alignment", "to_json", "from_json"): + assert hasattr(accessoryType, method), f"{accessoryType.__name__} lost {method}" + + +def test_an_unknown_type_is_refused_rather_than_guessed_at(): + """ + Loud, because the quiet alternative is worse. + + Guessing would fetch against keys from a different derivation, which finds nothing and + reads as a tag that is simply out of range rather than as a bug. + """ + with pytest.raises(ValueError) as raised: + main.accessoryFromJson(json.dumps({"type": "something_from_the_future"})) + + assert "something_from_the_future" in str(raised.value) + + +@pytest.mark.parametrize("blob", ['{"no": "type"}', '"a string"', "null", "[]"]) +def test_json_that_is_not_an_accessory_is_refused(blob): + with pytest.raises(ValueError): + main.accessoryFromJson(blob) + + def test_convertPlistToJson_returns_none_on_garbage(): """Failure must be None, not an exception - the caller retries later.""" assert main.convertPlistToJson("not a plist at all") is None @@ -282,8 +378,10 @@ def get(self, i): def _runGetLastReports(monkeypatch, fetch_result, hours_back=24): - monkeypatch.setattr( - main.FindMyAccessory, "from_json", staticmethod(lambda _json: _FakeAirtag())) + # The dispatch, not the library class. These tests are about what getLastReports does with + # an accessory, not about reading one - and patching FindMy.py's own from_json used to let + # them feed it a blob with no "type", which the real thing has always rejected. + monkeypatch.setattr(main, "accessoryFromJson", lambda _json: _FakeAirtag()) monkeypatch.setattr( main, "_fetchReportsForAccessory", lambda *args, **kwargs: fetch_result) diff --git a/app/src/test/python/test_main_ranged_fetch.py b/app/src/test/python/test_main_ranged_fetch.py index 99a728e4..8ef7f9f8 100644 --- a/app/src/test/python/test_main_ranged_fetch.py +++ b/app/src/test/python/test_main_ranged_fetch.py @@ -326,13 +326,8 @@ def to_json(self): @pytest.fixture def stub_accessory(monkeypatch): - """from_json needs real accessory data; the tests here only care about failure handling.""" - class StubFindMyAccessory: - @staticmethod - def from_json(_data): - return StubAccessory() - - monkeypatch.setattr(main, "FindMyAccessory", StubFindMyAccessory) + """Reading an accessory needs real data; the tests here only care about failure handling.""" + monkeypatch.setattr(main, "accessoryFromJson", lambda _json: StubAccessory()) def test_getReports_reports_an_error_when_every_accessory_fails(monkeypatch, stub_accessory): From 5376722a554aadca8fa16ec1e3c7fe2588fe4880 Mon Sep 17 00:00:00 2001 From: Shane B Date: Sun, 16 Aug 2026 19:24:11 +0200 Subject: [PATCH 3/3] Pin one FindMy.py for the whole repository, and assert it The exporter tracked the branch while the app pinned a commit, so the two drifted by construction: every `uv lock` picked up whatever had been pushed since. The lockfile made each *build* reproducible, but "which FindMy.py does this repository use" had two answers, and today they were 47c4f89 and 102dd8e. That matters more here than in a repository of two unrelated programs, because `opentagviewer_export` is shared code that runs under both. Chaquopy packages it into the APK, where it executes against the pin in app/build.gradle.kts, and the desktop exporter runs the same files from source against python/uv.lock. One package behind two library versions breaks exactly one of its two consumers, on the first API difference, and nothing would say which. So python/pyproject.toml names a rev rather than a branch, and a test asserts all four places agree - app/build.gradle.kts, the bridge requirements, pyproject.toml and uv.lock. It reports which file disagrees and what each one says, because the useful failure is not "they differ". Checked before moving it that accessory.py is unchanged between the two commits, so the custom_rolling_key_accessory mapping the exporter writes is byte-identical to what the app now reads. The diff is confined to account.py, anisette.py and the keychain modules - the identity work - which the exporter deliberately does not use: it has no ADI provisioning of its own, so there is no second half to contradict, and python/exporter/identity.py says as much. Verified by drifting pyproject back one commit and watching the test name it. 95 bridge tests pass; the exporter's own suite has 7 pre-existing Windows-only failures in test_scriptable.py, unrelated to this - secrets.py refuses a file readable by other users, which no Windows temp file satisfies. They fail identically on a pristine tree. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/test/python/test_main.py | 49 ++++++++++++++++++++++++++++++++ python/pyproject.toml | 20 +++++++++---- python/uv.lock | 32 ++++++++++----------- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/app/src/test/python/test_main.py b/app/src/test/python/test_main.py index 85c1c48c..89f94f72 100644 --- a/app/src/test/python/test_main.py +++ b/app/src/test/python/test_main.py @@ -13,6 +13,8 @@ from pathlib import Path import json +import re + import pytest import main @@ -600,6 +602,53 @@ def test_a_session_that_still_works_restores_normally(): # Guard against the test environment drifting from what the app ships # -------------------------------------------------------------------------- +# The four files spell the same pin four ways - `...FindMy.py@`, `?rev=#`, and +# TOML's `{ git = "...FindMy.py", rev = "" }` - so this matches a full sha anywhere on a +# line that names the repository, rather than one exact syntax. Deliberately not "any 40-hex +# string in the file": uv.lock is full of other packages' revisions. +_FINDMY_SHA = re.compile(r"FindMy\.py[^\n]{0,80}?([0-9a-f]{40})") + + +def test_the_whole_repository_pins_one_findmy(): + """ + One repository, one FindMy.py - in the app build, the bridge tests, and the exporter. + + **Because `opentagviewer_export` is shared code that runs under both.** Chaquopy packages it + into the APK, where it executes against the pin in `app/build.gradle.kts`, and the desktop + exporter runs the same files from source against `python/uv.lock`. Two commits of one library + behind one package is a bug waiting for the first API difference, and it would break exactly + one of the two consumers. + + The exporter tracked the *branch*, so this drifted by construction: every `uv lock` picked up + whatever had been pushed since. A build was still reproducible - the lockfile records what it + resolved - but "which FindMy.py does this repository use" had two answers. + + Three files, one sha. Moving it means moving all three. + """ + root = Path(__file__).resolve().parents[3].parent + + sources = { + "app/build.gradle.kts": root / "app" / "build.gradle.kts", + "app/src/test/python/requirements.txt": Path(__file__).resolve().parent + / "requirements.txt", + "python/pyproject.toml": root / "python" / "pyproject.toml", + "python/uv.lock": root / "python" / "uv.lock", + } + + found = {} + for name, path in sources.items(): + assert path.is_file(), f"{name} is missing - this test no longer checks what it thinks" + shas = set(_FINDMY_SHA.findall(path.read_text(encoding="utf-8"))) + assert shas, f"{name} does not pin FindMy.py to a commit" + assert len(shas) == 1, f"{name} names more than one FindMy.py commit: {sorted(shas)}" + found[name] = shas.pop() + + assert len(set(found.values())) == 1, ( + "the repository pins more than one FindMy.py:\n" + + "\n".join(f" {name}: {sha}" for name, sha in sorted(found.items())) + ) + + def test_pinned_versions_match_the_app_build(): """ These tests only mean anything if they run against the same library the APK ships. diff --git a/python/pyproject.toml b/python/pyproject.toml index d33faa4d..d4c968f5 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -22,12 +22,22 @@ dependencies = [ # writing an encrypted bundle needs this. Pulls pycryptodomex, which is the same library # as pycryptodome above under a different namespace - both end up installed. "pyzipper==0.4.0", - # The iCloud export route. A branch rather than a release: the iCloud keychain export work + # The iCloud export route. A fork rather than a release: the iCloud keychain export work # is unreleased. This becomes a plain `FindMy==` pin once it lands on PyPI. # - # Unlike the Chaquopy pin in app/build.gradle.kts, this one is reproducible anyway - uv.lock - # records the resolved commit, so two builds of the same commit of this repository install - # the same FindMy.py. Moving to a newer one is `uv lock --upgrade-package FindMy`. + # **The commit is in [tool.uv.sources], and it is the same one app/build.gradle.kts + # installs.** It tracked the branch until it turned out that this repository ships one + # shared package - opentagviewer_export - to two consumers running two different FindMy.py + # commits: Chaquopy packages it into the APK, and the exporter runs it from source. A branch + # made that drift by construction, and the first API difference would have broken exactly + # one of them. + # + # uv.lock recorded the resolved commit, so a *build* was always reproducible - but "which + # FindMy.py does this repository use" still had two answers. Now it has one, asserted by + # test_main.py::test_the_whole_repository_pins_one_findmy. + # + # Moving it means editing three places together: here, app/build.gradle.kts, and + # app/src/test/python/requirements.txt, then `uv lock`. "FindMy", # Checkboxes and arrow-key selection for the CLI. Pure Python, no native code, and it works # on Windows - which rules out curses. See main/prompts.py for what happens without a @@ -59,7 +69,7 @@ constraint-dependencies = [ ] [tool.uv.sources] -FindMy = { git = "https://github.com/parawanderer/FindMy.py", branch = "feat/icloud-keychain-export" } +FindMy = { git = "https://github.com/parawanderer/FindMy.py", rev = "102dd8ea14767d2a2aa745186ac23276f32689f1" } [dependency-groups] # Only the release build installs this, with `uv sync --no-default-groups --group build`. It is diff --git a/python/uv.lock b/python/uv.lock index eb979ec3..81ce8919 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -529,8 +529,8 @@ resolution-markers = [ "platform_machine == 'x86_64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "cffi", marker = "platform_machine == 'x86_64' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } wheels = [ @@ -547,8 +547,8 @@ resolution-markers = [ "platform_machine != 'x86_64' or sys_platform != 'darwin'", ] dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "cffi", marker = "(platform_machine != 'x86_64' and platform_python_implementation != 'PyPy') or (platform_python_implementation != 'PyPy' and sys_platform != 'darwin')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ @@ -649,7 +649,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -659,7 +659,7 @@ wheels = [ [[package]] name = "findmy" version = "0.10.1" -source = { git = "https://github.com/parawanderer/FindMy.py?branch=feat%2Ficloud-keychain-export#47c4f898e4664420d203569a94bae435af73a252" } +source = { git = "https://github.com/parawanderer/FindMy.py?rev=102dd8ea14767d2a2aa745186ac23276f32689f1#102dd8ea14767d2a2aa745186ac23276f32689f1" } dependencies = [ { name = "aiohttp" }, { name = "anisette" }, @@ -1032,7 +1032,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "findmy", git = "https://github.com/parawanderer/FindMy.py?branch=feat%2Ficloud-keychain-export" }, + { name = "findmy", git = "https://github.com/parawanderer/FindMy.py?rev=102dd8ea14767d2a2aa745186ac23276f32689f1" }, { name = "pycryptodome", specifier = "==3.22.0" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "pyzipper", specifier = "==0.4.0" }, @@ -1717,7 +1717,7 @@ name = "winrt-runtime" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } wheels = [ @@ -1743,7 +1743,7 @@ name = "winrt-windows-devices-bluetooth" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } wheels = [ @@ -1769,7 +1769,7 @@ name = "winrt-windows-devices-bluetooth-advertisement" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } wheels = [ @@ -1795,7 +1795,7 @@ name = "winrt-windows-devices-bluetooth-genericattributeprofile" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } wheels = [ @@ -1821,7 +1821,7 @@ name = "winrt-windows-devices-enumeration" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } wheels = [ @@ -1847,7 +1847,7 @@ name = "winrt-windows-devices-radios" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } wheels = [ @@ -1873,7 +1873,7 @@ name = "winrt-windows-foundation" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } wheels = [ @@ -1899,7 +1899,7 @@ name = "winrt-windows-foundation-collections" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } wheels = [ @@ -1925,7 +1925,7 @@ name = "winrt-windows-storage-streams" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } wheels = [