Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
131 commits
Select commit Hold shift + click to select a range
b4125f3
Add clean-room protocol specification for iCloud FindMy key export
parawanderer Aug 13, 2026
4ba4375
Amend specification from clean-room implementation feedback
parawanderer Aug 13, 2026
53e23b5
Answer the decrypted-field form from ciphertext sizes
parawanderer Aug 13, 2026
79a2a52
Specify joining the trust circle after escrow recovery
parawanderer Aug 13, 2026
1ed2ce9
Tell implementers not to look for coherence that is not there
parawanderer Aug 13, 2026
a25558f
Enumerate the trust-circle join messages
parawanderer Aug 13, 2026
379b007
Specify what the protection structure's signature covers, and the pag…
parawanderer Aug 13, 2026
480f878
Correct the escrow label, and keep unknown-field reporting
parawanderer Aug 13, 2026
5290bc7
Explain the companion escrow record, and make partial a cross-check
parawanderer Aug 13, 2026
222fbd0
Correct the companion hypothesis, and specify the peer identifier
parawanderer Aug 13, 2026
5b655dd
Make viability the primary safety rule for escrow deletion
parawanderer Aug 13, 2026
b56fda6
Tidy the open questions across all six stages
parawanderer Aug 13, 2026
1ddfb8e
Record that a deletion which addressed nothing reports success
parawanderer Aug 13, 2026
4457c79
Say that an escrow cleanup is not a fix
parawanderer Aug 13, 2026
2542fbd
Specify how to write a KeyVault message, not only how to read one
parawanderer Aug 13, 2026
4071873
Disambiguate the two identifiers in escrow recovery
parawanderer Aug 13, 2026
c45821f
Say what persists after joining, and what the passcode buys
parawanderer Aug 13, 2026
0c45c69
Specify fetching key shares, and that joining may be unnecessary to read
parawanderer Aug 13, 2026
c595aea
Specify where a peer's public keys come from
parawanderer Aug 13, 2026
d8895a7
Specify what every signature in stage 3 covers
parawanderer Aug 13, 2026
d93535d
A received key share is a CloudKit record, not a protobuf message
parawanderer Aug 13, 2026
023745c
Say that the peer directory is fetched by paging, not one call
parawanderer Aug 13, 2026
af21360
Record what a key share's archive actually holds
parawanderer Aug 13, 2026
6a2412d
Stop uniform decryption failure reading as evidence for the cipher
parawanderer Aug 13, 2026
2b6ca4e
Specify the key share cipher exactly, and correct the view keys
parawanderer Aug 13, 2026
b3f0d4e
Record that the key shares now unwrap, and why the search could not h…
parawanderer Aug 14, 2026
ba9c5eb
Narrow the last gap to the step between view keys and keychain items
parawanderer Aug 14, 2026
f2c8dfd
Specify the step from a view's keys to a keychain item
parawanderer Aug 14, 2026
9d6de94
Say that export is a mode, not a compatibility path
parawanderer Aug 14, 2026
a99dcdd
Correct what cloudKitMetadata holds, and export it
parawanderer Aug 14, 2026
bd04a70
Say what each output format loses, since neither contains the other
parawanderer Aug 14, 2026
b3adf8d
Specify the record save path, so an owner can rename their tags
parawanderer Aug 14, 2026
499792b
Record that Stage 3 now reaches Stage 5's input
parawanderer Aug 14, 2026
5ce7366
Choose the export format per accessory, not per bundle
parawanderer Aug 14, 2026
2248118
Mark the mixed-selection case as not reachable yet
parawanderer Aug 14, 2026
2f7079e
Record that a compressed public key here is a bare x coordinate
parawanderer Aug 14, 2026
83927e7
Ask where a beacon record's protecting key actually lives
parawanderer Aug 14, 2026
55c3b52
Say that a record's key comes from the zone, not the keychain
parawanderer Aug 14, 2026
f09634b
Ask where the rest of the zone keys come from
parawanderer Aug 14, 2026
bb44961
Say which structure the HMAC's third part is, and where zone keys end
parawanderer Aug 14, 2026
4fb89c7
Record that the HMAC verifies and the zone still yields one key
parawanderer Aug 14, 2026
998fea7
Do not claim no derivation exists between a zone key and a record's
parawanderer Aug 14, 2026
5afef87
Rule out the derived key, and report the record's key as unsourced
parawanderer Aug 14, 2026
9b9933c
One key protects all 35 records, and nothing described holds it
parawanderer Aug 14, 2026
4ff48df
Say that a private key blob is public x first, then the scalar
parawanderer Aug 14, 2026
a388a3f
Falsify the blob-layout prediction: that half was already read correctly
parawanderer Aug 14, 2026
62e9738
Ask whether the zone identity's 32 bytes are a scalar or a public x
parawanderer Aug 14, 2026
615887b
The 32 bytes are not the record's key; ask whether they are a key at all
parawanderer Aug 14, 2026
4db1213
Close N: the keyset's checksum was being read as its key
parawanderer Aug 14, 2026
605510d
Update the status table, which was three stages out of date
parawanderer Aug 14, 2026
b62d26c
Say how a terms failure actually arrives, which section 5.2 never did
parawanderer Aug 14, 2026
a4f4011
Write down the export zip layout exactly
parawanderer Aug 14, 2026
b5cf260
Raise the PET lifetime against the terms flow in GAPS
parawanderer Aug 14, 2026
9f54a69
Settle whether the PET is renewed before accepting terms
parawanderer Aug 14, 2026
4ffc88e
Close O1: the PET renewal is settled in §5.2
parawanderer Aug 14, 2026
117cd5a
Say what a rendered alignment record carries, and name the join keys
parawanderer Aug 14, 2026
b09c9eb
Say that cloudKitMetadata's three facts survive as CloudKit fields
parawanderer Aug 14, 2026
9e4d2f2
Doubt that a beacon with no naming record must be exported
parawanderer Aug 14, 2026
7d7f7ca
Ask P1: the unnamed-beacon check needs a run, and correct a bad note
parawanderer Aug 14, 2026
a23f54b
Make omission the default for an unmatched beacon, not synthesis
parawanderer Aug 14, 2026
91c10fe
Record that accessories from this route locate
parawanderer Aug 14, 2026
1e68fff
P3: confirm the model claim, and two things the fixture contradicts
parawanderer Aug 14, 2026
ae97748
Stop two single-account observations reading as format facts
parawanderer Aug 14, 2026
6ef14db
Skip a beacon with no privateKey, rather than one with no name
parawanderer Aug 14, 2026
82b2acc
Q1: the write path is verified against Apple's own Find My
parawanderer Aug 14, 2026
4c5319f
Q3: the keyset checksum is implemented and does not verify
parawanderer Aug 14, 2026
3f6fa42
Record that the write path is verified against Apple's own client
parawanderer Aug 14, 2026
250bf98
Settle the nested keyset checksum: the rule is right, the encoder is not
parawanderer Aug 14, 2026
38748ca
Q3 follow-up: not the empty members, probably the application tag
parawanderer Aug 14, 2026
84d3906
Prefer looking a record up per write to tracking its tag
parawanderer Aug 14, 2026
8895d75
Round R: four things blocking the join, and what is buildable meanwhile
parawanderer Aug 14, 2026
892fbe6
Specify what a joining client must build for itself
parawanderer Aug 14, 2026
3b63975
Name the four escrow roots, and the timestamp format
parawanderer Aug 14, 2026
4682d17
Record round S: enrolment built, three questions about it open
parawanderer Aug 14, 2026
f318ef1
Correct three metadata key names, and say what the record holds
parawanderer Aug 14, 2026
9fce179
Close round S: all four answered, and the code follows
parawanderer Aug 14, 2026
4ad85bf
Say that the entropy is enrolment's input, never its invention
parawanderer Aug 14, 2026
0fc984e
Stop implying a generated password is a recovery path
parawanderer Aug 14, 2026
79309cf
Record round T: the questions a writer has that a reader never asks
parawanderer Aug 14, 2026
c8ea97f
Specify the write direction of the join, and fix what R4 got wrong
parawanderer Aug 14, 2026
1eaaff9
Carry the four escrow roots next to the specification
parawanderer Aug 14, 2026
0c729c9
Record round U: the join is built, and the response message is unnamed
parawanderer Aug 14, 2026
0c0d624
Specify what joinWithVoucher returns, and what restorePoint is
parawanderer Aug 14, 2026
67f9043
Close U1 and U2: they were one question, and the reply was specified
parawanderer Aug 14, 2026
142bf4a
Say never to retry a join, and that string and bytes hide each other
parawanderer Aug 14, 2026
0160666
Type the two tables that were left untyped
parawanderer Aug 14, 2026
125dca9
Record round V: what a live preflight run settled
parawanderer Aug 14, 2026
a79d123
Record what the first live preflight found
parawanderer Aug 14, 2026
ad90ea9
Name the one requirement this work does not remove
parawanderer Aug 14, 2026
c52cffb
Plan the two exporters and when the app joins the circle
parawanderer Aug 14, 2026
ac0f009
Correct two things the wizard already settles
parawanderer Aug 14, 2026
a69d330
Add the sharee, and say the export is the answer for now
parawanderer Aug 14, 2026
3b15574
Record round W: the join went through, and what it does not prove
parawanderer Aug 14, 2026
241bf7b
Close W1: the record the join created recovers, and its keys decrypt
parawanderer Aug 14, 2026
4b6cd80
Record round X: device removal is what makes a bottle non-viable
parawanderer Aug 14, 2026
fbefe58
Add rule 11: the app is one device
parawanderer Aug 14, 2026
1bf1fe3
Brief the desktop exporter's move to iCloud
parawanderer Aug 15, 2026
72ca930
Stop calling the macOS route offline, because it joined too
parawanderer Aug 15, 2026
621bb8f
Make the shared export sink the first stage of the brief
parawanderer Aug 15, 2026
0834d26
Correct why records without a private key are skipped
parawanderer Aug 15, 2026
8c1b863
Fix two sanitisation slips in the committed export fixtures
parawanderer Aug 15, 2026
d574ebe
Add the shared export package that writes the bundle
parawanderer Aug 15, 2026
b4a201b
Point the exporter at iCloud, and give it a CLI
parawanderer Aug 15, 2026
c387de3
Let the app identify an accessory through the shared package
parawanderer Aug 15, 2026
877db2d
Document the CLI, and what the app has to do to catch up
parawanderer Aug 15, 2026
cee9b86
Choose the export route by what the machine can do
parawanderer Aug 15, 2026
638aada
Rebuild the wizard on the same code the CLI runs
parawanderer Aug 15, 2026
e260ecd
Say what an export leaves on the Apple account, and how to remove it
parawanderer Aug 15, 2026
64655f7
Rewrite the exporter's README for what it now is
parawanderer Aug 15, 2026
17f7ed6
Rebuild the wizard's list as a table, and ask for both credentials at…
parawanderer Aug 15, 2026
639f048
Stop the dialogs accepting what Apple will only reject
parawanderer Aug 15, 2026
e911156
Put the windows somewhere a person would look
parawanderer Aug 15, 2026
c7618fa
Add sample key files to try the self-generated tag import with
parawanderer Aug 15, 2026
997302c
Say which fixtures belong where
parawanderer Aug 15, 2026
fd6fa13
Identify a self-generated tag by its key, not by what its file called it
parawanderer Aug 15, 2026
74a0bc6
Make the frozen binary actually start
parawanderer Aug 15, 2026
2a217be
Ship the data files the keychain session needs, and prove it offline
parawanderer Aug 15, 2026
f776f3f
Build a directory rather than a single file, and start twenty times f…
parawanderer Aug 15, 2026
6a1a8cb
Record that the app becomes an exporter, and then most people need no…
parawanderer Aug 15, 2026
009aa26
Stop every export registering another fake Mac
parawanderer Aug 15, 2026
1dce488
Point the exporter's help button at the page that describes it
parawanderer Aug 15, 2026
1a410e9
Stop the old decryptor greeting you with "foo"
parawanderer Aug 15, 2026
6334438
Stop the headless CLI needing a window, and let pyright see the share…
parawanderer Aug 15, 2026
41ea556
Stop a cancelled sign-in leaving the wizard hung for ever
parawanderer Aug 15, 2026
ffdc12a
Stop the CLI registering another fake Mac on every export
parawanderer Aug 15, 2026
0fd90fd
Let the wizard open without an Apple account, and stop it losing your…
parawanderer Aug 15, 2026
c5b6bc5
Keep the tags a key file gave you, and let them be the whole export
parawanderer Aug 15, 2026
9881f8c
Say that the app cannot import the shared package yet
parawanderer Aug 15, 2026
02a8d1e
Say which CI job actually runs the Tk tests
parawanderer Aug 15, 2026
9229aba
Colour the rows you ticked, and count them
parawanderer Aug 15, 2026
0b0919f
Stop a finished test's worker aborting the next one
parawanderer Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .flake8
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
[flake8]
max-line-length = 120
max-complexity = 10
max-complexity = 10

