A little bird told me. Turns screenshots into Apple Maps guides — the places people tell you about, kept somewhere you'll find them.
The bundle id is com.spencerfields.littlebird and the OCR method channel is
littlebird/ocr. Both are deliberate leftovers from the app's first name: the
App ID, provisioning profile and every shipped build are bound to that
identifier, and it is invisible to users. Renaming it would cost a new App ID,
a new profile and a new App Store record for no benefit.
This repository is public. It is public deliberately, so GitHub Actions minutes are free. Nothing sensitive may exist as a file here — a commit is a disclosure.
That is a narrower rule than it sounds. CI secrets are fine: repository secrets are encrypted, never printed in logs, and not exposed to pull requests from forks, so signing certificates and the App Store Connect key live there safely. The Maps Server API key does not belong here at all — it lives in the Cloudflare Worker, which is why
MapsServerResolvercalls an endpoint rather than signing a JWT in the app.Never commit:
.p8,.p12,.pem, provisioning profiles,.envfiles, or personal reel screenshots. All are gitignored, and the No credentials committed CI job fails the build if one slips through anyway.
Scaffold only — the pipeline is wired end to end, but place resolution is a stub until a Maps Server API key exists.
There is no Mac on this project. Flutter runs on Windows, so the list screens, the picker and the confirmation flow all hot-reload locally; only the iOS build needs a macOS CI runner. Native Swift would be the better choice with a Mac and is the worse one without.
Exactly one Swift file exists — ios/Runner/AppDelegate.swift, which bridges
Vision for OCR. It has no interface, so it never needs a simulator.
| Piece | State |
|---|---|
| Guide-link encoding | Verified. Tests assert byte-for-byte against links opened on a physical iPhone that populated real guides. |
| OCR | Measured at 25/25 on graded synthetic frames using Vision .accurate. Real reel typography is untested. |
| Chrome filtering / place picking | Heuristic. Tested against a realistic line set, but the "largest text wins" rule comes from frames we authored. |
| Resolution | Stubbed. StubResolver returns real, device-verified place ids so the rest of the app runs. |
| Share extension | Not built. Deliberately deferred — it is the riskiest config item and cannot be debugged without a device. |
Both were learned by getting them wrong:
- Every place must carry an Apple place ID (
I+ 16 hex digits). A guide payload built from coordinates renders perfectly onmaps.apple.comand opens with zero places in the Maps app. Browser testing gives a false pass. - Guides cannot be merged or appended to. A guide link always creates a new guide, even when the name matches one already saved, and there is no read access to what the user has. Treat Apple Maps as an output device, not as storage — and never build anything that claims to "sync".
One place therefore publishes as a place card, where Add to Guide can append to an existing guide. Several places can only become a new guide.
lib/src/guide_link.dart protobuf encoder, base64, chunking at 50
lib/src/ocr.dart method channel + chrome filtering
lib/src/resolver.dart PlaceResolver interface, stub and Maps Server stub
ios/Runner/AppDelegate.swift the Vision bridge, the only Swift here
flutter testEverything above the platform channel is pure Dart and runs on Windows. The app
itself needs an iOS device: OCR returns OcrUnavailable anywhere else.
Guides over three places need a one-time unlock. Non-consumable,
com.spencerfields.littlebird.unlimited, $4.99.
The client side is done: StoreKit wired, price read from the store rather than formatted locally, purchase and restore both failing closed, the non-consumable completed so it stops being replayed on launch, and a restore entry in the app bar so a returning customer never has to trip the paywall to find it.
The product itself must be created in a browser. The App Store Connect API
refuses it outright — POST /v1/inAppPurchases returns "The resource
'inAppPurchases' does not allow 'CREATE'. Allowed operation is:
GET_INSTANCE" — and that is the API declining the operation, not a permissions
problem. An Admin key gets the same answer. Same story as creating the app
record.
In App Store Connect → Wren → Monetisation → In-App Purchases → +:
| Field | Value |
|---|---|
| Type | Non-Consumable |
| Reference name | Wren Unlimited Guides |
| Product ID | com.spencerfields.littlebird.unlimited |
| Price | $4.99 (Apple's nearest price point) |
| Display name | Guides of any size |
| Description | Save as many places to a guide as you like, instead of three. One payment, kept for good. |
| Family Sharing | On — costs nothing and reduces support mail |
Also needed before it will sell: the Paid Applications Agreement must be active, and the IAP needs a review screenshot and review notes before it can be submitted alongside a build.
Until the product exists, queryProductDetails returns nothing, price() is
null and the sheet falls back to the hardcoded $4.99 string — so the paywall
still renders, but buy() will refuse. That is deliberate: the store failing is
never allowed to grant the unlock.
- Mint a Maps identifier and a
.p8key, confirm/v1/searchreturnsI+hex place ids, then implementMapsServerResolveragainst the Cloudflare Worker so the key never ships in the app. - Put ten real reel screenshots through Vision and see whether real typography behaves like the synthetic fixtures.
- Add the share extension, once the loop above is trusted.
- Turn on the TestFlight job in CI — without a Mac, that is the device-testing loop.
The project lives in OneDrive, which intermittently locks build/ and
ios/Flutter/ephemeral/ mid-build. If Flutter reports it "failed to delete a
directory", delete the folder and re-run. Excluding those two paths from
OneDrive sync avoids it.