# 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 ``: ```xml CFBundleURLTypes CFBundleURLName fieldnotes CFBundleURLSchemes fieldnotes FlutterDeepLinkingEnabled ``` `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 CFBundleURLTypes CFBundleURLName fieldnotes CFBundleURLSchemes fieldnotes ``` ### Android `android/app/src/main/AndroidManifest.xml`, inside the `` for `.MainActivity`: ```xml ``` 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 `"" "%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 com.apple.security.network.client ``` 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).