diff --git a/AGENTS.md b/AGENTS.md index cd026ad0..cfccfaf7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` enforces it, and runs in `test-release-version` diff --git a/python/exporter/version.py b/python/exporter/version.py index b7ad3e75..4ff4448f 100644 --- a/python/exporter/version.py +++ b/python/exporter/version.py @@ -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}" diff --git a/python/exporter/wizard.py b/python/exporter/wizard.py index feaddea8..0c3dd48d 100644 --- a/python/exporter/wizard.py +++ b/python/exporter/wizard.py @@ -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, diff --git a/python/test/test_wizard_locks_by_default.py b/python/test/test_wizard_bundle_locking.py similarity index 89% rename from python/test/test_wizard_locks_by_default.py rename to python/test/test_wizard_bundle_locking.py index c5dc491e..eb444f6c 100644 --- a/python/test/test_wizard_locks_by_default.py +++ b/python/test/test_wizard_bundle_locking.py @@ -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. @@ -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(