# flake8's own default exclusions do not cover a virtualenv, and `uv sync` puts one inside
# python/. Without this, linting ./python walks site-packages and reports hundreds of findings in
# other people's code - including under the E9/F63/F7/F82 selection that CI treats as fatal.
extend-exclude = .venv,venv,build,dist

# The shared package re-exports its public names lazily, through a module-level __getattr__, so
# the imports in its TYPE_CHECKING block exist only for editors and type checkers. flake8 cannot
# see the connection and reports every one of them as unused.
#
# Both spellings, because flake8 matches the path as it was walked: from the repository root - as
# CI does, to pick up this file - it is python/opentagviewer_export/..., and from python/, as a
# person iterating does, it is opentagviewer_export/... with no leading segment for */ to match.
per-file-ignores =
*/opentagviewer_export/__init__.py:F401
opentagviewer_export/__init__.py:F401
383 changes: 206 additions & 177 deletions .github/workflows/macos-exporter-python.yml

Large diffs are not rendered by default.

46 changes: 25 additions & 21 deletions .github/workflows/macos-scripts-python.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# Lints and tests everything under python/: the desktop exporter and the shared export package.
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: MacOS Scripts
Expand All @@ -24,45 +24,49 @@ jobs:
strategy:
fail-fast: true
matrix:
# 3.9 is what the Xcode Command Line Tools install, so it is the interpreter anyone
# running the wizard from source on a stock Mac actually gets. Dropping it means
# 3.10-only syntax can land and the wizard dies at import on a fresh machine, which
# is exactly how it broke once.
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
# 3.9 used to be here, because it is what the Xcode Command Line Tools install and so what
# anyone running the wizard from source on a stock Mac gets. It is gone because FindMy.py
# requires 3.10, and the iCloud export route is the whole point of this exporter now.
#
# The consequence is real and belongs in the wiki rather than only here: running from
# source - the VM bootstrap - now needs a Python installed first. The built .app is
# unaffected; it bundles its own interpreter.
python-version: ["3.10", "3.11", "3.12", "3.13"]

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@v5

