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 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..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 @@ -66,6 +68,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 +380,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) @@ -502,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/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): 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 = [