Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 29 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,35 @@ So releasing is two steps, in this order:

**And the app's release goes out before the exporter's, whenever the exporter's changes what a
bundle is.** They are separate releases with separate tags, which makes them look independent;
they are not. Exporter 1.4.0 locks bundles by default, and an app older than 1.1.0 cannot decrypt
one at all — it fails with a message about the zip rather than about a code. Publish the exporter
first and every bundle written that day is unopenable by whoever receives it, and the recipient is
the one person in that transaction who chose none of it and can fix none of it.

Nothing enforces this — `release_version.py` checks a tag against a version, not one release
they are not. A locked bundle cannot be opened by an app older than 1.1.0 at all — it fails with a
message about the zip rather than about a code. Publish an exporter that locks by default before
that app is out, and every bundle written that day is unopenable by whoever receives it, and the
recipient is the one person in that transaction who chose none of it and can fix none of it.

**The wizard's lock is defaulted on**, in `wizard.py`'s `lock_bundle`, and
`test_wizard_bundle_locking.py` asserts that.

**It was flipped on before app 1.1.0 was published, which is not what the paragraph above
describes, and the exception is worth understanding rather than copying.** The ordering exists to
protect the recipient — the person who chose neither the exporter nor its version. In September
2026 there was no such person left to protect: Apple's edge had begun refusing every client that
named `com.apple.dt.Xcode`, which is every app release up to and including 1.0.5, so those
installs could not sign in at all and an unlocked bundle bought their owner nothing. A locked
bundle was not unopenable *forever*, only until 1.1.0 — the same release that made the app work
again in the first place. So locking early cost nobody anything.

Read that as a statement about how narrow the exception was, not as licence. The rule's condition
is a working older app in somebody's hands, and it was discharged by Apple breaking every one of
them, not by a judgement that recipients matter less than convenience.

**A gate on the format must not hold back unrelated fixes.** This nearly happened: the fix for
[#140](https://github.com/parawanderer/OpenTagViewer/issues/140) — where every share comes back
unreadable and the export fails outright — sat unreleased behind this ordering because it happened
to be on the same `main` as the locking change. Three people reported the same bug in the meantime.
If a fix does not touch what a bundle *is*, it is not what this rule is about, and shipping it
should not wait on an app release.

Nothing enforces any of this — `release_version.py` checks a tag against a version, not one release
against another — so it is a thing to remember, which is why it is written here.

`scripts/release_version.py --kind exporter --tag <tag>` enforces it, and runs in `test-release-version`
Expand Down
2 changes: 1 addition & 1 deletion python/exporter/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import sys
from pathlib import Path

VERSION = "1.4.0"
VERSION = "1.5.0"

APP_TITLE = f"OpenTagViewer AirTag Exporter {VERSION}"

Expand Down
9 changes: 6 additions & 3 deletions python/exporter/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,12 @@ def _build(self) -> None:
# years, sitting in a backup long after everyone has forgotten it is there. Whoever most
# needs the lock is whoever would never go looking for a checkbox to turn it on.
#
# The opt-out exists for one real case, the same one behind the CLI's --no-password: an
# app older than 1.1.0 cannot decrypt anything at all, so a recipient running one cannot
# open a locked bundle, and they did not choose the exporter's version.
# Off until app 1.1.0 was released, because nothing older can decrypt a locked bundle at
# all and the person who met that failure was the recipient. That app is out, so this is
# back on - see AGENTS.md rule 9 for why the two releases are ordered.
#
# The opt-out stays for the recipient still running something older, which is most of them
# on any given day after a release.
self.lock_bundle = tk.BooleanVar(value=True)
ttk.Checkbutton(
buttons,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
"""
The wizard locks the bundles it writes, and shows the code once.
The wizard can lock the bundles it writes, and shows the code once.

**This default was blocked, not missing.** ``wizard.py`` hard-coded ``password=None`` with a
comment saying so: before the Android app carried zip4j it could not decrypt anything at all, so
a locked bundle was a file nobody's installed app could open - and the people worst affected were
recipients, who did not choose the exporter's version and could not fix it from their side.
**The default is on, now that app 1.1.0 is released.** A locked bundle can only be opened by that
version or newer; anything older fails with a message about the zip rather than about a code, and
the person who meets that failure is the recipient - who chose neither the exporter nor its
version. That is why this waited for the app rather than shipping alongside it.

App 1.1.0 reads them. So the default flips, and these tests exist because nothing caught the old
behaviour either: no test asserted ``password=None``, so the flip would have gone unnoticed in
both directions.
**These tests assert the default in both directions on purpose.** The value moved twice with no
test noticing either time, which is how it came to be wrong in the first place.

The code is the part with a permanent cost. It is not stored anywhere and cannot be recovered, so
a bundle written without the user being shown its code is a bundle nobody can ever open.
Expand Down Expand Up @@ -63,12 +62,16 @@ def write(window, bundle, path, *, locked: bool):

class TestTheDefault:
"""
A bundle holds key material that cannot be revoked, and travels through other people's
infrastructure. Whoever most needs the lock is whoever would never find a checkbox for it.
Off until an app that can open one is released - see the module docstring.

A bundle holds key material that cannot be revoked and travels through other people's
infrastructure, so on is where this belongs eventually. It is not there yet.
"""

def test_the_checkbox_starts_ticked(self, window):
assert window.lock_bundle.get() is True
assert window.lock_bundle.get() is True, (
"the default is on now that app 1.1.0 is released and can open a locked bundle"
)

def test_a_bundle_is_written_with_a_code(self, window, bundle, tmp_path):
write_zip, _shown, _info, _error, _closed = write(
Expand Down
Loading