# --frozen: install exactly what uv.lock records and fail if it disagrees with pyproject.toml,
# rather than quietly resolving something else. That is what makes the lock mean anything -
# FindMy.py is pinned there by commit, since it is a branch rather than a release.
- name: Install dependencies
working-directory: ./python
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
pip install -r requirements.txt
run: uv sync --frozen --python ${{ matrix.python-version }}

- name: Lint with flake8
working-directory: . # run flake8 from repository root
working-directory: . # run flake8 from repository root, so it picks up .flake8
run: |
# Run flake8 on the 'python' directory, letting it pick up the .flake8 config file
# stop the build if there are Python syntax errors or undefined names
flake8 ./python --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 ./python --count --exit-zero --statistics
uv run --project python flake8 ./python --count --select=E9,F63,F7,F82 --show-source --statistics
uv run --project python flake8 ./python --count --exit-zero --statistics

# Both suites: the wizard's own, and the shared export package's, which live with the package.
- name: Test with pytest
working-directory: ./python
run: |
pytest ./test
run: uv run --frozen pytest ./test ./opentagviewer_export

# The release check reads VERSION out of wizard.py by parsing the module, so a refactor
# that makes it unreadable - moving it into a class, computing it from parts - would
# otherwise only surface when a release failed. This is that same read, run whenever
# python/ changes.
# The release check reads VERSION out of main/version.py by parsing the module, so a refactor
# that makes it unreadable - moving it into a class, computing it from parts - would otherwise
# only surface when a release failed. This is that same read, run whenever python/ changes.
- name: Check the exporter version is still readable
working-directory: .
run: |
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,9 @@ tmp/
# in - none of which belongs to anybody else working on this.
/.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetSelector.xml

