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
17 KiB
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
- Catching the callback on native
- Token storage
- macOS network access
- garage_iap and Stripe
- garage_ui's macOS plugin
- 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
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 anhttp(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:
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 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:
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>:
<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:
<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:
<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:
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— addprotocol_activation: fieldnotesundermsix_configand the installer registers (and on uninstall, removes) it. Only works for the packaged app, not while debugging. - Unpackaged — write
HKCU\Software\Classes\fieldnotesyourself, with an emptyURL Protocolvalue andshell\open\commandset to"<path to exe>" "%1". The doc has awin32_registrysnippet 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:
#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 andrestore()finds nothing.
Fixes, pick one:
-
Sign the app properly (a team, a development cert) and add the entitlement.
-
Pass your own
TokenStoreuntil you do: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:
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:
<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.dartchecksPlatform.isIOS || Platform.isAndroidand returnsunsupported. - 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:
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:
-
MainActivity.ktextendsFlutterFragmentActivity, notFlutterActivity:import io.flutter.embedding.android.FlutterFragmentActivity class MainActivity : FlutterFragmentActivity() -
The activity theme is a descendant of
Theme.AppCompat. Inandroid/app/src/main/res/values/styles.xml,LaunchThemebecomesparent="Theme.AppCompat.Light.NoActionBar"and invalues-night/styles.xmlparent="Theme.AppCompat.DayNight.NoActionBar". Stripe's example app setsNormalThemetoparent="Theme.MaterialComponents". -
minSdk 21+ (but see the API 23 note above), Kotlin 1.8.0+, Android Gradle plugin 8+.
-
Their ProGuard
-dontwarnrules inproguard-rules.pro, and for 11.xandroid.enableR8.fullMode=falseingradle.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:
flutter:
plugin:
platforms:
macos:
pluginClass: GarageUiPlugin
GarageUiPlugin registers two method channels:
garage/eyedropper—isAvailableandpick, backed byNSColorSampler(the system loupe, whole screen).garage/cursor_lock—lock/unlock, hides and pins the cursor while you scrub a number field, and pushes rawscrubDeltacalls 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.dartimports the web version whendart.library.htmlexists (JS web builds) and uses the browserEyeDropperAPI, which is Chromium only, feature checked. Everything else uses the native version, which answersfalsefor "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 nodart.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.isSupportedis!kIsWeb && macOS. Off macOSlock()/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:
dependencies:
pointycastle: ^4.0.0
Upstream: dart-lang/pub#2447
(git package depending on a path package) and
dart-lang/pub#4674 (the
package_graph.json error).