The Garage SDKs, in the open

garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
This commit is contained in:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
+473
View File
@@ -0,0 +1,473 @@
# Platform setup
The bits of wiring that live in your app's runner folders, entitlements and
manifests rather than in Dart. None of it is something a package can do for
you, which is why it's all in one place here.
- [Redirect URIs](#redirect-uris)
- [Catching the callback on native](#catching-the-callback-on-native)
- [Token storage](#token-storage)
- [macOS network access](#macos-network-access)
- [garage_iap and Stripe](#garage_iap-and-stripe)
- [garage_ui's macOS plugin](#garage_uis-macos-plugin)
- [The pub bug with git + path deps](#the-pub-bug-with-git--path-deps)
## Redirect URIs
### Registering the client
Your app is a **public** PKCE client. `garage_auth` never sends a
`client_secret` — it cant, anything shipped in an app binary isnt a secret.
Create the client under your project in the hub (or over the API with
`"is_public": true`) and list every redirect URI the app will ever send.
What Garage lets you register:
| Shape | Example | For |
| --- | --- | --- |
| `https://` | `https://fieldnotes.example/auth/callback` | web |
| `http://` on loopback only | `http://localhost:8765/auth/callback` | local dev |
| custom scheme | `fieldnotes://auth/callback` | native + desktop |
The `redirect_uri` sent to `/authorize` has to match a registered one
character for character. There are no wildcards. The one thing that floats is
the **port on a loopback URL** — register `http://localhost:8765/auth/callback`
and `flutter run -d chrome` on whatever random port it picks will still match.
Scheme, host and path still have to be exact.
`garage_auth` sends the same resolved URI to `/authorize` and again in the
token exchange, so if one works the other will too.
### Web ignores your scheme and host
On web, `redirect_web.dart` throws away the scheme and host of the
`redirectUri` you passed and uses `window.location.origin` instead. Only the
**path** is kept. So with
```dart
GarageAuth(redirectUri: "https://fieldnotes.example/auth/callback", ...)
```
a build served from `https://beta.fieldnotes.example` sends
`https://beta.fieldnotes.example/auth/callback`. That's deliberate, the IdP
bounces back to the same deployment the user started on. The catch is that
**every origin you deploy to needs its own registered redirect URI** — prod,
staging, a preview domain, all of them. Localhost is covered by the loopback
port rule above.
How the path gets picked:
- A schemeless value (`/auth/callback`) or an `http(s)` URL — its path is used.
- A custom scheme URI — falls back to `/auth/callback`.
That fallback exists because of a real gotcha. In `garagepay://auth/callback`
the `auth` bit is the **host**, not part of the path, and the path is just
`/callback`. Taking the path off it used to produce
`https://host/callback`, which nobody had registered, and sign-in died with
`redirect_uri not registered`. So a custom-scheme URI on web always means
`/auth/callback`, whatever you wrote after the scheme.
If you want web to land somewhere other than `/auth/callback`, pass a web
shaped value when you're on web:
```dart
final auth = GarageAuth(
issuer: "https://hub.imbenji.net/auth-api",
clientId: "field-notes",
redirectUri: kIsWeb ? "/signed-in" : "fieldnotes://auth/callback",
);
```
and make sure that path is both a route in your app and registered on the
client, per origin.
### Native uses it verbatim
On iOS, Android, macOS, Windows and Linux (`redirect_io.dart`) the configured
URI is used exactly as given. `signIn()` opens the authorize URL in the
**external** browser via `url_launcher` (`LaunchMode.externalApplication`) and
returns straight away. Garage redirects the browser to your custom scheme, the
OS hands that to your app, and your app has to catch it and call
`completeSignIn(uri.queryParameters)`. Nothing in `garage_auth` listens for
the link itself.
## Catching the callback on native
Two jobs: tell the OS your app owns the scheme, and listen for the link in
Dart. [`app_links`](https://pub.dev/packages/app_links) does the listening on
every platform and its per-platform docs are the reference for the runner
changes — the snippets below are from those docs, check them against the
version you actually resolve.
The Dart side:
```dart
final appLinks = AppLinks(); // singleton, make it early so the cold-start link isnt missed
appLinks.uriLinkStream.listen((uri) async {
if (uri.scheme != "fieldnotes") return;
try {
await auth.completeSignIn(uri.queryParameters);
} catch (e, st) {
print("sign in callback failed: $e\n$st");
}
});
```
`uriLinkStream` delivers the initial link as well as later ones.
From Flutter 3.24 Flutter's own deep link handling has to be switched off or it
fights `app_links` for the link. That's the `FlutterDeepLinkingEnabled` /
`flutter_deeplinking_enabled` lines below.
### iOS
`ios/Runner/Info.plist`, inside the top `<dict>`:
```xml
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>fieldnotes</string>
<key>CFBundleURLSchemes</key>
<array>
<string>fieldnotes</string>
</array>
</dict>
</array>
<key>FlutterDeepLinkingEnabled</key>
<false/>
```
`app_links` 7 on iOS needs Flutter 3.38.1 or newer, and supports both the
app-delegate and the newer scene lifecycle.
### macOS
Same `CFBundleURLTypes` block, in `macos/Runner/Info.plist`:
```xml
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>fieldnotes</string>
<key>CFBundleURLSchemes</key>
<array>
<string>fieldnotes</string>
</array>
</dict>
</array>
```
### Android
`android/app/src/main/AndroidManifest.xml`, inside the `<activity>` for
`.MainActivity`:
```xml
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="fieldnotes" android:host="auth" />
</intent-filter>
```
Note the host. For `fieldnotes://auth/callback` the host is `auth` (same
host-vs-path thing as the web gotcha above). You can drop `android:host` and
match on the scheme alone, but keeping it cuts down on clashing with another
app that picked the same scheme.
Test it without going through sign-in:
```sh
adb shell am start -a android.intent.action.VIEW \
-d "fieldnotes://auth/callback?code=x\&state=y"
```
(`completeSignIn` will throw a state mismatch on that, which is fine, it proves
the link arrived.)
### Windows
Windows doesnt read a manifest for a plain win32 app, the scheme has to go in
the registry, and `app_links` wont do that for you. Two routes, both in the
`app_links` Windows doc:
- **Packaged with [`msix`](https://pub.dev/packages/msix)** — add
`protocol_activation: fieldnotes` under `msix_config` and the installer
registers (and on uninstall, removes) it. Only works for the packaged app,
not while debugging.
- **Unpackaged** — write `HKCU\Software\Classes\fieldnotes` yourself, with an
empty `URL Protocol` value and `shell\open\command` set to
`"<path to exe>" "%1"`. The doc has a `win32_registry` snippet for it.
You also want the link going to the instance thats allready running (the one
waiting on sign-in), not a fresh one. In `windows/runner/main.cpp`:
```cpp
#include "app_links/app_links_plugin_c_api.h"
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
_In_ wchar_t *command_line, _In_ int show_command) {
if (SendAppLinkToInstance()) {
return EXIT_SUCCESS;
}
// ...
```
### Linux
Two halves again. The scheme is registered by whatever installs the app — a
`.desktop` entry with `x-scheme-handler/fieldnotes` in its mime types (if you
build packages with `flutter_distributor`, that's `supported_mime_type` in
`make_config.yaml`). And `linux/my_application.cc` has to become a single
instance app that accepts the URL on the command line — the `app_links` Linux
doc has the exact patch (present the existing window in `activate`, return
`FALSE` from `local_command_line`, and swap `G_APPLICATION_NON_UNIQUE` for
`G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN`). Copy it
from there rather than from here, it touches three spots in the file.
## Token storage
The default `SecureTokenStore` is `flutter_secure_storage`. Worth knowing
before you debug anything: the store doesnt only hold tokens. `beginSignIn()`
writes the PKCE verifier and `state` into it, and `completeSignIn()` reads them
back. So a store that cant write, or one that forgets across the round trip,
breaks **sign-in**, not just "stay signed in". On web the tab reloads at the
callback, so a `MemoryTokenStore` there gives you a `State mismatch on OAuth
callback` every time.
### macOS: unsigned builds cant use the Keychain
On macOS `flutter_secure_storage` wants the `keychain-access-groups`
entitlement, in both `DebugProfile.entitlements` and `Release.entitlements`.
A useful value for it involves `$(AppIdentifierPrefix)` — your team ID — which
only exists when you sign with a real development certificate.
Ad-hoc signed (`CODE_SIGN_IDENTITY = "-"`, what you get with no team set), it
goes wrong either way:
- **with** the entitlement, the build fails, there's no team for it to resolve
against;
- **without** it, every Keychain call returns `-34018`
(`errSecMissingEntitlement`). Sign-in fails at the verifier stash and
`restore()` finds nothing.
Fixes, pick one:
1. Sign the app properly (a team, a development cert) and add the entitlement.
2. Pass your own `TokenStore` until you do:
```dart
final auth = GarageAuth(
issuer: ...,
clientId: ...,
redirectUri: ...,
tokenStore: MyPrefsTokenStore(), // anything that implements read/write/delete
);
```
`flutter_secure_storage`'s own README also documents a third way on 10 and
newer: `MacOsOptions(usesDataProtectionKeychain: false)` drops to the legacy
Keychain, which doesnt need the entitlement. `SecureTokenStore` takes a
`storage:` argument so you could hand it one configured like that. We havent
leaned on it ourselves, so treat that as their claim, not ours.
Their README has one more macOS catch: Keychain Sharing needs a provisioning
profile, and on a free Apple developer account Xcode embeds a machine specific
one, so the built app only launches on the Mac that built it.
### Web compiled to wasm
`flutter build web --wasm` fails if the graph resolves `flutter_secure_storage`
**9**. Its web half (`flutter_secure_storage_web` 1.x) is written against
`dart:html`, which dart2wasm doesnt have. And a custom `TokenStore` doesnt save
you — the generated web plugin registrant imports every web plugin in the
dependency graph whether your code touches it or not, so it gets compiled
anyway.
The packages allow `flutter_secure_storage: ">=9.2.2 <12.0.0"`. Make sure you
resolve onto **10 or 11**, whose web half (`flutter_secure_storage_web` 2.x)
doesnt use `dart:html`. If something else in your app is holding it on 9, pin
it:
```yaml
dependencies:
flutter_secure_storage: ^10.0.0 # or ^11.0.0
```
Knock-on effect on Android: `flutter_secure_storage` 10 raised its minimum to
**API 23**. If your `minSdk` is lower, bump it.
On web the storage is best effort either way — the README calls its WebCrypto
backed web implementation experimental.
## macOS network access
A macOS app from `flutter create` runs in the App Sandbox, and the sandbox
blocks outgoing connections untill you add the client entitlement. Every one
of these packages talks to Garage over HTTP, so without it the first discovery
fetch fails (usually as a `SocketException: Connection failed (Operation not
permitted)`).
Add it to **both** `macos/Runner/DebugProfile.entitlements` and
`macos/Runner/Release.entitlements`:
```xml
<key>com.apple.security.network.client</key>
<true/>
```
The debug profile file allready has `network.server` in it by default. That's
incoming connections, so the flutter tools can talk to the running app — it
does nothing for your requests. Dont remove it, and dont mistake it for the
one you need. Check both files, it's easy to have one build working and the
other not.
## garage_iap and Stripe
`PurchaseMode.sheet` uses `flutter_stripe`'s PaymentSheet. `garage_iap` pins
`flutter_stripe: ^11.1.0`, so these are the 11.x requirements from its README.
### Where the sheet is even tried
`sheet.dart` conditionally imports `sheet_io.dart` when `dart:io` exists and
`sheet_stub.dart` otherwise:
- **iOS, Android** — the real sheet.
- **macOS, Windows, Linux** — `sheet_io.dart` checks `Platform.isIOS ||
Platform.isAndroid` and returns `unsupported`.
- **Web** — the stub, always `unsupported`.
`unsupported` drops to the browser handoff. So does any **subscription**,
because the server answers the payment-intent call with `fallback: "handoff"`.
### The setup is not optional on iOS / Android
Heads up, this one catches people out: the handoff fallback only covers
platforms with **no** sheet. On iOS and Android the sheet is always attempted,
and if the platform setup below is missing, `initPaymentSheet` /
`presentPaymentSheet` fail. `sheet_io.dart` only swallows a
`FailureCode.Canceled` — anything else is logged and rethrown, and `purchase()`
throws. It doesnt fall back. If you cant do the setup, call
`purchase(..., mode: PurchaseMode.handoff)` explicitly.
**iOS** — iOS 13 or above. In `ios/Podfile`:
```ruby
platform :ios, '13.0'
```
and match `IPHONEOS_DEPLOYMENT_TARGET` in the Xcode build settings. If you
want card scanning, add an `NSCameraUsageDescription` to `Info.plist`.
**Android** — the Stripe Android SDK uses AppCompat UI and the support fragment
manager for the sheet, so:
1. `MainActivity.kt` extends `FlutterFragmentActivity`, not `FlutterActivity`:
```kotlin
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity()
```
2. The activity theme is a descendant of `Theme.AppCompat`. In
`android/app/src/main/res/values/styles.xml`, `LaunchTheme` becomes
`parent="Theme.AppCompat.Light.NoActionBar"` and in `values-night/styles.xml`
`parent="Theme.AppCompat.DayNight.NoActionBar"`. Stripe's example app sets
`NormalTheme` to `parent="Theme.MaterialComponents"`.
3. minSdk 21+ (but see the API 23 note above), Kotlin 1.8.0+, Android Gradle
plugin 8+.
4. Their ProGuard `-dontwarn` rules in `proguard-rules.pro`, and for 11.x
`android.enableR8.fullMode=false` in `gradle.properties`. Copy both from the
README of the exact version you resolved, the rule list has changed between
versions.
None of this hot reloads. Do a full rebuild after.
The handoff itself only needs `url_launcher`, which works everywhere without
setup (on macOS it still needs the network entitlement above, like
everything else).
## garage_ui's macOS plugin
`garage_ui` declares one native plugin, macOS only:
```yaml
flutter:
plugin:
platforms:
macos:
pluginClass: GarageUiPlugin
```
`GarageUiPlugin` registers two method channels:
- **`garage/eyedropper`** — `isAvailable` and `pick`, backed by
`NSColorSampler` (the system loupe, whole screen).
- **`garage/cursor_lock`** — `lock` / `unlock`, hides and pins the cursor while
you scrub a number field, and pushes raw `scrubDelta` calls back to Dart
while locked.
It's picked up by the generated plugin registrant, so theres nothing to add to
`MainFlutterWindow.swift`. The podspec targets macOS 10.15.
Everywhere else there's no native side, and the Dart side copes:
- **Eyedropper** — `eyedropper.dart` imports the web version when
`dart.library.html` exists (JS web builds) and uses the browser `EyeDropper`
API, which is Chromium only, feature checked. Everything else uses the native
version, which answers `false` for "available" off macOS without touching the
channel. Callers then fall back to sampling the app's own frame, which is
window only but works everywhere. A wasm build has no `dart.library.html`,
so it takes the native version: unavailable unless the browser reports
macOS, in which case the channel call fails, gets logged, and it's
unavailable anyway.
- **Cursor lock** — `CursorLock.isSupported` is `!kIsWeb && macOS`. Off macOS
`lock()` / `unlock()` do nothing and no deltas come back, so the scrub widget
drives itself off Flutter's normal pointer events.
If you see a `MissingPluginException` for either channel on macOS, the runner
hasnt been rebuilt since `garage_ui` was added — a hot restart doesnt register
new plugins, a full `flutter run` / build does.
## The pub bug with git + path deps
On some Dart versions pub loses the dependencies of a **git package that has
a path dependency of its own**. That's exactly our shape: `garage_entitlements`
and `garage_iap` are git deps (with `path:`) that themselves depend on
`../garage_auth` by path. When it hits, the extra deps those packages declare —
`pointycastle` is the one you'll notice — go missing from the resolved graph,
and the build dies with an error mentioning `package_graph.json` (something
like `dependencies for ... missing. Try running flutter pub get`, which doesnt
help).
We've seen it on Dart 3.12.0; 3.12.2 is fine. Upgrading is the real fix. If you
cant, declare the missing dep directly in your app so pub resolves it
regardless:
```yaml
dependencies:
pointycastle: ^4.0.0
```
Upstream: [dart-lang/pub#2447](https://github.com/dart-lang/pub/issues/2447)
(git package depending on a path package) and
[dart-lang/pub#4674](https://github.com/dart-lang/pub/issues/4674) (the
`package_graph.json` error).