# Fixture-investigation notebook. Its whole job is decoding real exports, so its saved cell
# outputs contain the very things the fixtures under app/src/test/resources/ exist to have
# stripped: device names ("<person>'s iPad"), record UUIDs, key material. Committing it would
# publish, in a notebook, exactly what was redacted out of the plists next to it.
scripts/plist_explorer.ipynb
56 changes: 49 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,23 +108,31 @@ rather than copying file contents into your own commit. Credit them in the PR de

### 9. Bump the exporter's `VERSION` before tagging a release

`VERSION` in `python/main/wizard.py` is the only place the macOS exporter's version is
`VERSION` in `python/exporter/version.py` is the only place the desktop exporter's version is
written. It reaches the window title and, more importantly, every export it produces, as
`via: OpenTagViewer.app:<version>` in `OPENTAGVIEWER.yml` — which is how anyone looking at a
zip later works out which exporter built it.
`via: <producer>:<version>` in `OPENTAGVIEWER.yml` — which is how anyone looking at a zip later
works out what built it.

**There is more than one producer, and they must not claim to be each other.** The windowed
exporter stamps `OpenTagViewer.wizard:<version>`, its CLI stamps `OpenTagViewer.cli:<version>`, and
the Android app will stamp its own. They share `VERSION` because they ship together; the name in
front of it is what makes a bug report answerable. The shared writer takes `via` as a parameter
and never invents one — see `python/opentagviewer_export/bundle.py`.

