Files
Garage-SDKs/docs/platform-setup.md
T
ImBenjiandClaude Opus 5.5 b269201919 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
2026-09-23 18:49:21 +01:00

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

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 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:

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 — 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:

#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:

    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.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:

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:

    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:

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:

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).