Nothing patches it at build time, and nothing should: the wizard also runs from source (the
VM bootstrap, `python main/wizard.py`), and those exports stamp `via:` too, so a build-time
VM bootstrap, `python -m exporter.wizard`), and those exports stamp `via:` too, so a build-time
patch would make two artifacts from one commit disagree.

So releasing is two steps, in this order:

1. Commit the `VERSION` bump to `main`
2. Tag that commit `macos-exporter-v<the same version>` and publish the release
2. Tag that commit `exporter-v<the same version>` and publish the release

`scripts/release_version.py --kind exporter --tag <tag>` enforces it, and runs in `test-release-version`
before either build job. A tag that disagrees fails the release rather than shipping a build
that lies about itself. Full procedure: [CONTRIBUTING.md](./CONTRIBUTING.md#releasing-the-macos-exporter).
before any build job. A tag that disagrees fails the release rather than shipping a build that
lies about itself. `macos-exporter-v` is the old spelling and still resolves, because tags already
published keep their name — but it stopped being true when the exporter started building for
Windows and Linux. Full procedure: [CONTRIBUTING.md](./CONTRIBUTING.md#releasing-the-exporter).

### 10. Update the docs that index what you added

Expand All @@ -144,6 +152,40 @@ The test for whether it belongs here rather than in a comment: would somebody hi
reading the code that explains it? Anisette's machine-identity binding is the example — it
presents as auth failing for no reason, hours away from the code responsible.

### 11. The app is one device, and every path must say the same thing

Everything this app does against Apple is attributed to a device identity it invents, and the
user sees the result: an entry in their Apple account's device list, with a *Remove from Account*
button next to the words "If you do not recognise this device".

**So the identity has to be one identity.** It is assembled in more than one place — the client
info string in `anisette/AdiDeviceIdentity.java`, the hardware headers, the serial the FindMy.py
providers take as a parameter — and paths that disagree do not fail. They register **separate
devices**, and the user is invited to remove things they cannot identify, which breaks the
session that was working.

Under the iCloud export route the same identity reaches two further places a person reads: the
escrow record's metadata, which is the only way to recognise a record in a listing, and the
peer's `serialNumber` in the keychain trust circle. A serial that differs between them makes one
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:

- **One source of truth.** A path that needs the identity reads it; it does not compose its own.
- **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
never produce.
- **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.

**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
user reading their device list can recognise it — see the wiki.

---

## Building and testing
Expand Down
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# CLAUDE.md

The rules for this repo live in [AGENTS.md](./AGENTS.md), so that every agent reads the same
file rather than two copies that drift apart. This one exists only to pull it in — Claude Code
loads `CLAUDE.md` automatically, and the line below imports the real thing.

**Put new rules in `AGENTS.md`, not here.**

@AGENTS.md
79 changes: 56 additions & 23 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ wizard).
| Anisette tests | `app/src/androidTest/java/.../anisette/` | as above, **opt-in** | yes, and network |
| UI tests | `AppleLoginFlowTest`, `app/src/androidTest/java/.../ui/` | Espresso, on the managed device | provisioned for you |
| Chaquopy bridge tests | `app/src/test/python/` | pytest | no |
| Desktop wizard tests | `python/test/` | pytest | no |
| Desktop exporter tests | `python/test/` | pytest | no |
| Shared export package | `python/opentagviewer_export/tests/` | pytest | no |
| Tooling tests | `scripts/test/` | pytest | no |

### Run everything
Expand Down Expand Up @@ -295,18 +296,45 @@ enforces that.
Fixtures live in `app/src/test/resources/` — a redacted copy of a real export. See the
README there for what was redacted and why.

### Desktop wizard tests
### Desktop exporter tests

For the macOS export wizard under `python/`.
For the exporter under `python/` — the CLI, the windowed wizard, and the shared export package
they both write bundles with. Dependencies are managed with [uv](https://docs.astral.sh/uv/),
which installs a suitable Python itself:

```bash
cd python
python -m pip install -r requirements.txt flake8 pytest
uv sync

python -m pytest ./test
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
uv run pytest ./test ./opentagviewer_export
uv run flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
```

**Both paths, always.** `python/test/` covers the exporter — the iCloud mapping, the key-file
readers, the CLI's own decisions — and `python/opentagviewer_export/tests/` covers the shared
package that writes the bundle. The second is the one the Android app also depends on, and its
strongest test rebuilds the committed export fixtures in `app/src/test/resources/`
**byte for byte**, so a change in how a record is serialised fails here rather than on a phone.

Two things it deliberately does not cover, because they need an Apple account: signing in, and
everything the account then yields. What happens to the records afterwards is covered in full.

**Two files need Tk and skip without it** — `test_asyncui.py`, for what happens when somebody
closes the progress window, and `test_wizard_list.py`, for the list of accessories. Both drive
real Tk widgets, because what they cover *is* the event loop and the widget: a cancelled progress
window used to leave the wizard hung for ever, and that is not reproducible against a stand-in.

**CI runs them on one job in four.** The matrix is 3.10 to 3.13 on `macos-14`, and only its 3.13
has `_tkinter` — the other three skip both files at collection, so a green square there says
nothing about the window. `-rs` is what tells you which you got:

```bash
uv run pytest ./test -rs
```

`361 passed, 4 skipped` means Tk was missing and neither file ran. `378 passed, 2 skipped` means
they did, and the two remaining skips are the macOS-14-only decryptor tests.

### Tooling tests

```bash
Expand Down Expand Up @@ -416,13 +444,13 @@ source .venv/bin/activate
pip install -r requirements.txt

# 4. Run it.
PYTHONPATH=. python3 main/wizard.py
PYTHONPATH=. python3 exporter/wizard.py
```

</details>

`PYTHONPATH=.` is not optional: `wizard.py` does `from main.airtag_decryptor import ...`, and
running the file directly puts `python/main` on `sys.path` rather than `python/`, so the
`PYTHONPATH=.` is not optional: `wizard.py` does `from exporter.airtag_decryptor import ...`, and
running the file directly puts `python/exporter` on `sys.path` rather than `python/`, so the
import fails. It is the same thing the CI workflow sets.

Then check the export actually contains what it should:
Expand Down Expand Up @@ -517,8 +545,8 @@ python scripts/make_test_beacon_plist.py out.plist --days-old 730
| --- | --- | --- |
| `build-debug.yml` | push/PR to `main` | Translation check, pyright, string tooling tests, instrumented tests on an emulator, JVM tests, Chaquopy bridge tests, debug APK |
| `build-release.yml` | on release | Translation check, JVM tests, Chaquopy bridge tests, release APK |
| `macos-scripts-python.yml` | `python/**` changes | Wizard tests across Python 3.10–3.13 on macOS 14 |
| `macos-exporter-python.yml` | on release | Tag/version check, wizard tests, the PyInstaller build for both architectures |
| `macos-scripts-python.yml` | `python/**` changes | Exporter and shared-package tests across Python 3.10–3.13 on macOS 14 |
| `macos-exporter-python.yml` | on release | Tag/version check, exporter tests, and the PyInstaller build for macOS (both architectures), Windows and Linux |
| `update-contributors.yml` | weekly | Regenerates the contributor list on the Information page, opens a PR if it changed |
| `check-adi-libraries.yml` | weekly | Checks Apple's ADI libraries still match what is checked in, opens an issue if they drifted |

Expand All @@ -528,9 +556,9 @@ emulator definition in CI to keep in step with `app/build.gradle.kts`.

---

## Releasing the macOS exporter
## Releasing the exporter

The exporter's version lives in exactly one place — `VERSION` in `python/main/wizard.py`:
The exporter's version lives in exactly one place — `VERSION` in `python/exporter/version.py`:

```python
VERSION = "1.0.5"
Expand All @@ -543,7 +571,7 @@ be true.

**Nothing rewrites it at build time.** The release tag names the zip and the GitHub release;
the app keeps whatever was committed. That is deliberate rather than an oversight — the wizard
also runs straight from source (the VM bootstrap below, `python main/wizard.py`), and those
also runs straight from source (the VM bootstrap below, `python -m exporter.wizard`), and those
runs stamp `via:` as well. If CI patched the tag into the source, a binary and a from-source
export built from the same commit would claim different versions, which is the same drift in a
place nobody would think to look.
Expand All @@ -552,8 +580,8 @@ So a release is two steps, in this order:

```bash
# 1. Bump it, commit it, push it
# (edit python/main/wizard.py -> VERSION = "1.0.6")
git commit -am "Bump the macOS exporter to 1.0.6"
# (edit python/exporter/version.py -> VERSION = "1.1.1")
git commit -am "Bump the exporter to 1.1.1"
git push origin main

# 2. Write the changes for this version, then create the release as a draft
Expand All @@ -579,23 +607,28 @@ same for the app releases.
The draft is deliberate: the release workflow triggers on `published`, so nothing builds or
ships until someone clicks the button.

The tag must be `macos-exporter-v` followed by exactly what `VERSION` says. Before either
build job starts, `test-release-version` runs:
The tag must be `exporter-v` followed by exactly what `VERSION` says. Before any build job
starts, `test-release-version` runs:

```bash
python scripts/release_version.py --kind exporter --tag macos-exporter-v1.0.6
python scripts/release_version.py --kind exporter --tag exporter-v1.1.0
```

which fails the release, with instructions, if the two disagree — so the mistake costs a
minute rather than an incorrectly labelled build on the releases page. Both build jobs then
take the version from that job's output instead of parsing the tag themselves, so the zip
minute rather than an incorrectly labelled build on the releases page. Every build job then
takes the version from that job's output instead of parsing the tag themselves, so the zip
name, the release title, and the version the app reports cannot come apart.

> **The tag used to be `macos-exporter-v`, and still resolves.** The exporter reads accessories
> out of iCloud now and builds for Windows and Linux as well, so the name stopped being true —
> but tags already published keep theirs forever, and nobody's habits update with a rename. Use
> `exporter-v` for anything new.

You can run the same check locally before tagging:

```bash
python scripts/release_version.py --kind exporter --print # what the source declares
python scripts/release_version.py --kind exporter --tag macos-exporter-v1.0.6 # would this tag be accepted?
python scripts/release_version.py --kind exporter --print # what the source declares
python scripts/release_version.py --kind exporter --tag exporter-v1.1.0 # would this tag be accepted?
```

If you tagged before bumping, the fix is to push the bump, delete the release and its tag, and
Expand Down
Loading
Loading