From b2692019197e60d44ddd3ecab4917caf71ff45c3 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Wed, 23 Sep 2026 18:49:21 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7 --- .gitignore | 16 + LICENSE | 21 + README.md | 383 +++ docs/garage-ui-style-guide.md | 503 ++++ docs/local-development.md | 146 + docs/offline-licences.md | 345 +++ docs/platform-setup.md | 473 ++++ garage_auth/LICENSE | 21 + garage_auth/analysis_options.yaml | 6 + garage_auth/example/main.dart | 60 + garage_auth/lib/garage_auth.dart | 12 + garage_auth/lib/src/authed_client.dart | 74 + garage_auth/lib/src/garage_auth.dart | 305 +++ garage_auth/lib/src/oidc.dart | 83 + garage_auth/lib/src/platform/redirect.dart | 1 + garage_auth/lib/src/platform/redirect_io.dart | 23 + .../lib/src/platform/redirect_web.dart | 40 + garage_auth/lib/src/token_store.dart | 42 + garage_auth/pubspec.yaml | 28 + garage_entitlements/LICENSE | 21 + garage_entitlements/analysis_options.yaml | 7 + .../lib/garage_entitlements.dart | 21 + .../lib/src/garage_entitlements.dart | 391 +++ garage_entitlements/lib/src/jwks_verify.dart | 211 ++ garage_entitlements/lib/src/key_cache.dart | 96 + garage_entitlements/lib/src/models.dart | 174 ++ garage_entitlements/pubspec.yaml | 36 + .../test/jwks_verify_test.dart | 187 ++ garage_entitlements/test/keys.dart | 96 + garage_entitlements/test/refresh_test.dart | 282 ++ garage_iap/LICENSE | 21 + garage_iap/example/lib/main.dart | 142 + garage_iap/example/pubspec.yaml | 23 + garage_iap/lib/garage_iap.dart | 25 + garage_iap/lib/src/garage_iap.dart | 499 ++++ garage_iap/lib/src/jwks_verify.dart | 166 ++ garage_iap/lib/src/licence_cache.dart | 91 + garage_iap/lib/src/models.dart | 162 ++ garage_iap/lib/src/sheet/sheet.dart | 35 + garage_iap/lib/src/sheet/sheet_io.dart | 49 + garage_iap/lib/src/sheet/sheet_stub.dart | 7 + garage_iap/pubspec.yaml | 40 + garage_iap/test/jwks_verify_test.dart | 152 ++ garage_ui/LICENSE | 21 + garage_ui/lib/app.dart | 111 + garage_ui/lib/app_frame_capture.dart | 115 + garage_ui/lib/app_menu.dart | 235 ++ garage_ui/lib/app_menu_notifier.dart | 67 + garage_ui/lib/button.dart | 2414 +++++++++++++++++ garage_ui/lib/color_input.dart | 799 ++++++ garage_ui/lib/context_menu.dart | 116 + garage_ui/lib/date_input.dart | 1372 ++++++++++ garage_ui/lib/editor_chrome.dart | 308 +++ garage_ui/lib/extensions.dart | 145 + garage_ui/lib/field_error.dart | 92 + garage_ui/lib/garage_ui.dart | 52 + garage_ui/lib/input_mask.dart | 183 ++ garage_ui/lib/menu.dart | 1056 +++++++ garage_ui/lib/misc.dart | 257 ++ garage_ui/lib/navigation.dart | 600 ++++ garage_ui/lib/overlay.dart | 1262 +++++++++ garage_ui/lib/pane_overlay.dart | 89 + garage_ui/lib/panel.dart | 91 + garage_ui/lib/platform/cursor_lock.dart | 63 + garage_ui/lib/platform/eyedropper.dart | 23 + garage_ui/lib/platform/eyedropper_native.dart | 48 + garage_ui/lib/platform/eyedropper_web.dart | 50 + garage_ui/lib/popover_placement.dart | 160 ++ garage_ui/lib/properties.dart | 940 +++++++ garage_ui/lib/scroll_edge_fade.dart | 44 + garage_ui/lib/scrollbar.dart | 167 ++ garage_ui/lib/select.dart | 1371 ++++++++++ garage_ui/lib/selection_controls.dart | 533 ++++ garage_ui/lib/semantics_scope.dart | 71 + garage_ui/lib/settings_list.dart | 303 +++ garage_ui/lib/sheet.dart | 490 ++++ garage_ui/lib/surface.dart | 907 +++++++ garage_ui/lib/tab_view.dart | 156 ++ garage_ui/lib/text_field.dart | 1620 +++++++++++ garage_ui/lib/theme.dart | 6 + garage_ui/lib/theme/colour_scheme.dart | 562 ++++ garage_ui/lib/theme/garage_theme.dart | 90 + garage_ui/lib/theme/schemes.dart | 88 + garage_ui/lib/theme/support.dart | 169 ++ garage_ui/lib/theme/theme_data.dart | 573 ++++ garage_ui/lib/theme/typography.dart | 105 + garage_ui/lib/toast.dart | 471 ++++ garage_ui/macos/Classes/CursorLock.swift | 64 + garage_ui/macos/Classes/GarageUiPlugin.swift | 58 + .../macos/Classes/ScreenColorSampler.swift | 58 + garage_ui/macos/garage_ui.podspec | 27 + garage_ui/pubspec.yaml | 24 + garage_ui/test/button_centring_test.dart | 185 ++ garage_ui/test/button_group_fill_test.dart | 105 + garage_ui/test/chrome_bar_density_test.dart | 80 + garage_ui/test/density_gap_scale_test.dart | 43 + garage_ui/test/field_error_test.dart | 131 + garage_ui/test/field_feature_height_test.dart | 112 + garage_ui/test/gap_step_test.dart | 63 + garage_ui/test/geometry_baseline_test.dart | 98 + garage_ui/test/icon_tier_colour_test.dart | 121 + garage_ui/test/input_mask_test.dart | 163 ++ garage_ui/test/nested_button_group_test.dart | 149 + garage_ui/test/properties_reorder_test.dart | 167 ++ .../test/properties_section_actions_test.dart | 131 + .../test/properties_section_copy_test.dart | 67 + garage_ui/test/property_row_copy_test.dart | 187 ++ .../test/property_slot_variant_test.dart | 111 + garage_ui/test/scheme_derive_test.dart | 182 ++ garage_ui/test/scrub_fallback_test.dart | 95 + garage_ui/test/select_popup_spacing_test.dart | 141 + garage_ui/test/select_popup_width_test.dart | 137 + garage_ui/test/settings_list_test.dart | 154 ++ garage_ui/test/support/test_scheme.dart | 12 + garage_ui/test/text_consistency_test.dart | 69 + .../test/text_field_semantics_focus_test.dart | 56 + garage_ui/test/text_ladder_test.dart | 43 + 117 files changed, 26944 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/garage-ui-style-guide.md create mode 100644 docs/local-development.md create mode 100644 docs/offline-licences.md create mode 100644 docs/platform-setup.md create mode 100644 garage_auth/LICENSE create mode 100644 garage_auth/analysis_options.yaml create mode 100644 garage_auth/example/main.dart create mode 100644 garage_auth/lib/garage_auth.dart create mode 100644 garage_auth/lib/src/authed_client.dart create mode 100644 garage_auth/lib/src/garage_auth.dart create mode 100644 garage_auth/lib/src/oidc.dart create mode 100644 garage_auth/lib/src/platform/redirect.dart create mode 100644 garage_auth/lib/src/platform/redirect_io.dart create mode 100644 garage_auth/lib/src/platform/redirect_web.dart create mode 100644 garage_auth/lib/src/token_store.dart create mode 100644 garage_auth/pubspec.yaml create mode 100644 garage_entitlements/LICENSE create mode 100644 garage_entitlements/analysis_options.yaml create mode 100644 garage_entitlements/lib/garage_entitlements.dart create mode 100644 garage_entitlements/lib/src/garage_entitlements.dart create mode 100644 garage_entitlements/lib/src/jwks_verify.dart create mode 100644 garage_entitlements/lib/src/key_cache.dart create mode 100644 garage_entitlements/lib/src/models.dart create mode 100644 garage_entitlements/pubspec.yaml create mode 100644 garage_entitlements/test/jwks_verify_test.dart create mode 100644 garage_entitlements/test/keys.dart create mode 100644 garage_entitlements/test/refresh_test.dart create mode 100644 garage_iap/LICENSE create mode 100644 garage_iap/example/lib/main.dart create mode 100644 garage_iap/example/pubspec.yaml create mode 100644 garage_iap/lib/garage_iap.dart create mode 100644 garage_iap/lib/src/garage_iap.dart create mode 100644 garage_iap/lib/src/jwks_verify.dart create mode 100644 garage_iap/lib/src/licence_cache.dart create mode 100644 garage_iap/lib/src/models.dart create mode 100644 garage_iap/lib/src/sheet/sheet.dart create mode 100644 garage_iap/lib/src/sheet/sheet_io.dart create mode 100644 garage_iap/lib/src/sheet/sheet_stub.dart create mode 100644 garage_iap/pubspec.yaml create mode 100644 garage_iap/test/jwks_verify_test.dart create mode 100644 garage_ui/LICENSE create mode 100644 garage_ui/lib/app.dart create mode 100644 garage_ui/lib/app_frame_capture.dart create mode 100644 garage_ui/lib/app_menu.dart create mode 100644 garage_ui/lib/app_menu_notifier.dart create mode 100644 garage_ui/lib/button.dart create mode 100644 garage_ui/lib/color_input.dart create mode 100644 garage_ui/lib/context_menu.dart create mode 100644 garage_ui/lib/date_input.dart create mode 100644 garage_ui/lib/editor_chrome.dart create mode 100644 garage_ui/lib/extensions.dart create mode 100644 garage_ui/lib/field_error.dart create mode 100644 garage_ui/lib/garage_ui.dart create mode 100644 garage_ui/lib/input_mask.dart create mode 100644 garage_ui/lib/menu.dart create mode 100644 garage_ui/lib/misc.dart create mode 100644 garage_ui/lib/navigation.dart create mode 100644 garage_ui/lib/overlay.dart create mode 100644 garage_ui/lib/pane_overlay.dart create mode 100644 garage_ui/lib/panel.dart create mode 100644 garage_ui/lib/platform/cursor_lock.dart create mode 100644 garage_ui/lib/platform/eyedropper.dart create mode 100644 garage_ui/lib/platform/eyedropper_native.dart create mode 100644 garage_ui/lib/platform/eyedropper_web.dart create mode 100644 garage_ui/lib/popover_placement.dart create mode 100644 garage_ui/lib/properties.dart create mode 100644 garage_ui/lib/scroll_edge_fade.dart create mode 100644 garage_ui/lib/scrollbar.dart create mode 100644 garage_ui/lib/select.dart create mode 100644 garage_ui/lib/selection_controls.dart create mode 100644 garage_ui/lib/semantics_scope.dart create mode 100644 garage_ui/lib/settings_list.dart create mode 100644 garage_ui/lib/sheet.dart create mode 100644 garage_ui/lib/surface.dart create mode 100644 garage_ui/lib/tab_view.dart create mode 100644 garage_ui/lib/text_field.dart create mode 100644 garage_ui/lib/theme.dart create mode 100644 garage_ui/lib/theme/colour_scheme.dart create mode 100644 garage_ui/lib/theme/garage_theme.dart create mode 100644 garage_ui/lib/theme/schemes.dart create mode 100644 garage_ui/lib/theme/support.dart create mode 100644 garage_ui/lib/theme/theme_data.dart create mode 100644 garage_ui/lib/theme/typography.dart create mode 100644 garage_ui/lib/toast.dart create mode 100644 garage_ui/macos/Classes/CursorLock.swift create mode 100644 garage_ui/macos/Classes/GarageUiPlugin.swift create mode 100644 garage_ui/macos/Classes/ScreenColorSampler.swift create mode 100644 garage_ui/macos/garage_ui.podspec create mode 100644 garage_ui/pubspec.yaml create mode 100644 garage_ui/test/button_centring_test.dart create mode 100644 garage_ui/test/button_group_fill_test.dart create mode 100644 garage_ui/test/chrome_bar_density_test.dart create mode 100644 garage_ui/test/density_gap_scale_test.dart create mode 100644 garage_ui/test/field_error_test.dart create mode 100644 garage_ui/test/field_feature_height_test.dart create mode 100644 garage_ui/test/gap_step_test.dart create mode 100644 garage_ui/test/geometry_baseline_test.dart create mode 100644 garage_ui/test/icon_tier_colour_test.dart create mode 100644 garage_ui/test/input_mask_test.dart create mode 100644 garage_ui/test/nested_button_group_test.dart create mode 100644 garage_ui/test/properties_reorder_test.dart create mode 100644 garage_ui/test/properties_section_actions_test.dart create mode 100644 garage_ui/test/properties_section_copy_test.dart create mode 100644 garage_ui/test/property_row_copy_test.dart create mode 100644 garage_ui/test/property_slot_variant_test.dart create mode 100644 garage_ui/test/scheme_derive_test.dart create mode 100644 garage_ui/test/scrub_fallback_test.dart create mode 100644 garage_ui/test/select_popup_spacing_test.dart create mode 100644 garage_ui/test/select_popup_width_test.dart create mode 100644 garage_ui/test/settings_list_test.dart create mode 100644 garage_ui/test/support/test_scheme.dart create mode 100644 garage_ui/test/text_consistency_test.dart create mode 100644 garage_ui/test/text_field_semantics_focus_test.dart create mode 100644 garage_ui/test/text_ladder_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9690e3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.DS_Store +.idea/ +.vscode/ + +# dart / flutter +.dart_tool/ +.packages +.flutter-plugins +.flutter-plugins-dependencies +build/ +**/pubspec.lock +pubspec_overrides.yaml + +# plugin ephemeral stuff +**/Flutter/ephemeral/ +**/.symlinks/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..72fab54 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 IMBENJI.NET LTD + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b89322 --- /dev/null +++ b/README.md @@ -0,0 +1,383 @@ +# Garage SDKs + +Flutter packages for building apps against [Garage](https://hub.imbenji.net) — +sign in, offline licence checks, in-app purchasing, and the design system every +Garage app is drawn with. + +| Package | What it does | +| --- | --- | +| [`garage_auth`](garage_auth) | **Sign in with Garage.** OIDC Authorization Code + PKCE against the hub, token storage with refresh, and a shared authed HTTP client. Everything else builds on it. | +| [`garage_entitlements`](garage_entitlements) | **Offline licence keys.** One signed key per entitlement, verified against the store's published JWKS, so `has("pro")` works with no network. | +| [`garage_iap`](garage_iap) | **In-app purchasing** for the app-store storefront — products, an embedded Stripe sheet or browser handoff, and an offline-verifiable licence. The storefront is parked; new apps want `garage_entitlements`. | +| [`garage_ui`](garage_ui) | **The design system.** Compact, desktop-first widgets and theme — shadcn-shaped API, Blender-shaped rendering. No Material. | + +None of the packages hold secrets. `garage_auth`, `garage_entitlements` and +`garage_iap` only ever call public endpoints, with the signed-in user's own +bearer, and the licence packages only hold the *public* half of the signing key +— they can check a key, they can never mint one. + +Everything here is MIT licensed, see [LICENSE](LICENSE). + +- [Install](#install) +- [Hosts](#hosts) +- [garage_auth](#garage_auth) +- [garage_entitlements](#garage_entitlements) +- [garage_iap](#garage_iap) +- [garage_ui](#garage_ui) +- [More docs](#more-docs) + + +## Install + +The packages arent on pub.dev. Git-depend on this repo, pick the package with +`path:`, and pin a tag: + +```yaml +dependencies: + garage_auth: + git: + url: https://git.imbenji.dev/IMBENJI.NET/Garage-SDKs.git + path: garage_auth + ref: v0.1.0 + garage_entitlements: + git: + url: https://git.imbenji.dev/IMBENJI.NET/Garage-SDKs.git + path: garage_entitlements + ref: v0.1.0 +``` + +Pin a tag, dont float `main`. `garage_entitlements` decides who gets the paid +features, and "it changed under us" is not a fun thing to debug. + +`garage_entitlements` and `garage_iap` layer on `garage_auth`. You get it +transitively, but declare it too — you'll be constructing a `GarageAuth` +yourself anyway, and keep all of them on the same `ref`. + +**Working on the SDKs at the same time as an app?** Point the app at a local +checkout with a gitignored `pubspec_overrides.yaml`, so edits hot reload with +no push or ref bump. See [docs/local-development.md](docs/local-development.md). + + +## Hosts + +Two of them, and mixing them up is the usual way to lose an afternoon: + +``` +https://hub.imbenji.net/auth-api identity — the OIDC issuer, for garage_auth +https://pay.imbenji.net/api commerce — products, entitlements, licence keys +https://pay.imbenji.net the hosted checkout buyers land on +``` + + +## garage_auth + +```dart +import "package:garage_auth/garage_auth.dart"; + +final auth = GarageAuth( + issuer: "https://hub.imbenji.net/auth-api", + clientId: "my-app", // your own public PKCE client + redirectUri: "myapp://auth/callback", +); + +await auth.restore(); // pick up an existing session on boot + +await auth.signIn(); // kicks off OIDC PKCE +final me = await auth.profile(); // userinfo claims + +final res = await auth.client.get( // authed http — bearer added, refresh on 401 + Uri.parse("https://pay.imbenji.net/api/v1/entitlements"), +); + +await auth.signOut(); + +auth.isSignedIn; // bool +auth.accessToken; // String? — the opaque bearer +``` + +`GarageAuth` is a `ChangeNotifier`, so hand it to `provider` or a +`ListenableBuilder` and the UI rebuilds on sign in / out. Share **one** instance +across the app — the other packages take it and reuse its session and client. + +Scopes default to `openid profile email`; pass `scopes:` for more. + +### Sign-in has two halves + +OIDC needs a round trip to the identity provider, so it's split: + +1. **`signIn()`** (same as `beginSignIn()`) builds the PKCE challenge, stashes + the verifier + state, and sends the user to the authorize endpoint. + - **Web:** the tab navigates away. The future never really "returns" — the + app reloads at the redirect path. + - **Native / desktop:** the system browser opens and the app keeps running. +2. **`completeSignIn(params)`** finishes the token exchange. Feed it the query + params off the inbound callback URL. A bad `state`, a missing code or an + `error` param throws an `AuthError`. + +Wiring the callback on web, with `go_router`: + +```dart +GoRoute( + path: "/auth/callback", + builder: (context, state) { + final auth = context.read(); + auth.completeSignIn(state.uri.queryParameters).then((_) { + if (context.mounted) context.go("/"); + }); + return const Center(child: CircularProgressIndicator()); + }, +); +``` + +On native, catch the inbound deep link (`app_links` or similar) and hand it over: + +```dart +final uri = Uri.parse(incomingDeepLink); +await auth.completeSignIn(uri.queryParameters); +``` + +A custom-scheme redirect like `myapp://auth/callback` has to be registered in +each platform's runner — that's the app's job, a package cant do it for you. See +[docs/platform-setup.md](docs/platform-setup.md). + +### Tokens + +Tokens are **opaque**, validated server side by introspection. The authed +`client` refreshes once on a `401` and replays the request; if the refresh +fails the session is dropped so your UI can ask the user to sign in again. +Only replayable (non-streamed) requests get the refresh. + +Storage defaults to `flutter_secure_storage` (`SecureTokenStore`) — Keychain / +Keystore on mobile, libsecret / DPAPI on desktop, best effort on web. Swap it +through the `TokenStore` seam: + +```dart +final auth = GarageAuth( + issuer: ..., + clientId: ..., + redirectUri: ..., + tokenStore: MemoryTokenStore(), // tests, or your own hive/prefs store +); +``` + +Keys are namespaced by `clientId`, so two instances in one app wont collide. + +The default store has two sharp edges — **unsigned macOS builds** and **web +compiled to wasm**. Both are in [docs/platform-setup.md](docs/platform-setup.md). + + +## garage_entitlements + +Give it a signed-in `GarageAuth`, a project slug and the commerce base url. It +fetches a key per entitlement, verifies each one, and answers `has(sku)` with no +network at all. + +```dart +import "package:garage_entitlements/garage_entitlements.dart"; + +final ent = GarageEntitlements( + auth: auth, // a signed-in GarageAuth + projectSlug: "field-notes", + apiBaseUrl: "https://pay.imbenji.net/api", +); + +await ent.cached(); // boot: verifies whats on disk +await ent.refresh(); // whenever you have a connection + +if (ent.has("pro-annual")) unlockTheThing(); +``` + +It's a `ChangeNotifier`, so a `ListenableBuilder` redraws your gates when the set +moves. Call `clear()` on sign-out. + +`cached()` needs the user's `sub` to check keys against, and it gets that from +userinfo — one network call per instance, then it's remembered. On a cold start +with no connection it cant resolve it, and no keys load (the ones on disk are +kept for next time). + +### One key per entitlement + +Money bought one thing, so the key unlocks that one thing. A single project-wide +licence would be a wallet — hand it to a lock that cares about one feature and +it learns everything the user owns. Each key is a compact RS256 JWT whose `aud` +is `"/"`, which is what lets a lock verify one knowing only its +own sku and the public key. + +### The two clocks + +```dart +final key = ent.key("pro-annual")!; +key.expiresAt; // the KEY's exp — this IS the access decision +key.entitlementExpiresAt; // when the subscription runs out, or null +``` + +The server clamps a key's `exp` to the entitlement's own expiry (and the grace +window on a bouncing renewal) before signing, so a key can never outlive the +thing it unlocks. Checking `exp` alone is correct. `entitlementExpiresAt` is how +a UI says "renews on the 3rd" — never gate on it. + +### Refresh replaces, it doesnt merge + +The one behaviour worth knowing. `refresh()` verifies **every** key in the +response before writing anything, then **replaces** the whole cached set for the +project. Anything not in the response is gone. + +A cancelled subscription simply stops coming back. If refresh merged, its key +would sit there working untill its own `exp`. A response that can't be fully +verified leaves the previous set exactly as it was — there's no half-applied +refresh. + +### Key lifetime + +The server decides, from the product's `licence_ttl_seconds`, then the +project's, then its own default. You can ask for **less** with +`refresh(ttl: ...)`; asking for more is quietly clamped. Shorter means the app +has to come online more often. Longer means a refunded purchase stays unlocked +that long, because nothing revokes a key before its `exp`. + +### Sandbox + +A sandbox key never satisfies a live check. While the project is in sandbox, +construct with `mode: "sandbox"`. + +### The rest + +```dart +await ent.entitlements(); // the ledger, straight from the server +await ent.productBySku("pro"); // name + price, for a purchase screen +await ent.portalUrl(productId, returnUrl: "myapp://pay/done"); +``` + +`entitlements()` is the truth, but it's a network call and says nothing offline +— use it for a purchase screen ("you allready own this"), not a feature gate. + +`portalUrl()` mints a one-shot handoff code so the buyer doesnt sign in twice, +and gives you back the checkout `Uri`. Launching it is yours — there's no +`url_launcher` or Stripe dependency in this package, on purpose, so it builds +clean on desktop. The `returnUrl` must be registered on the project or checkout +refuses it. + +What it isn't: a purchase SDK, a replacement for the ledger, or a revocation +system. How verification works, check by check, and how the cache is laid out: +[docs/offline-licences.md](docs/offline-licences.md). + + +## garage_iap + +> Built for the app-store storefront, which is parked. It still works and the +> storefront still uses it, but a new app selling through Garage wants +> `garage_entitlements` plus the hosted checkout. + +```dart +import "package:garage_iap/garage_iap.dart"; + +final iap = GarageIap( + auth: auth, + appSlug: "my-app", + apiBaseUrl: "https://store.imbenji.net/api", +); + +final products = await iap.products(); // List +final ents = await iap.entitlements(); // List + +await iap.purchase(products.first, mode: PurchaseMode.sheet); // or .handoff + +final licence = await iap.licence(); // cached, offline-capable +final pro = iap.has("pro"); +``` + +### Purchase modes + +- **`PurchaseMode.handoff`** — creates a checkout, opens it in the system + browser, then polls entitlements until the product is granted. The webhook is + the source of truth; the redirect is just a nudge. Works everywhere. +- **`PurchaseMode.sheet`** — the embedded `flutter_stripe` PaymentSheet for + one-time products. Subscriptions, and platforms with no native sheet (web, + linux, desktop), fall back to the handoff automatically. + +Either way it polls with a backoff afterwards. A grant that hasnt landed by +`pollTimeout` returns `PurchaseOutcome.pending` — not an error, it may still +arrive. + +The sheet needs `flutter_stripe`'s own platform setup (iOS 13+, an Android +`FlutterFragmentActivity`, …). On iOS and Android that setup isnt optional — +without it `purchase()` throws rather than falling back, so use +`PurchaseMode.handoff` if you cant do it. Web and desktop fall back on their own. + +### Offline licence + +`licence()` fetches one licence JWT covering the app's products **and** the JWKS +in the same trip, and caches both (`gi.licence.`). Offline it checks the +RS256 signature against the cached JWKS by `kid`, `exp` against the device +clock, and that `app` and `sub` match. No cached licence, or past `exp` while +offline, means not entitled. `cachedLicence()` is the no-network boot path; +`licence(forceRefresh: true)` forces a fetch. + +Seams: `LicenceCache` (`MemoryLicenceCache` for tests), `merchantName` for the +sheet, `pollTimeout`. + + +## garage_ui + +The widgets and theme every Garage app is built with. + +```dart +import "package:garage_ui/garage_ui.dart"; + +final theme = ThemeData( // garage_ui's ThemeData, not material's + colorScheme: ColourScheme.derive( + brightness: Brightness.dark, + background: const Color(0xFF18181B), + foreground: const Color(0xFFFAFAFA), + primary: const Color(0xFF3B82F6), + ), + density: const Density.compact(), // or .normal() / .product() +); + +GarageApp.router(routerConfig: router, theme: theme); +``` + +Inside, everything reads the theme: + +```dart +final theme = GarageTheme.of(context); +final cs = theme.colorScheme; // ColourScheme — every colour +theme.density; // Density — every size +theme.typography; +``` + +Two influences, and they arent the same kind: + +- **shadcn — the API.** Variant names (`.primary` / `.secondary` / `.outline` / + `.ghost` / `.destructive`), the `ColourScheme` slot names, the dot-constructor + shape. That's why a shadcn snippet usually *compiles*. +- **Blender — the rendering.** Flat chrome, bordered panels, dense controls, no + elevation. That's why the same snippet doesnt *look* like shadcn once it runs. + +There's no `package:flutter/material.dart` anywhere in it. No `Scaffold`, no +`ThemeData.dark()`, no literal `Color(0x…)` outside the theme files — if your +widget reaches for one, you've stepped outside the system. + +The API is in the source (every file in `lib/` is short and commented). Which +widget, which variant, which colour token in which situation is the other half, +and that's [docs/garage-ui-style-guide.md](docs/garage-ui-style-guide.md). + +The package also ships a small macOS plugin (`GarageUiPlugin`) for the +eyedropper and cursor lock. + + +## More docs + +- [docs/local-development.md](docs/local-development.md) — editing the SDKs + alongside an app, with hot reload +- [docs/platform-setup.md](docs/platform-setup.md) — redirect URIs, macOS + Keychain, wasm, Stripe +- [docs/offline-licences.md](docs/offline-licences.md) — how a licence key is + verified and cached +- [docs/garage-ui-style-guide.md](docs/garage-ui-style-guide.md) — using + garage_ui so it actually looks like a Garage app + +The HTTP API these packages call is documented on its own, so you can drop to it +whenever you need to — the SDKs are a convenience, not a requirement. Selling +in particular is three plain HTTP calls and a redirect; the one part you +shouldnt hand-roll is verifying a licence key. diff --git a/docs/garage-ui-style-guide.md b/docs/garage-ui-style-guide.md new file mode 100644 index 0000000..1bd4c74 --- /dev/null +++ b/docs/garage-ui-style-guide.md @@ -0,0 +1,503 @@ +# Garage UI style guide + +This is not the widget API reference (read the source for that — every file in +`lib/` is short and commented). This is the *other* half: which widget, which +variant, which colour token, in which situation. Getting a Garage app to use +`Button` and `Panel` is easy. Getting it to actually look like a Garage app — +right variant for the right emphasis, right token for the right surface, right +gap for the right kind of space — is the part that doesn't fall out of the +API. That's what this document is for. + +It's written from **observed usage** in Arcs & Angles (metro_map_maker) and +Music Maker, the two real apps built on this package. Every rule below is +backed by a real call site, not a guess at what "should" be idiomatic. Where +a pattern only has 1-2 examples, that's said explicitly — treat it as a lead, +not a law. + +## Where this comes from + +Two influences, and they are not the same kind of influence — conflating them +is the usual misreading: + +- **shadcn (via `shadcn_flutter`) — the API surface.** Variant names + (`.primary` / `.secondary` / `.outline` / `.ghost` / `.destructive`), the + `ColourScheme` slot vocabulary, the dot-constructor shape. This is why a + shadcn snippet usually *compiles*. +- **Blender — the rendering.** Flat chrome, bordered panels, dense controls, + no elevation, a properties pane with a hard split down it. This is why the + same snippet doesn't *look* like shadcn once it runs. + +So: **not a pixel-for-pixel restyle of shadcn.** The names carried over; the +geometry, the density and the entire chrome layer did not. `shadcn_flutter` +was dropped as a dependency once the port finished — nothing here defers to +it, and where the two disagree on how something should look, this package +wins. Expect ported code to compile and then need its spacing and emphasis +re-picked against the tables below. + +## Mental model + +A Garage app has three layers, outside-in: + +1. **Chrome** — the app's own furniture: headers, footers, the menu bar, the + shell that docks everything else. Flat, dark, no elevation. Reads as part + of the window, not as content sitting on the window. +2. **Panels** — bordered, rounded, elevated-feeling regions that hold actual + tool content (explorer trees, property inspectors, docked windows). This + is where most of the UI actually lives. +3. **Controls** — buttons, fields, selects, menus. Live inside panels or + chrome, never bare against the app background. + +Nothing in this system uses Flutter's Material widgets. There is no +`ThemeData.dark()`, no `Colors.black`, no `Scaffold`. Every colour comes from +`ColourScheme`, every size comes from `Density`, every font comes from +`Typography`. If a widget you're building reaches for `package:flutter/material.dart` +or a literal `Color(0x...)` outside `theme/*.dart`, that's the tell you've +stepped outside the system. + +## Getting the theme + +```dart +final theme = GarageTheme.of(context); +final cs = theme.colorScheme; +``` + +Everything hangs off `theme`: `theme.colorScheme`, `theme.density`, +`theme.typography`, `theme.iconTheme`, `theme.radiusMd` / `.borderRadiusMd` +(and `Sm`/`Lg`/`Xl`/`Xxl`), `theme.panelRadius`, `theme.panelGap`. + +One exception: an app's *unaccented* scheme — the one nobody's accent-colour +override has touched — is read through the app's settings provider, not +`GarageTheme.of(context)`. In Arcs & Angles that's +`context.watch().anaScheme`. Reach for this specifically +when you need `chrome` on something that must stay neutral even if the user +picked a wild accent colour (this is rare — most code never needs to do this, +because `ChromeBar`/`Panel`/`GarageShell` already read `chrome`/`panel` +internally). See "Colour tokens" below for why `chrome` gets this treatment. + +## Colour tokens + +`ColourScheme` (in `theme/colour_scheme.dart`) is one flat list of named +colours — no light/dark split, no derived roles computed at paint time. Every +field is authored by hand per scheme (Arcs & Angles ships eleven: zinc, +crimson, slate, forest, stone, teal, indigo, amber, carbon, fuchsia, plus each +one's light twin). When you add a new UI surface, you're choosing which of +these *existing* tokens it belongs to — you're not inventing a new colour. + +### Base semantic slots (shadcn-shaped, still the backbone) + +| Token | What it's for | +|---|---| +| `background` / `foreground` | The app's base surface and default text colour. | +| `card` / `cardForeground` | `Card`/`SurfaceCard` fill and the text colour merged inside them. | +| `popover` / `popoverForeground` / `popoverBorder` | Dropdowns, select popups, context menus — anything that floats over content in an `OverlayPortal`. | +| `primary` / `primaryHovered` / `primaryForeground` | The accent. Active/engaged control state, the one CTA in a dialog. Swappable per-user via `withAccent()`. | +| `secondary` / `secondaryHovered` / `secondaryForeground` | Neutral filled control — a resting toggle, an attached-to-a-field icon button. Not an accent colour, just "has a fill." | +| `muted` / `mutedForeground` | De-emphasised text/backgrounds — labels, hints, disabled-adjacent copy. `.muted()` text extension reads `mutedForeground`. | +| `destructive` | Delete/discard/record actions. Used sparingly — 3 call sites total in Arcs & Angles. | +| `border` | Generic 1px outline — text field default border colour, general dividing lines. | +| `divider` | A line *between* things in a layout (menu separators, section dividers) — conceptually different from `border` even though schemes often set them equal. | +| `ring` | Keyboard-focus outline. Not the canvas selection ring — see `canvasSelectionRing`. | +| `chart1`-`chart5` | Reserved for data visualisation, unused by the UI kit itself. | + +### App-chrome slots (the part shadcn never had) + +| Token | What it's for | +|---|---| +| `chrome` | Header/footer/menu-bar background. Deliberately nudged off `background` so it reads as *app furniture*, not content. Read via the unaccented scheme (see above) — chrome shouldn't shift when the user picks an accent. `ChromeBar` and `GarageShell` apply this for you; you'd only reach for it by hand building a header from scratch (see `blender_header.dart`, `scene_stats_hud.dart`). | +| `panel` / `panelBorder` / `panelBorderHighlighted` | `Panel`'s fill and border — resting vs. "this is the active/hovered one" (see `Panel(active: ...)`). This is the surface docked tool content lives in. | +| `input` / `inputBackground` / `inputBackgroundHovered` / `inputBackgroundFocused` / `inputBorder` | Text field fill (three interaction states) and border. | +| `explorerRowEven` / `explorerRowOdd` / `explorerRowHovered` / `explorerRowText` | Zebra-striped tree/list rows (explorer panel, layer lists). | +| `menuItemText` / `menuItemHovered` | Dropdown/menu row text and hover fill. | +| `popoverItemHovered` | Hover fill for popover rows that aren't menu items (kept distinct from `menuItemHovered` while the two are still being evaluated — may merge later). | +| `tooltipBackground` / `tooltipBorder` | Tooltips get their own pair rather than reusing `popover*` — they sit on top of *everything* and want more contrast than a panel-level surface. | +| `propertiesSectionBackground` / `propertiesSectionBorder` / `propertiesSectionLabel` | The boxed sub-sections inside an object-properties panel. | +| `canvasBackdrop` / `canvasPaper` / `canvasGridMinor` / `canvasGridMajor` / `canvasGridMajorDot` / `canvasBoundary` | Authored canvas colours — not derived at paint time, these are picked by hand per scheme like everything else. App-specific (Arcs & Angles' map canvas); a non-canvas app can mostly ignore this group. | +| `canvasAlignmentGuide` | Smart-guide lines while dragging/resizing. Deliberately its own slot, not `ring` — `ring` is keyboard focus, a different job. | +| `canvasSelectionRing` | Selection outline/handles. Tracks `primary` for most schemes but exists as its own slot so an achromatic scheme (carbon: near-black/near-white primary) can still give selection actual hue. If you add `withAccent()` support anywhere, remember `canvasSelectionRing` needs the same brightened-for-visibility treatment `_brightenForSelectionRing` gives it, or an accent override leaves the selection ring the one thing on screen still showing the old colour. | + +### Authoring a new scheme + +Look at `settings_state.dart` in the host app, not the package — that's where +the actual eleven-scheme palette lives (the package only defines the *type* +and the neutral fallback used in tests/demos). Each scheme is grouped under +`// ── Accounted for ──` vs `// ── not reviewed yet ──` comments — that's a +live audit trail, not decoration; keep using it as new tokens get added so +it's visible which colours were deliberately chosen vs. still riding an old +default. + +## Density & spacing + +`Density` (`theme/theme_data.dart`) is the **only** place pixel heights get +hand-set. Everything else — icon size, padding, line-box height — is a +derived getter. The class doc lays out the chain in full; the short version: + +- You set `controlHeight` (23 compact / 27 normal), `fontSize` (10, both + densities), `lineHeight` (1.1, both densities). +- `lineBox = (fontSize * lineHeight).roundToDouble()` falls out of those. +- `iconSize = lineBox` — a control icon always matches the text beside it. +- `controlPaddingY = (controlHeight - lineBox) / 2` — padding is *derived + from* the height target, never the other way round. + +**Never hand-type a control height or a vertical padding.** If a number needs +tuning, it belongs in `Density`, not at the call site — that's exactly the +mistake the class doc says produced four different control heights and a +stray `Transform.translate` before this system existed. + +Two densities are in active use: `ButtonDensity.compact` (54 call sites) is +the default for tool chrome — toolbars, menus, panels. `ButtonDensity.normal` +(5 call sites) shows up for things meant to feel less cramped — e.g. the +`Button.primary` "confirm this row" pattern in `blender_properties_section.dart` +via `ButtonDensity.fromTheme(theme)`. Default to compact unless you have a +specific reason not to. + +Two spacing scales, don't mix them up: +- `controlGap` — INSIDE a control (icon-to-label gap). Tighter, on purpose. + It's also the base unit the gap scale below is derived from. +- `containerGap` / `containerPadding` — between controls in a panel/popover/ + dialog, and the padding inside one. + +### The gap scale + +Layout spacing between widgets comes off `theme.density`, not a literal: + +| Step | compact | normal | Use it for | +|---|---|---|---| +| `gapXxs` | 2 | 3 | hairline — a label sat directly above its value | +| `gapXs` | 4 | 5 | tight — icon-adjacent, or items reading as one unit | +| `gapSm` | 6 | 8 | snug | +| `gapMd` | 8 | 10 | **the default** — related but distinct | +| `gapLg` | 12 | 15 | section-level, within a panel or form | +| `gapXl` | 16 | 20 | between major blocks | +| `gapXxl` | 24 | 30 | page-level | + +```dart +const Gap.md(), // <- this, not Gap(8) +``` + +`Gap` has a named constructor per step. They resolve their extent from the +theme at build time, so a call site stays `const` and still tracks the density +- no `GarageTheme.of(context)` needed in a build method just because it +contains a gap. `Gap(n)` with a literal still works and still wins, for the +rare thing that genuinely isn't on the scale. + +When in doubt, reach for `gapMd`. It's the same 8px `Gap(8)` was, so the old +advice hasn't changed — it just has a name now, and it moves when the density +does instead of staying 8 forever. + +`gapMd` equals `containerGap` and `gapXl` equals `containerPadding` at both +densities. That's not arranged, it's what those two were already set to, which +is the evidence `controlGap` is the right base unit — +`test/density_gap_scale_test.dart` holds it to that. + +**Reaching for a literal is now the exception, not the rule.** Before the +scale existed the two Garage web frontends had drifted to sixteen distinct gap +values between them, including a 3, a 5 and ten 14s. If a step doesn't fit, +that's worth a conversation about the scale rather than a one-off number. + +## Buttons — variant semantics + +`Button` and `IconButton` both expose five named constructors: +`.primary` / `.secondary` / `.outline` / `.ghost` / `.destructive`. Real usage +across both apps settles into a clear pattern — this is the single most +useful thing in this document: + +| Variant | Meaning | Evidence | +|---|---|---| +| **ghost** | Default, lowest-emphasis action. Toolbar icons, dialog close (X) buttons, settings-cog buttons. Most common `IconButton` variant by a wide margin (15 sites). | `pane_dialog.dart` close button, `blender_editor.dart` header icons | +| **outline** | Second most common (`Button.outline`: 14 sites). The "resting/inactive" half of a toggle pair, AND the standard "Cancel"/dismissive action in a dialog action row. | `export_dialog_widgets.dart`: `Button.outline(onPressed: onCancel, child: Text("Cancel"))` | +| **secondary** | Neutral filled control — NOT a toggle's resting state (that's outline), more like "has a job but isn't the emphasised one." An icon button glued onto a text field (browse/file-picker button in a `ButtonGroup`) — see *Properties* below; the field goes `TextFieldVariant.secondary` to match, and reaching for `outline` here is the usual slip. Also used as a toggle's resting state in a couple of places (Music Maker's transport controls) — outline and secondary are somewhat interchangeable for "not active right now," pick whichever reads better against the surrounding controls. | `export_dialog_widgets.dart` browse button, Music Maker transport | +| **primary** | The accent colour. Two jobs: (1) the *engaged* half of a toggle-button pair — `condition ? primary : outline` is the standard toggle idiom, used repeatedly (`_SnapPopoverButton`, `draw_panel.dart` eyedropper, Music Maker play/pause); (2) the single confirm/CTA action in a dialog, almost always via the `PrimaryButton` shorthand rather than `Button.primary` directly. | `_open ? IconButton.primary(...) : IconButton.outline(...)` | +| **destructive** | Reserved for genuinely dangerous/irreversible actions — delete, record. Rare on purpose (3 sites total). Don't reach for it just because something is "important." | Music Maker's record toggle: `recording ? IconButton.destructive(...) : IconButton.secondary(...)` | + +**The toggle-button recipe** (this exact shape appears in every editor): + +```dart +active + ? IconButton.primary( + density: ButtonDensity.compact, + icon: Icon(LucideIcons.some_icon).iconSmall, + onPressed: onToggle, + ) + : IconButton.outline( + density: ButtonDensity.compact, + icon: Icon(LucideIcons.some_icon).iconSmall, + onPressed: onToggle, + ) +``` + +**The dialog action-row recipe** (`export_dialog_widgets.dart`, verbatim shape): + +```dart +Row( + children: [ + const Spacer(), + Button.outline(onPressed: onCancel, child: const Text("Cancel")), + const Gap(8), + PrimaryButton(onPressed: enabled ? onConfirm : null, child: Text(confirmLabel)), + ], +) +``` + +There are also bare `PrimaryButton` / `SecondaryButton` / `OutlineButton` / +`GhostButton` / `DestructiveButton` widgets (no `.constructor` dot-syntax) — +lighter-weight wrappers around the same variants. `PrimaryButton` specifically +is the idiomatic way to write a dialog's confirm button, over `Button.primary`. + +## Icons + +- Source: `flutter_lucide`, re-exported through `garage_ui.dart` as + `LucideIcons`. **Names are snake_case** (`LucideIcons.file_plus`, + `LucideIcons.chevron_right`) — this is `flutter_lucide`'s native spelling, + used directly. There is no camelCase shim in this package. +- Three tiers: `.iconSmall` (`density.iconSize`), `.iconMedium` (20px), + `.iconLarge` (24px). Set size via the extension, never a hardcoded `size:`. +- **`Button` and `IconButton` already apply `small` to everything inside + them** — `leading`, `trailing` and the child — so a bare `Icon` in a button + is correctly sized and needs no extension. Reach for one only to *override* + that. Outside a button the ambient default is `medium` (20px), which is + usually too big for a control-adjacent icon; that's where a bare `Icon` does + go wrong. +- `small` is `1.1 × fontSize` — 13px at product, 11px at compact/normal — not + the line box. It was the line box, which is tidy for layout (an icon-only + control matches a text control's height for free) and wrong for the eye: + lucide glyphs fill their box nearly edge to edge while a 12px font caps out + around 8.5px, so a line-box icon read ~70% taller than the text next to it. + Control height is unaffected either way — that comes from `controlHeight`. +- Muted/de-emphasised icon: chain `.muted()` after the size extension — + `Icon(LucideIcons.chevron_left).iconSmall.muted()`. + +## Typography + +`theme.typography` exposes `normal` / `medium` (weight-only styles) and +`small` (the shared control font — size and line-height come from `Density`, +never set this by hand). Two font-family helpers: + +- `theme.typography.sansStyle(style)` — Geist, the UI's default face. +- `theme.typography.monoStyle(style)` — Geist Mono. Reach for this + specifically for **numeric readouts**: perf stats, transport time, + coordinates — anywhere the content is a number that benefits from + fixed-width digits. Not for general UI text. + +Text-widget shorthands (`Widget` extensions, merge over ambient +`DefaultTextStyle`): `.xSmall()` / `.small()` / `.large()`, `.medium()` / +`.semiBold()` / `.bold()`, `.muted()`. + +### The text ladder + +The three size shorthands come off `density`, not literals: + +| Shorthand | Token | compact / normal | Use it for | +|---|---|---|---| +| `.xSmall()` | `textXs` | 11 | dialog copy, headings, hints — and the ambient body size | +| `.small()` | `textSm` | 14 | body copy a step above the controls | +| `.large()` | `textLg` | 18 | headings | + +`textXs` sits one step above `density.fontSize` — 11 against a 10px control +font — and is also what `GarageTheme` sets as the ambient body size, so page +copy and dialog copy are the same size by construction. + +It was `x1.2` (12) for a while, which put every dialog title and page heading a +fifth above the controls beneath them. That read as oversized rather than as +hierarchy — weight and colour carry the emphasis, not size. + +What was wrong before is that the two were *unlinked*: these were hardcoded +`12`/`14`/`18` multiplied by `scaling`, while the control font comes off +`Density` and is deliberately not scaled. The intended 12-vs-10 held at scaling +1.0 and drifted to 14.4-vs-10 at 1.2 — the ratio moved with scaling. Deriving +them pins it, and makes `density.fontSize` the single knob that moves every +piece of app text together. + +`scaling` is deliberately **not** applied to these. It still moves the +medium/large icon tiers, border widths and `GarageTheme`'s own +`DefaultTextStyle` — but text that has to line up with a control cannot be on +a different axis from the control. + +## Structural widgets + +- **`Panel`** — the bordered, rounded surface for docked tool content. Two + modes: standalone (tracks its own hover) or controlled (`active: bool`, + parent drives it — used when multiple panels need to be mutually exclusive, + e.g. only one lit at a time in a Blender-style three-pane layout). Border + goes from `panelBorder` to `panelBorderHighlighted` when active/hovered. +- **`ChromeBar`** — the shared treatment for a header/footer strip: fixed + height, `chrome` background, padding. Both the header and footer in an + editor should be built from this rather than a raw `Container`, so they + stay pixel-identical in height (`kChromeBarHeight`, shared constant). +- **`EditorShell`** — header / center / optional left+right docks / footer, + stacked as one frame. The outermost layout of a whole editor screen. +- **`GarageShell`** — main content + two stacked sidebar panes with a + draggable resize handle between main and sidebar, plus centralised hover + (only one of the three panes lit at once). This is the Blender-style + three-pane editor shell, factored out so it isn't hand-rolled per app. +- **`ButtonGroup`** — lays controls out in a row (or column) and zeroes the + corners where they touch, so they read as one connected control rather than + two things that happen to be adjacent. A field with a button welded to its + end (password + edit, path + browse, input + unit) is a `ButtonGroup`, not a + `Row` with a `Gap` in it. Nested groups merge rather than shadow — an inner + group can be told to drop both its top and its start edge, which is how a + stacked field/eye/button cluster avoids a doubled stroke down the seam. + + Watch `expands`. It defaults to `false`, which wraps the flex in an + `IntrinsicHeight`; the group stretches its children on the cross axis, so + without that wrapper it needs a bounded height from its parent and throws + `BoxConstraints forces an infinite height` when it doesn't get one. Only + pass `expands: true` when the parent already gives it a height. + +- **`Card`** vs **`SurfaceCard`** — `Card` always draws its own + `OutlinedContainer` (border + fill). `SurfaceCard` additionally understands + sheet-overlay context: inside a sheet it collapses to just padding, because + the sheet is already the surface and a nested card would double up the + border. Default to `Card` unless the content might end up inside a sheet. +- **`OutlinedContainer`** — the base primitive `Card`/`Panel` build on: + border + radius + optional shadow. Reach for it directly for one-off + floating chrome that isn't quite a card — e.g. a mobile slide-in panel: + + ```dart + OutlinedContainer( + borderColor: GarageTheme.of(context).colorScheme.border, + boxShadow: [ + BoxShadow(color: const Color(0xff000000).withValues(alpha: 0.15), blurRadius: 4, spreadRadius: 2), + ], + child: ..., + ) + ``` + + A bigger, "floating well above the app" shadow (splash/welcome overlay) + goes heavier and uses negative spread to keep the blur from reading as a + hard edge: `blurRadius: 40, spreadRadius: -8, offset: Offset(0, 20)`, alpha + `0.4`. Scale shadow weight to how far off the page the thing is meant to + read as floating — a docked panel border shadow and a modal-over-everything + shadow should not look like the same intensity. + +## Properties — the settings-row system + +Any screen that is a list of *things you can change* is built from +`PropertiesSection` + `PropertyRow`. This covers settings panes, inspectors, +and account screens. Do not assemble one out of `Column` + `Text` + a +divider — the split alignment, the collapse behaviour, the actions band and +the row minimum height are all in here already. + +- **`PropertiesSection`** — a titled, collapsible block: `title`, `subtitle`, + `rows`, an optional `actions` band along the bottom for section-level + buttons (Save / Reset / Refresh), and `collapsed` + `onToggle` driven by the + parent so several sections can be remembered independently. +- **`PropertyRow`** — one setting. `label` on the left of the split, `child` + (the control) on the right. `split` is the fraction of the width sitting + left of that line, so every row in a section lines its controls up at the + same x. `labelless: true` for a row that has no name of its own. + +### `subtitle` vs `description` — the one that gets got wrong + +Both are muted second lines. They are not interchangeable, and picking the +wrong one is the single most common mistake against this component: + +| | Where it renders | What it's for | +|---|---|---| +| **`subtitle`** | Inside the **label column**, under the label, holding the same right-alignment against the split | *Naming the value.* "Last used 3d ago", "Never used", "2 permissions · last used 5m ago" — text that says **which** row this is | +| **`description`** | **Full width** under the whole row, spanning label *and* control | *Explaining the setting.* Consequences, caveats, what changes when you change it — "Permanently remove this account… This cannot be undone." | + +The test: does the sentence identify **this particular item** (subtitle), or +does it explain **what the control does** (description)? A list of five +passkeys wants five subtitles, not five full-width paragraphs. + +**Never put explanatory copy in `child`.** It is the single failure mode this +component has. A paragraph in the control column shares a cell with the +control, so the copy wraps to three lines, the button gets squeezed against +the right edge, and the section's split alignment stops meaning anything +because every row's control now starts somewhere different. `child` is for +the control. Copy goes in `description`. + +```dart +// WRONG - copy competing with the control for the same column +PropertyRow( + label: "Delete account", + scheme: scheme, + child: Row(children: [ + Expanded(child: Text("Permanently remove this account…").muted()), + const Gap.md(), + Button.destructive(onPressed: onDelete, child: const Text("Delete…")), + ]), +) + +// RIGHT - the slot that already exists for it +PropertyRow( + label: "Delete account", + scheme: scheme, + description: "Permanently remove this account, all sign-in methods, and " + "any OAuth grants. This cannot be undone.", + child: Button.destructive(onPressed: onDelete, child: const Text("Delete…")), +) +``` + +A row that is *only* explanation and has no control at all is still a +`PropertyRow` — give it the `description` and pass `const SizedBox.shrink()` +as the child. + +### Fields inside a property row + +`TextField` takes a `variant`: `TextFieldVariant.outline` (default) or +`.secondary`. Inside a properties pane, prefer `.secondary` — it matches the +filled treatment the surrounding controls use, and it pairs with +`ButtonStyle.secondary()` when a button is welded to the field in a +`ButtonGroup`. An `outline` field next to a `secondary` button (or the +reverse) reads as two controls from different screens. + +A value the user is not allowed to edit — a password, a verified email — is +still a field: `readOnly: true, enabled: false` with a stand-in value, not a +bare `Text` floating in the column. It keeps the row's geometry and tells the +reader "this is a value that lives here" rather than "this is a caption." + +## The menu system — one model, two renderers + +This is the pattern from the `AppMenuGroup` model in `app_menu.dart`, and +it's worth calling out on its own because it's easy to accidentally +reinvent per-app (it has been, twice): + +Define the menu once as data — `List` (`AppMenuGroup` → +`AppMenuAction` / `AppMenuCheck` / `AppMenuSeparator`) — not as widgets. Then: + +1. **In-app render**: walk the model into `Menubar`/`MenuButton`/ + `MenuCheckbox`/`MenuDivider` widgets (see `_toMenuItem`/`_menuItemsFor` in + either app's menu code for the translation). +2. **Native macOS bar**: `AppMenuNativeRenderer.build(menus, appName: ...)` + pushed into an `AppMenuNotifier` sitting above the app's `Router`, via + `PlatformMenuHost`. + +Both renderers must consume the **same** computed `menus` value from the +**same** build pass — not two independent calls to whatever builds the model. +Two separate computations drift: they'll watch slightly different state, +recompute at different times, and eventually disagree about what's checked or +what a shortcut is (this exact bug shipped and had to be fixed). Compute +once, hand the value to both. Gate the native push on +`AppMenuNativeRenderer.signature(menus)`, not the list itself — building a +fresh `AppMenuGroup` tree (and fresh `SingleActivator`s inside it) on every +build is normal and fine, but pushing to the native bar on every build is not +— the signature is what turns "recomputed every frame" into "pushed only +when it actually changed." + +## Anti-patterns + +- **No `package:flutter/material.dart`.** Not even for `Colors.black`. Use + `Color(0xff000000)` / `Color(0xffffffff)` — literal, not `Colors.*`. The UI + kit is deliberately `WidgetsApp`-based, not `MaterialApp`-based, so an app + built on it shouldn't reach for Material either. +- **Don't hand-type a control height, icon size, or vertical padding.** It + belongs in `Density` as a named field with everything else derived from it. +- **Don't build `chrome`/`panel` treatment from raw `Container` + hardcoded + colour.** Use `ChromeBar`/`Panel`/`EditorShell`/`GarageShell` — they read + the right token (and in chrome's case, the right *unaccented* scheme) for + you. +- **Don't hand-roll a settings row.** `PropertiesSection` + `PropertyRow` + exist, and a `Column` of `Text` + control + divider will silently lose the + split alignment, the collapse state and the actions band. If you find + yourself writing `Row(children: [Expanded(Text(...)), Gap, Button])` inside + a settings pane, you want `description:` instead. +- **Don't glue a button to a field with a `Gap`.** That's a `ButtonGroup` — + it merges the touching corners so the pair reads as one control. +- **Don't build a menu as widgets directly.** Model it as `AppMenuGroup` data + first (see above), even if there's currently only one renderer consuming + it — a native menu bar tends to get added later, and retrofitting a model + under existing widget-only menu code is exactly the refactor that + motivated this document. +- **Don't reach for `destructive` for "this matters."** It means + delete/discard/record — genuinely dangerous, genuinely irreversible. diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 0000000..9268996 --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,146 @@ +# Local development + +How to edit the SDKs and an app at the same time, and see the change in the +running app straight away — no push, no tag, no `ref:` bump while you iterate. + + +## The setup + +Your app keeps depending on the SDKs the normal way, as pinned git deps (see +[Install](../README.md#install)): + +```yaml +dependencies: + garage_ui: + git: + url: https://git.imbenji.dev/IMBENJI.NET/Garage-SDKs.git + path: garage_ui + ref: v0.1.0 +``` + +Leave that alone. Clone this repo somewhere, then next to the app's +`pubspec.yaml` add a `pubspec_overrides.yaml` that points the packages you're +working on at the local checkout: + +```yaml +# local garage sdks, so edits hot reload without a push. gitignored, never commit +dependency_overrides: + garage_ui: + path: ../Garage-SDKs/garage_ui +``` + +The path is relative to the app's folder, so adjust it to wherever your checkout +actually lives. If there's a space anywhere in it, quote it: + +```yaml + path: "../../Documents/Projects/Garage Services/SDKs/garage_ui" +``` + +pub reads `pubspec_overrides.yaml` on its own, you dont pass it anything. Run +`flutter pub get` and you should see a line per override: + +``` +! garage_ui 0.1.0 from path ../Garage-SDKs/garage_ui (overridden in ./pubspec_overrides.yaml) +``` + +If that line isn't there, the override isn't active — check the path. + + +## Overriding auth, entitlements or iap + +`garage_entitlements` and `garage_iap` both depend on `garage_auth` by path +(`path: ../garage_auth` in their pubspecs). So the moment you override either +one, the local copy pulls in a local `garage_auth` too — and your app is still +asking for the git one. pub sees `garage_auth` from two sources and refuses. + +The rule: **if you override `garage_entitlements` or `garage_iap`, override +`garage_auth` as well.** + +```yaml +dependency_overrides: + garage_auth: + path: ../Garage-SDKs/garage_auth + garage_entitlements: + path: ../Garage-SDKs/garage_entitlements + garage_ui: + path: ../Garage-SDKs/garage_ui +``` + +`garage_ui` doesnt depend on any of the others, so it can be overridden on its +own. + + +## Hot reload + +- Changed `pubspec_overrides.yaml` (added, removed, or edited a path)? Run + `flutter pub get`, then do a full restart of the app. Hot reload wont pick up + a dependency swap. +- After that, edits inside the SDK checkout behave like your own app code. Save + and hot reload. +- Same caveats as app code: if the change is in something that only runs once + (`main()`, initial state, a `const` widget tree), hot restart instead. + + +## Gitignore it + +Add this to the app's `.gitignore`: + +``` +pubspec_overrides.yaml +``` + +The path in it only exists on your machine. Commit it and every other checkout +breaks, and so does any CI job or Docker image build, because none of them have +your local SDK checkout sitting next to the app. + +While the override is active, `pubspec.lock` records the path source instead +of the git one. Thats expected. Just dont ship a lockfile in that state — see +below. + +Also: never edit the copies under `~/.pub-cache/git`. pub treats that folder as +disposable and will overwrite or delete it without asking, and your app isn't +necessarily even reading from the copy you changed. Edit a real checkout and +override to it. + + +## Shipping an SDK change + +Once the change works locally: + +1. Commit and push it in this repo. +2. Tag a new version, e.g. `v0.1.1`, and push the tag. +3. In each app that should get it, bump `ref:` to the new tag. Do it on purpose, + per app — dont float `main`. +4. Check the app builds **without** the override. Move it aside, resolve against + the real tag, and analyze: + + ```sh + mv pubspec_overrides.yaml pubspec_overrides.yaml.off + flutter pub get + flutter analyze + ``` + + This is the step that catches a forgotten push, a tag on the wrong commit, or + a `ref:` you missed. It also puts `pubspec.lock` back on the git source. +5. Deploy, then move the override back if you're carrying on. + + +## Running the SDKs' own tests + +These have tests: + +```sh +cd garage_entitlements && flutter test +cd garage_iap && flutter test +cd garage_ui && flutter test +``` + +`garage_auth` has no `test/` dir yet. + +There are two examples, both minimal wiring references rather than full apps: + +- `garage_auth/example/main.dart` — a single file showing `GarageAuth` wired + into an app. It has no pubspec of its own. +- `garage_iap/example` — a small package wiring `garage_auth` + `garage_iap` + together by path. `flutter pub get` and `flutter analyze` in there is a quick + way to check the two still fit together. diff --git a/docs/offline-licences.md b/docs/offline-licences.md new file mode 100644 index 0000000..1a844f9 --- /dev/null +++ b/docs/offline-licences.md @@ -0,0 +1,345 @@ +# Offline licences + +How `garage_entitlements` checks a licence key, where it keeps them between +runs, and what it takes to do the same thing without the Flutter package. +There's a short bit on `garage_iap`'s older licence at the end, for contrast. + +Everything here is read off the source in +`garage_entitlements/lib/src/` — `jwks_verify.dart` for the checks, +`key_cache.dart` for storage, `garage_entitlements.dart` for `refresh()` and +`cached()`. If this doc and the code ever disagree, the code wins. + +- [What a key is](#what-a-key-is) +- [The checks](#the-checks) +- [Where keys are kept](#where-keys-are-kept) +- [refresh()](#refresh) +- [cached(), and the subject](#cached-and-the-subject) +- [Key rotation](#key-rotation) +- [The device clock](#the-device-clock) +- [Verifying without the package](#verifying-without-the-package) +- [garage_iap's licence, for contrast](#garage_iaps-licence-for-contrast) + + +## What a key is + +One compact RS256 JWT per entitlement. The header carries `alg` and `kid`, the +payload looks like this: + +```json +{ + "sub": "user-123", + "iss": "https://pay.imbenji.net", + "aud": "field-notes/pro", + "project": "field-notes", + "sku": "pro", + "kind": "one_off", + "mode": "live", + "expires_at": "2026-10-03T00:00:00Z", + "iat": 1790000000, + "exp": 1790003600 +} +``` + +| Claim | What it is | +| --- | --- | +| `sub` | the user id the key was minted for | +| `iss` | who signed it | +| `aud` | `"/"` — one string, not an array | +| `project`, `sku` | the same two things again, split out | +| `kind` | `"one_off"` or `"subscription"` | +| `mode` | `"live"` or `"sandbox"` | +| `expires_at` | when the *entitlement* runs out, ISO 8601. Missing for something owned outright | +| `iat`, `exp` | seconds since epoch. `exp` is the *key's* expiry | + +`exp` is the access decision. The server clamps it to the entitlement's own +expiry before signing, so a key cant outlive the thing it unlocks. `expires_at` +is for a UI to show ("renews on the 3rd") — dont gate on it. + +The only claims that get checked are `iss`, `aud`, `sub`, `mode` and `exp`. +`project`, `sku` and `kind` are read into the `GarageKey` after the checks +pass, and `expires_at` becomes `entitlementExpiresAt`. + + +## The checks + +`verifyKey()` in `jwks_verify.dart`. The order is fixed, and the first failure +stops it — any failure means not entitled. Every failure is an +`EntitlementsError` with a `code`, so a caller can treat them all the same. + +Before the six proper checks there are two cheap ones: the token has to split +into three parts (`bad_token`) and the header `alg` has to be `RS256` +(`bad_alg`). No `none`, no HS256, no negotiating. + +**1. Signature.** Look up the JWKS key whose `kid` matches the header's `kid`, +rebuild the RSA public key from its `n` and `e`, and check a PKCS#1 v1.5 +SHA-256 signature over `header.payload`. That's pointycastle, the same RSA +stack the store signs with — there's no JWT library in the package, on +purpose. Only `kty: "RSA"` entries are considered. + +A `kid` that isnt in the JWKS is refused (`kid_not_found`), not guessed at. The +package wont try the other keys to see if one happens to work. The one +exception: if the header has *no* `kid` at all, the first RSA key in the doc is +used. + +A bad signature is `bad_signature`. A mangled signature that makes pointycastle +throw is logged and treated the same. + +**2. `iss`.** Must equal the issuer the app was built with — the `issuer` +constructor argument, which defaults to `kGarageLicenceIssuer`: + +```dart +const String kGarageLicenceIssuer = String.fromEnvironment( + "GARAGE_LICENCE_ISSUER", + defaultValue: "https://pay.imbenji.net", +); +``` + +So `--dart-define=GARAGE_LICENCE_ISSUER=...` overrides it at build time. The +point is that it's a constant the app checks against. The token doesnt get to +say who it is. Fails as `iss_mismatch`. + +**3. `aud`.** Must be exactly `"/"` — a single string. Right +project wrong sku fails, right sku wrong project fails, an array fails. +`aud_mismatch`. + +**4. `sub`.** Must equal the signed-in user's id. Somebody elses key copied +onto this device fails here. `sub_mismatch`. Where the expected `sub` comes +from matters a lot offline, see [cached()](#cached-and-the-subject). + +**5. `mode`.** Must equal the `mode` the `GarageEntitlements` was constructed +with (`"live"` by default). A sandbox key never satisfies a live check, and the +other way round. A missing `mode` fails too. `mode_mismatch`. + +**6. `exp`.** A key with no `exp` is refused (`no_exp`). Otherwise, if the +device clock is past it, `expired`. + +If all of that passes you get a `GarageKey`. `has(sku)` then just looks in the +verified set — and `key(sku)` re-checks `exp` every time it's asked, dropping a +key that went stale while the app was running. + + +## Where keys are kept + +`KeyCache` is the seam. The default is `SecureKeyCache`, on +`flutter_secure_storage` — the same place `garage_auth` keeps its tokens. + +One blob per project, under `ge.keys.`: + +```json +{ + "keys": { "pro": "", "extras": "" }, + "jwks": { "keys": [ { "kty": "RSA", "kid": "...", "n": "...", "e": "..." } ] } +} +``` + +The JWKS and the whole key set are written together, in one write. There's +deliberately no "keys but no JWKS" state, because that's a pile of tokens you +cant check. + +What's on disk is the raw tokens, not a verdict. Every load re-verifies them. + +Things worth knowing about the default store: + +- A read that fails (or a blob that wont parse) is logged and treated as no + cache. +- A write or clear that fails is logged and **not** thrown. So a `refresh()` + can succeed in memory and still not have persisted — the next cold start + would see the old blob. + +`MemoryKeyCache` is the in memory one, for tests or anywhere you dont want +persistence: + +```dart +final ent = GarageEntitlements( + auth: auth, + projectSlug: "field-notes", + apiBaseUrl: "https://pay.imbenji.net/api", + cache: MemoryKeyCache(), +); +``` + +`clear()` deletes the blob and empties the in memory set. Call it on sign-out. +The `sub` check would reject the old user's keys anyway, but there's no reason +to leave them on disk. + + +## refresh() + +``` +GET /v1/licences?project=[&ttl=] +``` + +with the signed-in user's bearer. The response carries the keys and the JWKS +together: + +```json +{ + "licences": [ { "sku": "pro", "licence": "", ... } ], + "jwks": { "keys": [ ... ] } +} +``` + +What it does with it, in order: + +1. No `jwks` object in the body is an error (`no_jwks`). Nothing changes. +2. Work out the expected `sub` (see below). +3. For each entry, take the sku from the token's own `aud` — not the envelope's + `sku`, which is a convenience and ignored — and run the full + [six checks](#the-checks) against the JWKS that came in the same response. + A token with an `aud` it cant split into project/sku is `bad_token`. +4. If **any** key fails, the whole refresh throws. The cache and the in memory + set are left exactly as they were. +5. Only once every key has verified: write the new blob (all keys + the new + JWKS), then replace the in memory set and notify listeners. + +Replace, not merge. A sku missing from the response is gone, from memory and +from disk. That's how a cancelled subscription stops working the next time the +app is online, rather than hanging on untill its key's `exp`. An empty +`licences` list empties the set. + +`ttl` asks for shorter keys. The server clamps it, so asking for longer than +the project allows doesnt error, it just doesnt get you longer. + + +## cached(), and the subject + +`cached()` is the boot path: read the blob, verify each key, fill the set. It +never fetches keys. But it does need the expected `sub` for check 4, and that +is the one place it can need the network. + +The subject comes from `_subject()`: + +- The first time it's asked, it calls `auth.profile()`, which is the OIDC + userinfo endpoint (plus the discovery document, if `garage_auth` hasnt + fetched that yet this run). It takes `sub` out of the answer. +- After that it's remembered on the `GarageEntitlements` instance, so later + calls are free. + +So what happens depends on whether that instance has resolved a subject yet: + +- **It has** — say `refresh()` ran earlier this session. `cached()` is fully + offline. +- **It hasnt, and userinfo is reachable** — one round trip, then offline. +- **It hasnt, and userinfo isnt reachable** — a cold start with no network is + the usual one. `profile()` throws, `cached()` logs "cant resolve the subject", + and **drops every key** from the in memory set. `has()` is false for + everything. +- **The user isnt signed in** — `profile()` returns null, there's no `sub` + (`no_subject`), same result: every key dropped. + +In the last two cases only the in memory set is emptied. The blob on disk is +left alone, so a later `cached()` that can resolve the subject picks the keys +straight back up. + +Note that it's the first `cached()` on a fresh instance that makes the call, so +"verifies whats on disk, zero network" in the README holds once the subject is +known, not on a cold offline launch. If your app has to unlock features on a +plane from a cold start, that's the path to plan around. + +Once the subject is resolved, each cached key is checked on its own. A key that +fails — expired is the normal case, but also rotated away, wrong user, wrong +mode — is logged and dropped, and the rest are kept. One dead key doesnt take +the set with it. The sku it's checked against is the map key it was stored +under, so a token filed under the wrong sku fails `aud`. + +The remembered subject lives as long as the instance and `clear()` doesnt reset +it. If a different user can sign in without the app restarting, give them a new +`GarageEntitlements` rather than reusing the old one. + + +## Key rotation + +Rotation needs nothing from the app. + +The JWKS always arrives in the same response as the keys, and is cached with +them, so the keys on disk are always paired with the JWKS they were checked +against. While both old and new signing keys are in the published JWKS, keys +signed by either verify (there's a test for exactly that). When a key is +finally rotated out, any cached key still signed by it fails `kid_not_found` on +the next `cached()` — and the next `refresh()` brings down fresh keys *and* the +new JWKS together, so it sorts itself out the next time the app is online. + + +## The device clock + +Offline, `exp` is compared against the device clock, and the device clock can +be wound back. That's an accepted limitation. The alternative is refusing to +work without a network, which is the thing offline keys exist to avoid. + +What bounds it is the key lifetime. The shorter the TTL, the more often the app +has to come online and fetch fresh keys anyway. + + +## Verifying without the package + +If you're checking a key somewhere else — a backend, a CLI, another language — +it's the same job. + +**Getting keys and the JWKS.** The package gets both from one call: + +``` +GET https://pay.imbenji.net/api/v1/licences?project= +Authorization: Bearer +``` + +`ttl=` is optional. The body is `{"licences": [{"licence": "", ...}], "jwks": {...}}`. +`garage_entitlements` doesnt call a separate JWKS endpoint — it only ever uses +the `jwks` that comes back in that body — so that's the one to use. + +If you're holding a key you got some other way, you still need the JWKS that +goes with it. Keep them together, like the package does. + +**Checking one.** In this order, and treat any failure as not entitled: + +0. Three dot-separated parts. Header `alg` is exactly `RS256` — reject anything + else before looking further. +1. Find the JWKS entry with `kty: "RSA"` and a `kid` equal to the header's + `kid`. Not found = reject. Dont fall back to trying every key. Verify + RSASSA-PKCS1-v1_5 with SHA-256 over the ASCII bytes of + `
.` (the base64url strings as they are in the token), using + the public key from base64url `n` and `e`. +2. `iss == "https://pay.imbenji.net"`. Hard-code it. Dont read it from the + token and trust it. +3. `aud == "/"`, as a plain string, for the project and sku + you're gating. +4. `sub ==` the user you think you're talking to, from your own session — not + from the token. +5. `mode ==` `"live"` (or `"sandbox"` while the project is in sandbox). Missing + is a fail. +6. `exp` present, and now is before it. + +Most JWT libraries will do 1, 2, 3 and 6 for you if you give them the JWKS, pin +the algorithm to RS256 and tell them the issuer and audience. `sub` and `mode` +you check yourself. + +On a server you have a proper clock and a network, so the offline caveats dont +apply — and if all you want is "does this user own it right now", the +`/v1/entitlements` ledger is the truth anyway. The key is for when you cant +ask. + + +## garage_iap's licence, for contrast + +`garage_iap` predates all of this and works differently. One licence JWT for +the whole app, not one per entitlement: + +- Fetched from `GET /v1/licence?app=`, with the JWKS + fetched separately from `GET /v1/licence/jwks.json` in the same + trip. +- Its payload has an `app` claim and a `products` claim — a list of + `{sku, kind, expires_at}` for everything the user owns in that app. +- Cached as `{token, jwks}` under `gi.licence.` via the + `LicenceCache` seam (`SecureLicenceCache`, or `MemoryLicenceCache` for + tests). It's only written after it verifies. + +`verifyLicence()` checks less: `RS256`, the signature by `kid` (same lookup +rules as above), then `sub`, then `app`, then `exp`. There's no `iss`, no +`aud`, and no `mode` check. `has(sku)` then asks whether the sku is anywhere in +`products` — the per product `expires_at` isnt looked at, only the licence's +own `exp`. + +That's the wallet problem the per-entitlement keys were built to avoid: a +lock that only cares about one feature gets handed the list of everything the +user owns. It also resolves the subject through `profile()` the same way, so +the same cold-offline caveat applies — except there a failure to resolve it +throws out of `cachedLicence()` rather than quietly emptying the set. diff --git a/docs/platform-setup.md b/docs/platform-setup.md new file mode 100644 index 0000000..0a6506a --- /dev/null +++ b/docs/platform-setup.md @@ -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 ``: + +```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). diff --git a/garage_auth/LICENSE b/garage_auth/LICENSE new file mode 100644 index 0000000..72fab54 --- /dev/null +++ b/garage_auth/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 IMBENJI.NET LTD + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/garage_auth/analysis_options.yaml b/garage_auth/analysis_options.yaml new file mode 100644 index 0000000..a907916 --- /dev/null +++ b/garage_auth/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + # the SDK prints caught errors to console on purpose for debugging + avoid_print: false diff --git a/garage_auth/example/main.dart b/garage_auth/example/main.dart new file mode 100644 index 0000000..5deefcf --- /dev/null +++ b/garage_auth/example/main.dart @@ -0,0 +1,60 @@ +// Minimal, runnable-shaped example of wiring GarageAuth into a Flutter app. +// Not a full app — just the moving parts. See the README for the callback +// route + deep-link handling. + +import "package:flutter/material.dart"; +import "package:garage_auth/garage_auth.dart"; + +final auth = GarageAuth( + issuer: "https://hub.imbenji.net/auth-api", + clientId: "my-app", + redirectUri: "myapp://auth/callback", +); + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await auth.restore(); + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: ListenableBuilder( + listenable: auth, + builder: (context, _) { + return Scaffold( + appBar: AppBar(title: const Text("garage_auth example")), + body: Center( + child: auth.isSignedIn + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text("Signed in 🎉"), + TextButton( + onPressed: () async { + final me = await auth.profile(); + debugPrint("profile: $me"); + }, + child: const Text("Print profile"), + ), + TextButton( + onPressed: auth.signOut, + child: const Text("Sign out"), + ), + ], + ) + : TextButton( + onPressed: auth.signIn, + child: const Text("Sign in with Garage"), + ), + ), + ); + }, + ), + ); + } +} diff --git a/garage_auth/lib/garage_auth.dart b/garage_auth/lib/garage_auth.dart new file mode 100644 index 0000000..48fe72f --- /dev/null +++ b/garage_auth/lib/garage_auth.dart @@ -0,0 +1,12 @@ +// Sign in with Garage — OIDC PKCE auth core for Garage apps. +// +// The public surface is GarageAuth plus the TokenStore seam and AuthError. +// garage_iap (and any future package) builds on this — share one GarageAuth +// instance so everything reuses the same session + authed client. +library; + +export "src/garage_auth.dart" show GarageAuth; +export "src/oidc.dart" show AuthError; +export "src/token_store.dart" + show TokenStore, SecureTokenStore, MemoryTokenStore; +export "src/authed_client.dart" show AuthedClient; diff --git a/garage_auth/lib/src/authed_client.dart b/garage_auth/lib/src/authed_client.dart new file mode 100644 index 0000000..21f9e7a --- /dev/null +++ b/garage_auth/lib/src/authed_client.dart @@ -0,0 +1,74 @@ +import "dart:async"; + +import "package:http/http.dart" as http; + +// an http.Client that quietly attaches the current bearer token to every +// request, and if a call comes back 401 it tries to refresh the token *once* +// and replays the same request. callers use it exactly like a normal +// http.Client (.get/.post/.send) — auth is invisible to them. +class AuthedClient extends http.BaseClient { + AuthedClient({ + required http.Client inner, + required String? Function() tokenSource, + required Future Function() refresh, + }) : _inner = inner, + _token = tokenSource, + _refresh = refresh; + + final http.Client _inner; + final String? Function() _token; + final Future Function() _refresh; + + // we serialise refreshes so a burst of 401s doesnt fire five refreshes at + // once — the first one wins and the rest await it. + Future? _inflight; + + @override + Future send(http.BaseRequest request) async { + final first = await _inner.send(_withAuth(request, _token())); + + if (first.statusCode != 401) return first; + + // a streamed body can only be read once, so if the request carried one we + // cant safely replay it. bail out with the 401 in that case. + if (request is! http.Request) return first; + + // drain the failed response so the connection can be reused + await first.stream.drain(); + + final ok = await _refreshOnce(); + if (!ok) return first; + + return _inner.send(_clone(request, _token())); + } + + Future _refreshOnce() { + _inflight ??= _refresh().whenComplete(() => _inflight = null); + return _inflight!; + } + + http.BaseRequest _withAuth(http.BaseRequest req, String? token) { + if (token != null && token.isNotEmpty) { + req.headers["Authorization"] = "Bearer $token"; + } + return req; + } + + // rebuild a fresh Request because BaseRequest is single-shot once sent + http.Request _clone(http.Request src, String? token) { + final out = http.Request(src.method, src.url) + ..headers.addAll(src.headers) + ..followRedirects = src.followRedirects + ..maxRedirects = src.maxRedirects + ..persistentConnection = src.persistentConnection + ..bodyBytes = src.bodyBytes; + + if (token != null && token.isNotEmpty) { + out.headers["Authorization"] = "Bearer $token"; + } + return out; + } + + @override + void close() => _inner.close(); +} diff --git a/garage_auth/lib/src/garage_auth.dart b/garage_auth/lib/src/garage_auth.dart new file mode 100644 index 0000000..ba511ac --- /dev/null +++ b/garage_auth/lib/src/garage_auth.dart @@ -0,0 +1,305 @@ +import "dart:async"; +import "dart:convert"; + +import "package:flutter/foundation.dart"; +import "package:http/http.dart" as http; + +import "authed_client.dart"; +import "oidc.dart"; +import "platform/redirect.dart"; +import "token_store.dart"; + +// storage keys. namespaced by clientId so two GarageAuth instances in the same +// app (different clients) dont clobber each others tokens. +const _kAccess = "ga.access"; +const _kRefresh = "ga.refresh"; +const _kVerifier = "ga.pkce_verifier"; +const _kState = "ga.oauth_state"; + +// "Sign in with Garage" — the core auth client every Garage app (and the iap +// package) builds on. OIDC Authorization Code + PKCE against the hub, opaque +// token storage + refresh, and an authed http client that replays the bearer. +// +// final auth = GarageAuth( +// issuer: "https://hub.imbenji.net/auth-api", +// clientId: "my-app", +// redirectUri: "myapp://auth/callback", +// ); +// await auth.restore(); // pick up an existing session +// await auth.signIn(); // kick off PKCE +// final me = await auth.profile(); +// final r = await auth.client.get(Uri.parse(".../v1/whatever")); +// +// it's a ChangeNotifier so UIs can rebuild on sign in / out. +class GarageAuth extends ChangeNotifier { + GarageAuth({ + required this.issuer, + required this.clientId, + required this.redirectUri, + this.scopes = const ["openid", "profile", "email"], + http.Client? httpClient, + TokenStore? tokenStore, + PlatformRedirect redirect = const PlatformRedirect(), + }) : _http = httpClient ?? http.Client(), + _store = tokenStore ?? SecureTokenStore(), + _redirect = redirect { + _discovery = OidcDiscovery(issuer, _http); + client = AuthedClient( + inner: _http, + tokenSource: () => _accessToken, + refresh: _refresh, + ); + } + + final String issuer; + final String clientId; + final String redirectUri; + final List scopes; + + final http.Client _http; + final TokenStore _store; + final PlatformRedirect _redirect; + + late final OidcDiscovery _discovery; + + // the authed http client — adds the bearer, refreshes on 401. share this with + // garage_iap and any other higher level package so they all reuse one session. + late final AuthedClient client; + + String? _accessToken; + String? _refreshToken; + bool _restored = false; + + String? get accessToken => _accessToken; + bool get isSignedIn => _accessToken != null; + bool get isRestored => _restored; + + String _k(String base) => "$base.$clientId"; + + // ------------------------------------------------------------------------- + // session restore — call once on boot + // ------------------------------------------------------------------------- + + Future restore() async { + try { + _accessToken = await _store.read(_k(_kAccess)); + _refreshToken = await _store.read(_k(_kRefresh)); + } catch (e) { + print("garage_auth restore failed: $e"); + _accessToken = null; + _refreshToken = null; + } + _restored = true; + notifyListeners(); + } + + // ------------------------------------------------------------------------- + // sign in + // ------------------------------------------------------------------------- + + // begins the PKCE flow. on web this navigates the tab away to the IdP and + // never returns here — the app reloads at the redirect path and must call + // completeSignIn() with the query params. on native it opens the system + // browser; the host app catches the inbound deep link and likewise calls + // completeSignIn(). this is the lower level half of signIn(). + Future beginSignIn() async { + if (clientId.isEmpty) { + throw AuthError("GarageAuth: clientId is empty."); + } + + final ep = await _discovery.endpoints(); + + final verifier = randomUrlSafe(64); + final challenge = s256Challenge(verifier); + final state = randomUrlSafe(24); + + // stash so completeSignIn can finish the exchange after the round trip + await _store.write(_k(_kVerifier), verifier); + await _store.write(_k(_kState), state); + + final resolved = _redirect.resolveRedirectUri(redirectUri); + + final authUri = Uri.parse(ep.authorize).replace(queryParameters: { + "response_type": "code", + "client_id": clientId, + "redirect_uri": resolved, + "scope": scopes.join(" "), + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + }); + + await _redirect.navigateTo(authUri.toString()); + } + + // convenience over beginSignIn(). on web this triggers the redirect and the + // future never really "completes" (the page is leaving) — your callback route + // drives completeSignIn. on native it's the same begin step; the deep-link + // handler completes it. so think of signIn() as "start the flow". + Future signIn() => beginSignIn(); + + // finishes the exchange. callers pass the query params off the inbound + // callback url (web: go_router state.uri.queryParameters, native: parse the + // deep link). returns true when a token was obtained. + Future completeSignIn(Map params) async { + final code = params["code"]; + final returnedState = params["state"]; + final error = params["error"]; + + if (error != null) { + print("garage_auth callback error: $error"); + throw AuthError("Sign in failed: $error"); + } + if (code == null || code.isEmpty) { + throw AuthError("No authorization code in callback."); + } + + final savedState = await _store.read(_k(_kState)); + if (savedState == null || savedState != returnedState) { + throw AuthError("State mismatch on OAuth callback."); + } + + final verifier = await _store.read(_k(_kVerifier)); + if (verifier == null) { + throw AuthError("Missing PKCE verifier — start the sign in again."); + } + + final ep = await _discovery.endpoints(); + final resolved = _redirect.resolveRedirectUri(redirectUri); + + final resp = await _http.post( + Uri.parse(ep.token), + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + body: { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": resolved, + "client_id": clientId, + "code_verifier": verifier, + // NO client_secret — public client + }, + ); + + if (resp.statusCode != 200) { + print( + "garage_auth token exchange failed: ${resp.statusCode} ${resp.body}"); + throw AuthError("Token exchange failed (${resp.statusCode})."); + } + + await _absorbTokens(resp.body); + + // single-use bits — clear so a stale verifier cant be replayed + await _store.delete(_k(_kVerifier)); + await _store.delete(_k(_kState)); + + notifyListeners(); + return true; + } + + // ------------------------------------------------------------------------- + // refresh — used internally by the authed client on a 401 + // ------------------------------------------------------------------------- + + Future _refresh() async { + final rt = _refreshToken; + if (rt == null || rt.isEmpty) return false; + + try { + final ep = await _discovery.endpoints(); + final resp = await _http.post( + Uri.parse(ep.token), + headers: {"Content-Type": "application/x-www-form-urlencoded"}, + body: { + "grant_type": "refresh_token", + "refresh_token": rt, + "client_id": clientId, + }, + ); + + if (resp.statusCode != 200) { + print("garage_auth refresh failed: ${resp.statusCode}"); + // refresh token's no good anymore — drop the session so the UI can + // prompt a fresh sign in rather than spinning on dead tokens. + await _clearTokens(); + notifyListeners(); + return false; + } + + await _absorbTokens(resp.body); + notifyListeners(); + return true; + } catch (e) { + print("garage_auth refresh threw: $e"); + return false; + } + } + + Future _absorbTokens(String body) async { + final j = jsonDecode(body) as Map; + + final access = j["access_token"] as String?; + if (access == null || access.isEmpty) { + throw AuthError("No access_token in token response."); + } + _accessToken = access; + await _store.write(_k(_kAccess), access); + + // some flows rotate the refresh token, some dont return one on refresh — + // only overwrite when we actually got a new one. + final refresh = j["refresh_token"] as String?; + if (refresh != null && refresh.isNotEmpty) { + _refreshToken = refresh; + await _store.write(_k(_kRefresh), refresh); + } + } + + // ------------------------------------------------------------------------- + // profile / userinfo + // ------------------------------------------------------------------------- + + // reads the OIDC userinfo claims (sub, email, email_verified, is_developer, + // is_admin, …). goes through the authed client so it refreshes on 401. + // returns null when signed out. + Future?> profile() async { + if (!isSignedIn) return null; + + final ep = await _discovery.endpoints(); + final endpoint = ep.userinfo; + if (endpoint == null) { + throw AuthError("Issuer has no userinfo_endpoint in discovery."); + } + + final resp = await client.get(Uri.parse(endpoint)); + if (resp.statusCode != 200) { + throw AuthError("userinfo failed (${resp.statusCode})."); + } + + return jsonDecode(resp.body) as Map; + } + + // ------------------------------------------------------------------------- + // sign out + // ------------------------------------------------------------------------- + + Future signOut() async { + await _clearTokens(); + notifyListeners(); + } + + Future _clearTokens() async { + _accessToken = null; + _refreshToken = null; + try { + await _store.delete(_k(_kAccess)); + await _store.delete(_k(_kRefresh)); + } catch (e) { + print("garage_auth signOut storage clear failed: $e"); + } + } + + @override + void dispose() { + client.close(); + super.dispose(); + } +} diff --git a/garage_auth/lib/src/oidc.dart b/garage_auth/lib/src/oidc.dart new file mode 100644 index 0000000..972f69e --- /dev/null +++ b/garage_auth/lib/src/oidc.dart @@ -0,0 +1,83 @@ +import "dart:convert"; +import "dart:math"; + +import "package:crypto/crypto.dart"; +import "package:http/http.dart" as http; + +class AuthError implements Exception { + AuthError(this.message); + final String message; + + @override + String toString() => message; +} + +// the bits of the discovery doc we actually use. userinfo is optional in the +// spec but the hub serves it; we fall back gracefully if it's missing. +class OidcEndpoints { + OidcEndpoints({ + required this.authorize, + required this.token, + this.userinfo, + this.endSession, + }); + + final String authorize; + final String token; + final String? userinfo; + final String? endSession; +} + +// fetches + caches /.well-known/openid-configuration under the issuer. +class OidcDiscovery { + OidcDiscovery(this.issuer, this._client); + + final String issuer; + final http.Client _client; + + OidcEndpoints? _cached; + + Future endpoints() async { + if (_cached != null) return _cached!; + + final url = "$issuer/.well-known/openid-configuration"; + final resp = await _client.get(Uri.parse(url)); + if (resp.statusCode != 200) { + throw AuthError("OIDC discovery failed (${resp.statusCode}) at $url"); + } + + final j = jsonDecode(resp.body) as Map; + final authorize = j["authorization_endpoint"] as String?; + final token = j["token_endpoint"] as String?; + if (authorize == null || token == null) { + throw AuthError("Discovery document missing endpoints."); + } + + _cached = OidcEndpoints( + authorize: authorize, + token: token, + userinfo: j["userinfo_endpoint"] as String?, + endSession: j["end_session_endpoint"] as String?, + ); + return _cached!; + } +} + +// ----- PKCE ----- + +const _pkceChars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + +String randomUrlSafe(int len) { + final rnd = Random.secure(); + final sb = StringBuffer(); + for (var i = 0; i < len; i++) { + sb.write(_pkceChars[rnd.nextInt(_pkceChars.length)]); + } + return sb.toString(); +} + +String s256Challenge(String verifier) { + final digest = sha256.convert(utf8.encode(verifier)); + return base64Url.encode(digest.bytes).replaceAll("=", ""); +} diff --git a/garage_auth/lib/src/platform/redirect.dart b/garage_auth/lib/src/platform/redirect.dart new file mode 100644 index 0000000..a4221aa --- /dev/null +++ b/garage_auth/lib/src/platform/redirect.dart @@ -0,0 +1 @@ +export "redirect_io.dart" if (dart.library.js_interop) "redirect_web.dart"; diff --git a/garage_auth/lib/src/platform/redirect_io.dart b/garage_auth/lib/src/platform/redirect_io.dart new file mode 100644 index 0000000..aa8e3e7 --- /dev/null +++ b/garage_auth/lib/src/platform/redirect_io.dart @@ -0,0 +1,23 @@ +import "package:url_launcher/url_launcher.dart"; + +// native / desktop: the redirect uri is whatever custom scheme the app +// registered (myapp://auth/callback). it has to be wired into the per-platform +// runner manifests by the embedding app — we cant do that from a package. we +// just open the system browser and the host app catches the inbound deep link +// on resume and feeds the params back to completeSignIn(). +class PlatformRedirect { + const PlatformRedirect(); + + // on native the configured redirectUri is authoritative — there's no "origin". + String resolveRedirectUri(String configured) => configured; + + Future navigateTo(String url) async { + // fire it at the system browser. we dont await the result of the launch + // because the round trip happens out of process. + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + + // native has no live "current url" — the deep link arrives separately and the + // host app passes its params in. return null so callers know to use those. + Uri? currentUri() => null; +} diff --git a/garage_auth/lib/src/platform/redirect_web.dart b/garage_auth/lib/src/platform/redirect_web.dart new file mode 100644 index 0000000..ebee534 --- /dev/null +++ b/garage_auth/lib/src/platform/redirect_web.dart @@ -0,0 +1,40 @@ +import "package:web/web.dart" as web; + +// web: the redirect uri is the origin we're served from plus the callback path, +// and "navigate to authorize" literally drives the browser tab away. the app +// reloads at /auth/callback with ?code=&state= in the query. +class PlatformRedirect { + const PlatformRedirect(); + + // on web we ignore the app's configured scheme and use the live origin so the + // IdP bounces back to the same deployment. we keep the *path* the app asked + // for though, so it can route the callback wherever it likes. + String resolveRedirectUri(String configured) { + final loc = web.window.location; + + // pull the path off whatever the app configured. if it gave us a custom + // scheme (a native deep link) fall back to /auth/callback. + // + // careful with the deep link shape: in "garagepay://auth/callback" the + // "auth" is the HOST and the path is only "/callback", so taking the path + // off one of those produced https://host/callback and auth rejected it as + // an unregistered redirect_uri. only take the path when its actually a + // path — schemeless, or a real http(s) url. + var path = "/auth/callback"; + final parsed = Uri.tryParse(configured); + if (parsed != null && parsed.path.isNotEmpty) { + final isWebUrl = parsed.scheme == "http" || parsed.scheme == "https"; + if (!parsed.hasScheme || isWebUrl) { + path = parsed.path; + } + } + + return "${loc.origin}$path"; + } + + Future navigateTo(String url) async { + web.window.location.assign(url); + } + + Uri? currentUri() => Uri.parse(web.window.location.href); +} diff --git a/garage_auth/lib/src/token_store.dart b/garage_auth/lib/src/token_store.dart new file mode 100644 index 0000000..e23a634 --- /dev/null +++ b/garage_auth/lib/src/token_store.dart @@ -0,0 +1,42 @@ +import "package:flutter_secure_storage/flutter_secure_storage.dart"; + +// small key/value seam so the token storage backend is swappable. the default +// is flutter_secure_storage which covers mobile + desktop properly and falls +// back to a best-effort impl on web. anyone embedding the SDK can hand us their +// own (e.g. an in-memory one for tests, or shared_prefs if they dont care). +abstract class TokenStore { + Future read(String key); + Future write(String key, String value); + Future delete(String key); +} + +class SecureTokenStore implements TokenStore { + SecureTokenStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + final FlutterSecureStorage _storage; + + @override + Future read(String key) => _storage.read(key: key); + + @override + Future write(String key, String value) => + _storage.write(key: key, value: value); + + @override + Future delete(String key) => _storage.delete(key: key); +} + +// handy for tests, or platforms where you explicitly dont want persistence. +class MemoryTokenStore implements TokenStore { + final Map _m = {}; + + @override + Future read(String key) async => _m[key]; + + @override + Future write(String key, String value) async => _m[key] = value; + + @override + Future delete(String key) async => _m.remove(key); +} diff --git a/garage_auth/pubspec.yaml b/garage_auth/pubspec.yaml new file mode 100644 index 0000000..fc1b985 --- /dev/null +++ b/garage_auth/pubspec.yaml @@ -0,0 +1,28 @@ +name: garage_auth +description: "Sign in with Garage — OIDC PKCE auth core, token storage + refresh, and a shared authed HTTP client for Garage apps." +version: 0.1.0 +publish_to: 'none' + +environment: + sdk: ^3.5.0 + flutter: ">=3.5.0" + +dependencies: + flutter: + sdk: flutter + + http: ^1.5.0 + crypto: ^3.0.6 + flutter_secure_storage: ">=9.2.2 <12.0.0" + url_launcher: ^6.3.1 + + # only pulled in on web builds for reading the callback url / navigating the tab + web: ^1.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^6.0.0 + +flutter: diff --git a/garage_entitlements/LICENSE b/garage_entitlements/LICENSE new file mode 100644 index 0000000..72fab54 --- /dev/null +++ b/garage_entitlements/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 IMBENJI.NET LTD + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/garage_entitlements/analysis_options.yaml b/garage_entitlements/analysis_options.yaml new file mode 100644 index 0000000..af3fd7b --- /dev/null +++ b/garage_entitlements/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + # caught errors get printed on purpose — a swallowed exception is worse + # than a noisy console. + avoid_print: false diff --git a/garage_entitlements/lib/garage_entitlements.dart b/garage_entitlements/lib/garage_entitlements.dart new file mode 100644 index 0000000..2f4b6dc --- /dev/null +++ b/garage_entitlements/lib/garage_entitlements.dart @@ -0,0 +1,21 @@ +// Offline licence keys for Garage apps — layered on garage_auth. +// +// One key per entitlement, verified against the store's published JWKS. The +// key is the gate; the ledger is the truth behind it. +// +// final ent = GarageEntitlements( +// auth: auth, +// projectSlug: "field-notes", +// apiBaseUrl: "https://pay.imbenji.net/api", +// ); +// await ent.cached(); // boot, no network +// await ent.refresh(); // when theres a connection +// if (ent.has("pro-annual")) unlock(); +library; + +export "src/garage_entitlements.dart" + show GarageEntitlements, kGaragePortalBaseUrl, kGarageLicenceIssuer; +export "src/models.dart" + show GarageKey, GarageEntitlement, GarageProduct, EntitlementsError; +export "src/key_cache.dart" + show KeyCache, SecureKeyCache, MemoryKeyCache, CachedKeys; diff --git a/garage_entitlements/lib/src/garage_entitlements.dart b/garage_entitlements/lib/src/garage_entitlements.dart new file mode 100644 index 0000000..c3070db --- /dev/null +++ b/garage_entitlements/lib/src/garage_entitlements.dart @@ -0,0 +1,391 @@ +import "dart:async"; +import "dart:convert"; + +import "package:flutter/foundation.dart"; +import "package:garage_auth/garage_auth.dart"; +import "package:http/http.dart" as http; + +import "jwks_verify.dart"; +import "key_cache.dart"; +import "models.dart"; + +/// Where the payment portal lives. Overridable per call, but this is the one +/// buyers actually land on. +const String kGaragePortalBaseUrl = String.fromEnvironment( + "GARAGE_PORTAL_BASE_URL", + defaultValue: "https://pay.imbenji.net", +); + +/// What `iss` has to say. A constant, checked against — not something the +/// token gets to tell us. +const String kGarageLicenceIssuer = String.fromEnvironment( + "GARAGE_LICENCE_ISSUER", + defaultValue: "https://pay.imbenji.net", +); + +/// Offline licence keys for one project. +/// +/// One key per entitlement. Money bought one thing, so a key unlocks that one +/// thing — a project-wide licence would be a wallet, and handing a wallet to a +/// lock that only cares about one feature tells it everything the user owns. +/// +/// final ent = GarageEntitlements( +/// auth: auth, +/// projectSlug: "field-notes", +/// apiBaseUrl: "https://pay.imbenji.net/api", +/// ); +/// await ent.cached(); // fast, no network +/// await ent.refresh(); // when you have a connection +/// if (ent.has("pro-annual")) { ... } +/// +/// A ChangeNotifier, so a ListenableBuilder redraws the gates when the set +/// moves. +class GarageEntitlements extends ChangeNotifier { + GarageEntitlements({ + required this.auth, + required this.projectSlug, + required this.apiBaseUrl, + KeyCache? cache, + this.mode = "live", + this.issuer = kGarageLicenceIssuer, + }) : _cache = cache ?? SecureKeyCache(); + + final GarageAuth auth; + final String projectSlug; + final String apiBaseUrl; + + /// Which side of the ledger you expect. A sandbox key must never satisfy a + /// live check, so this is checked rather than believed — set it to "sandbox" + /// while the project is, and back when it goes live. + final String mode; + + /// The `iss` a key has to carry. + final String issuer; + + final KeyCache _cache; + + /// sku -> verified key. The gate reads this. + final Map _keys = {}; + + /// Every verified key we currently hold, sku -> key. + Map get keys => Map.unmodifiable(_keys); + + Uri _u(String path, [Map? q]) { + final base = apiBaseUrl.endsWith("/") + ? apiBaseUrl.substring(0, apiBaseUrl.length - 1) + : apiBaseUrl; + return Uri.parse("$base$path").replace(queryParameters: q); + } + + // ------------------------------------------------------------------------- + // the gate + // ------------------------------------------------------------------------- + + /// The verified key for [sku], or null. This is the gate — it is synchronous + /// and it never touches the network. Call [cached] once at boot and [refresh] + /// when you have a connection. + GarageKey? key(String sku) { + final k = _keys[sku]; + if (k == null) return null; + // held keys are checked at load, but a long running app can sit past one. + if (k.isExpired) { + _keys.remove(sku); + return null; + } + return k; + } + + /// Do they own [sku] right now. + bool has(String sku) => key(sku) != null; + + // ------------------------------------------------------------------------- + // fetching + // ------------------------------------------------------------------------- + + /// Pull the whole key set for this project and REPLACE what we hold. + /// + /// Replace, not merge. A cancelled subscription simply stops coming back in + /// the response — merging would leave its key sat there working untill its + /// own exp, which is exactly the bug this shape exists to avoid. + /// + /// Every key is verified BEFORE anything is written. A response we cant fully + /// check leaves the previous set alone rather than half-applying it. + /// + /// [ttl] asks for a shorter key than the ceiling would give. Asking for more + /// is not an error, it is just quietly clamped. + Future> refresh({Duration? ttl}) async { + _requireSignedIn(); + + final query = {"project": projectSlug}; + if (ttl != null) query["ttl"] = "${ttl.inSeconds}"; + + final resp = await auth.client.get(_u("/v1/licences", query)); + if (resp.statusCode != 200) { + throw _errorFrom(resp, fallback: "Could not fetch keys."); + } + + final body = jsonDecode(resp.body) as Map; + final jwks = body["jwks"]; + if (jwks is! Map) { + throw EntitlementsError( + "No jwks in the response — the keys cant be checked.", + code: "no_jwks", + ); + } + final jwksDoc = Map.from(jwks); + + final sub = await _subject(); + + // verify the lot first. the envelope's sku is a convenience; the verified + // token is the only thing we key off. + final verified = {}; + final tokens = {}; + + for (final raw in (body["licences"] as List? ?? const [])) { + if (raw is! Map) continue; + final token = raw["licence"] as String?; + if (token == null || token.isEmpty) continue; + + final aud = peekAudience(token); + if (aud == null) { + throw EntitlementsError( + "A key came back with no usable audience.", + code: "bad_token", + ); + } + + final key = verifyKey( + token, + jwksDoc, + expectedIssuer: issuer, + expectedProject: projectSlug, + expectedSku: aud.sku, + expectedSub: sub, + expectedMode: mode, + ); + + verified[key.sku] = key; + tokens[key.sku] = token; + } + + // only now does anything change. + await _cache.write(projectSlug, CachedKeys(keys: tokens, jwks: jwksDoc)); + + _keys + ..clear() + ..addAll(verified); + notifyListeners(); + + return keys; + } + + /// Load and verify the cached set. Zero network — this is the boot path, and + /// the one that keeps working on a plane. + /// + /// A key that no longer verifies (expired, rotated away, signed for somebody + /// else) is dropped and logged rather than throwing, so one dead key doesnt + /// take the whole set with it. + Future> cached() async { + final blob = await _cache.read(projectSlug); + if (blob == null) { + _keys.clear(); + notifyListeners(); + return keys; + } + + final String sub; + try { + sub = await _subject(); + } catch (error, stack) { + // no profile to match against — offline and never signed in properly. + print("[garage_entitlements] cant resolve the subject: $error"); + print(stack); + _keys.clear(); + notifyListeners(); + return keys; + } + + final loaded = {}; + blob.keys.forEach((sku, token) { + try { + loaded[sku] = verifyKey( + token, + blob.jwks, + expectedIssuer: issuer, + expectedProject: projectSlug, + expectedSku: sku, + expectedSub: sub, + expectedMode: mode, + ); + } catch (error) { + // expected often enough (an expired key is the normal case) that a + // stack would be noise — but never silent. + print("[garage_entitlements] dropping cached key '$sku': $error"); + } + }); + + _keys + ..clear() + ..addAll(loaded); + notifyListeners(); + + return keys; + } + + /// The ledger, straight from the server. The truth, and a fallback for when + /// key signing is switched off on a deployment. + /// + /// Gates should ask [key] / [has] instead — this is a network call, it says + /// nothing offline, and it hands back the whole account's grants for the + /// project rather than the one thing you were asking about. Good for a + /// purchase screen ("you allready own this"), wrong for a feature check. + Future> entitlements() async { + _requireSignedIn(); + + final resp = await auth.client.get(_u("/v1/entitlements")); + if (resp.statusCode != 200) { + throw _errorFrom(resp, fallback: "Could not load entitlements."); + } + + final body = jsonDecode(resp.body) as Map; + return (body["entitlements"] as List? ?? const []) + .whereType() + .map((m) => GarageEntitlement.fromJson(Map.from(m))) + .toList(); + } + + /// One product by sku, off the public portal lookup. Null when there isnt + /// one. No bearer needed, though we send one if we have it so `owned` comes + /// back meaning something. + Future productBySku(String sku) async { + final uri = _u("/v1/portal/by-sku/$projectSlug/$sku"); + + final resp = auth.isSignedIn + ? await auth.client.get(uri) + : await http.get(uri); + + if (resp.statusCode == 404) return null; + if (resp.statusCode != 200) { + throw _errorFrom(resp, fallback: "Could not load that product."); + } + + final body = jsonDecode(resp.body) as Map; + final product = body["product"]; + if (product is! Map) return null; + return GarageProduct.fromJson(Map.from(product)); + } + + // ------------------------------------------------------------------------- + // buying + // ------------------------------------------------------------------------- + + /// The portal url for [productId], with a one-shot handoff code on it so the + /// buyer doesnt sign in twice. + /// + /// Returns a Uri — LAUNCHING it is yours. This package has no url_launcher + /// dependency and isnt going to grow one; you allready have a way to open a + /// url and it isnt this package's business what it is. + /// + /// [returnUrl] has to be registered on the project or checkout refuses it. + Future portalUrl( + String productId, { + String? returnUrl, + String? portalBaseUrl, + }) async { + _requireSignedIn(); + + final base = portalBaseUrl ?? kGaragePortalBaseUrl; + final trimmed = base.endsWith("/") + ? base.substring(0, base.length - 1) + : base; + + final query = {}; + if (returnUrl != null && returnUrl.isNotEmpty) { + query["return_url"] = returnUrl; + } + + final code = await _mintHandoffCode(); + if (code != null) query["handoff"] = code; + + return Uri.parse( + "$trimmed/pay/$productId", + ).replace(queryParameters: query.isEmpty ? null : query); + } + + /// One-shot code from auth, or null when we couldnt get one — the portal + /// still works then, the buyer just signs in when they land. + Future _mintHandoffCode() async { + try { + final resp = await http.post( + Uri.parse("${auth.issuer}/auth/handoff/issue"), + headers: {"Authorization": "Bearer ${auth.accessToken}"}, + ); + if (resp.statusCode != 200) { + print( + "[garage_entitlements] handoff issue refused " + "(${resp.statusCode}): ${resp.body}", + ); + return null; + } + final json = jsonDecode(resp.body) as Map; + return json["handoff_code"] as String?; + } catch (error, stack) { + print("[garage_entitlements] couldnt mint a handoff code: $error"); + print(stack); + return null; + } + } + + // ------------------------------------------------------------------------- + + /// Throw the cached set away. Sign-out should call this — the keys are for + /// whoever was signed in, and the sub check would reject them anyway, but + /// leaving them on disk is untidy. + Future clear() async { + await _cache.clear(projectSlug); + _keys.clear(); + notifyListeners(); + } + + // resolve the user id a key has to be for. profile() round trips userinfo, + // which is fine — its behind the authed client and only wanted at load time. + String? _cachedSub; + Future _subject() async { + if (_cachedSub != null) return _cachedSub!; + final me = await auth.profile(); + final sub = me?["sub"] as String?; + if (sub == null || sub.isEmpty) { + throw EntitlementsError( + "No subject in the profile — cant match a key to a user.", + code: "no_subject", + ); + } + _cachedSub = sub; + return sub; + } + + void _requireSignedIn() { + if (!auth.isSignedIn) { + throw EntitlementsError( + "Not signed in — call auth.signIn() first.", + code: "not_signed_in", + ); + } + } + + EntitlementsError _errorFrom(http.Response resp, {required String fallback}) { + try { + final b = jsonDecode(resp.body) as Map; + final code = b["error"] as String?; + final message = b["message"] as String? ?? fallback; + return EntitlementsError(message, code: code); + } catch (error) { + // a non-json body (a gateway page, usually) — still worth saying. + print( + "[garage_entitlements] ${resp.statusCode} with an unreadable body: " + "$error", + ); + return EntitlementsError("$fallback (${resp.statusCode})"); + } + } +} diff --git a/garage_entitlements/lib/src/jwks_verify.dart b/garage_entitlements/lib/src/jwks_verify.dart new file mode 100644 index 0000000..ed53f9b --- /dev/null +++ b/garage_entitlements/lib/src/jwks_verify.dart @@ -0,0 +1,211 @@ +import "dart:convert"; +import "dart:typed_data"; + +import "package:pointycastle/export.dart"; + +import "models.dart"; + +// Offline key verification. The store signs a key RS256 and publishes the +// matching public half as a JWKS; we only ever hold that half, so we can check +// a key but never mint one. +// +// No jwt library here on purpose. A JWKS RSA verify is "split the compact +// token, rebuild the public key from n/e, check PKCS1v15 SHA-256 over +// header.payload", and pointycastle — the same stack the backend signs with — +// does exactly that. + +/// Verify [token] against [jwks] and the caller's expectations. +/// +/// The order is fixed and it matters: signature, then `iss`, then `aud`, then +/// `sub`, then `mode`, then `exp`. Anything failing means not entitled — every +/// failure is an [EntitlementsError] so a caller can treat them alike. +/// +/// `aud` is `"/"`, which is what lets a lock check a key knowing +/// only its own sku and the public key. +GarageKey verifyKey( + String token, + Map jwks, { + required String expectedIssuer, + required String expectedProject, + required String expectedSku, + required String expectedSub, + required String expectedMode, +}) { + final parts = token.split("."); + if (parts.length != 3) { + throw EntitlementsError("Malformed key token.", code: "bad_token"); + } + + final header = _decodeJsonSegment(parts[0]); + final payload = _decodeJsonSegment(parts[1]); + + final alg = header["alg"] as String?; + if (alg != "RS256") { + throw EntitlementsError("Unexpected key alg: $alg", code: "bad_alg"); + } + + // 1. signature. + final kid = header["kid"] as String?; + final key = _findKey(jwks, kid); + if (key == null) { + // rotation: the kid isnt in the jwks we hold. a fresh refresh pulls the new + // jwks down with the keys, so this sorts itself out next time we're online. + throw EntitlementsError("No JWKS key for kid '$kid'.", + code: "kid_not_found"); + } + + final signingInput = utf8.encode("${parts[0]}.${parts[1]}"); + final signature = _b64UrlBytes(parts[2]); + if (!_verifyRs256(key, Uint8List.fromList(signingInput), signature)) { + throw EntitlementsError("Key signature failed.", code: "bad_signature"); + } + + // 2. iss. + final iss = payload["iss"] as String?; + if (iss == null || iss != expectedIssuer) { + throw EntitlementsError("Key issuer mismatch.", code: "iss_mismatch"); + } + + // 3. aud — one string, "/". + final aud = payload["aud"]; + final wanted = "$expectedProject/$expectedSku"; + if (aud is! String || aud != wanted) { + throw EntitlementsError("Key audience mismatch.", code: "aud_mismatch"); + } + + // 4. sub. + final sub = payload["sub"] as String?; + if (sub == null || sub != expectedSub) { + throw EntitlementsError("Key subject mismatch.", code: "sub_mismatch"); + } + + // 5. mode. a sandbox key must never satisfy a live check. + final mode = payload["mode"] as String?; + if (mode == null || mode != expectedMode) { + throw EntitlementsError("Key mode mismatch.", code: "mode_mismatch"); + } + + final iat = _epoch(payload["iat"]); + final exp = _epoch(payload["exp"]); + if (exp == null) { + throw EntitlementsError("Key has no exp.", code: "no_exp"); + } + + final rawEntExpiry = payload["expires_at"] as String?; + + final verified = GarageKey( + subject: sub, + issuer: iss, + project: payload["project"] as String? ?? expectedProject, + sku: payload["sku"] as String? ?? expectedSku, + kind: payload["kind"] as String? ?? "one_off", + mode: mode, + issuedAt: iat ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + expiresAt: exp, + entitlementExpiresAt: (rawEntExpiry == null || rawEntExpiry.isEmpty) + ? null + : DateTime.tryParse(rawEntExpiry)?.toUtc(), + ); + + // 6. exp. trusts the device clock when offline, which is an accepted + // limitation — the alternative is refusing to work on a plane. + if (verified.isExpired) { + throw EntitlementsError("Key expired.", code: "expired"); + } + + return verified; +} + +/// The `aud` of a compact token without verifying anything — used to work out +/// which sku a cached key belongs to before we know what to check it against. +/// Never trust what comes out of here; it is a routing hint, nothing more. +({String project, String sku})? peekAudience(String token) { + try { + final parts = token.split("."); + if (parts.length != 3) return null; + final payload = _decodeJsonSegment(parts[1]); + final aud = payload["aud"]; + if (aud is! String) return null; + final slash = aud.indexOf("/"); + if (slash <= 0 || slash == aud.length - 1) return null; + return (project: aud.substring(0, slash), sku: aud.substring(slash + 1)); + } catch (error, stack) { + print("[garage_entitlements] couldnt peek at a token's aud: $error"); + print(stack); + return null; + } +} + +// ---- key lookup ---- + +// pull the RSA public key for a kid out of the jwks. a header with no kid and +// exactly one key in the doc is fine — take that one. +RSAPublicKey? _findKey(Map jwks, String? kid) { + final keys = jwks["keys"]; + if (keys is! List || keys.isEmpty) return null; + + Map? match; + for (final k in keys) { + if (k is! Map) continue; + final m = Map.from(k); + if (m["kty"] != "RSA") continue; + if (kid == null || m["kid"] == kid) { + match = m; + break; + } + } + + if (match == null) return null; + + final nB = match["n"] as String?; + final eB = match["e"] as String?; + if (nB == null || eB == null) return null; + + return RSAPublicKey( + _bytesToBigInt(_b64UrlBytes(nB)), + _bytesToBigInt(_b64UrlBytes(eB)), + ); +} + +bool _verifyRs256(RSAPublicKey key, Uint8List input, Uint8List sig) { + final verifier = Signer("SHA-256/RSA") as RSASigner; + verifier.init(false, PublicKeyParameter(key)); + try { + return verifier.verifySignature(input, RSASignature(sig)); + } catch (error, stack) { + // a mangled signature throws rather than coming back false. + print("[garage_entitlements] key verify threw: $error"); + print(stack); + return false; + } +} + +// ---- small codec helpers ---- + +Map _decodeJsonSegment(String seg) { + final bytes = _b64UrlBytes(seg); + return jsonDecode(utf8.decode(bytes)) as Map; +} + +Uint8List _b64UrlBytes(String s) { + // jwt segments are base64url with the padding stripped — put it back. + var out = s.replaceAll("-", "+").replaceAll("_", "/"); + final pad = out.length % 4; + if (pad > 0) out = out.padRight(out.length + (4 - pad), "="); + return base64.decode(out); +} + +BigInt _bytesToBigInt(List bytes) { + var r = BigInt.zero; + for (final b in bytes) { + r = (r << 8) | BigInt.from(b & 0xff); + } + return r; +} + +DateTime? _epoch(Object? v) { + if (v == null) return null; + final n = v is int ? v : int.tryParse("$v"); + if (n == null) return null; + return DateTime.fromMillisecondsSinceEpoch(n * 1000, isUtc: true); +} diff --git a/garage_entitlements/lib/src/key_cache.dart b/garage_entitlements/lib/src/key_cache.dart new file mode 100644 index 0000000..098b8bd --- /dev/null +++ b/garage_entitlements/lib/src/key_cache.dart @@ -0,0 +1,96 @@ +import "dart:convert"; + +import "package:flutter_secure_storage/flutter_secure_storage.dart"; + +// Where the keys live between runs. ONE blob per project holding the JWKS and +// the whole key set, written together — there is deliberately no "keys but no +// jwks" state, because that is a set of tokens you cannot check. +// +// Same secure storage garage_auth keeps the tokens in. A key is low value (it +// is public-key verifiable and short lived), but keeping it beside the tokens +// means one place to look for sdk state. + +/// The seam, so tests and odd platforms can swap the backend out. +abstract class KeyCache { + Future read(String projectSlug); + Future write(String projectSlug, CachedKeys value); + Future clear(String projectSlug); +} + +/// The whole cached set for one project. [keys] is sku -> compact JWT. +class CachedKeys { + CachedKeys({required this.keys, required this.jwks}); + + final Map keys; + + /// the jwks doc as fetched ( {"keys":[...]} ) + final Map jwks; + + Map toJson() => {"keys": keys, "jwks": jwks}; + + factory CachedKeys.fromJson(Map j) => CachedKeys( + keys: Map.from(j["keys"] as Map), + jwks: Map.from(j["jwks"] as Map), + ); +} + +class SecureKeyCache implements KeyCache { + SecureKeyCache({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + final FlutterSecureStorage _storage; + + String _k(String slug) => "ge.keys.$slug"; + + @override + Future read(String projectSlug) async { + try { + final raw = await _storage.read(key: _k(projectSlug)); + if (raw == null || raw.isEmpty) return null; + return CachedKeys.fromJson(jsonDecode(raw) as Map); + } catch (error, stack) { + // a cache we cant read is the same as no cache — say so and carry on. + print("[garage_entitlements] key cache read failed: $error"); + print(stack); + return null; + } + } + + @override + Future write(String projectSlug, CachedKeys value) async { + try { + await _storage.write( + key: _k(projectSlug), + value: jsonEncode(value.toJson()), + ); + } catch (error, stack) { + print("[garage_entitlements] key cache write failed: $error"); + print(stack); + } + } + + @override + Future clear(String projectSlug) async { + try { + await _storage.delete(key: _k(projectSlug)); + } catch (error, stack) { + print("[garage_entitlements] key cache clear failed: $error"); + print(stack); + } + } +} + +/// In memory. For tests, or anywhere you explicitly dont want persistence. +class MemoryKeyCache implements KeyCache { + final Map _m = {}; + + @override + Future read(String projectSlug) async => _m[projectSlug]; + + @override + Future write(String projectSlug, CachedKeys value) async => + _m[projectSlug] = value; + + @override + Future clear(String projectSlug) async => _m.remove(projectSlug); +} diff --git a/garage_entitlements/lib/src/models.dart b/garage_entitlements/lib/src/models.dart new file mode 100644 index 0000000..712db38 --- /dev/null +++ b/garage_entitlements/lib/src/models.dart @@ -0,0 +1,174 @@ +// The shapes this package deals in. A key, an entitlement row, and just enough +// of a product to put a price on a buy button. + +/// A verified licence key. One entitlement, one key — the key unlocks the thing +/// that was bought and says nothing about the rest of the account. +/// +/// Two clocks live on here and they are not the same thing: +/// +/// * [expiresAt] is the KEY's expiry. It is the access decision. The server +/// allready clamped it against everything below, so checking it alone is +/// correct. +/// * [entitlementExpiresAt] is when the SUBSCRIPTION runs out, if it ever +/// does. Informational — the thing a UI says out loud ("renews on the 3rd"). +class GarageKey { + GarageKey({ + required this.subject, + required this.issuer, + required this.project, + required this.sku, + required this.kind, + required this.mode, + required this.issuedAt, + required this.expiresAt, + this.entitlementExpiresAt, + }); + + /// the user id (`sub`) + final String subject; + + final String issuer; + final String project; + final String sku; + + /// "one_off" or "subscription" + final String kind; + + /// "live" or "sandbox". A sandbox key never satisfies a live check. + final String mode; + + final DateTime issuedAt; + + /// The key's own expiry (`exp`). THIS is the gate. + final DateTime expiresAt; + + /// The entitlement's expiry (`expires_at`), when it has one. Null for + /// something owned outright. Never the gate — for showing a date. + final DateTime? entitlementExpiresAt; + + bool get isSubscription => kind == "subscription"; + + bool get isExpired => DateTime.now().toUtc().isAfter(expiresAt); + + /// How long this key has left. Negative once it has gone. + Duration get timeLeft => expiresAt.difference(DateTime.now().toUtc()); +} + +/// A row from the entitlement ledger. The truth, when you are online. +class GarageEntitlement { + GarageEntitlement({ + required this.productId, + required this.kind, + required this.status, + required this.active, + this.projectId, + this.source, + this.expiresAt, + this.graceUntil, + }); + + final String productId; + final String? projectId; + final String kind; + + /// the raw provider state — 'active' | 'trialing' | 'past_due' | 'expired' | + /// 'refunded' | 'revoked'. Read [active] instead, unless you are showing + /// somebody why. + final String status; + + final String? source; + + /// null = owned outright. + final DateTime? expiresAt; + + /// set while a renewal is bouncing; access holds untill it passes. + final DateTime? graceUntil; + + /// the server's own verdict — status, expiry and grace allready folded in. + final bool active; + + factory GarageEntitlement.fromJson(Map j) { + final exp = j["expires_at"] as String?; + final grace = j["grace_until"] as String?; + return GarageEntitlement( + productId: j["product_id"] as String? ?? "", + projectId: j["project_id"] as String?, + kind: j["kind"] as String? ?? "app", + status: j["status"] as String? ?? "expired", + source: j["source"] as String?, + expiresAt: (exp == null || exp.isEmpty) ? null : DateTime.tryParse(exp), + graceUntil: + (grace == null || grace.isEmpty) ? null : DateTime.tryParse(grace), + active: j["active"] == true, + ); + } +} + +/// Enough of a product to build a purchase screen. Comes off the public +/// by-sku lookup, so there is no bearer needed and nothing secret in it. +class GarageProduct { + GarageProduct({ + required this.id, + required this.sku, + required this.name, + required this.kind, + required this.priceMinor, + required this.currency, + this.description, + this.interval, + this.trialDays, + this.active = true, + }); + + /// a uuid. Treat it as opaque, dont parse it + final String id; + final String sku; + final String name; + final String? description; + + /// 'app' or 'subscription' — the catalogue's own wording, which is not quite + /// the key's `kind`. A key says "one_off"; the catalogue still says "app". + final String kind; + + final int priceMinor; + final String currency; + + /// 'month' | 'year' | null + final String? interval; + + final int? trialDays; + final bool active; + + bool get isSubscription => kind == "subscription"; + bool get isFree => priceMinor <= 0; + + factory GarageProduct.fromJson(Map j) => GarageProduct( + id: j["id"] as String? ?? "", + sku: j["sku"] as String? ?? "", + name: j["name"] as String? ?? "", + description: j["description"] as String?, + kind: j["kind"] as String? ?? "app", + priceMinor: (j["price_minor"] as num?)?.toInt() ?? 0, + currency: j["currency"] as String? ?? "gbp", + interval: j["interval"] as String?, + trialDays: (j["trial_days"] as num?)?.toInt(), + active: j["active"] == true || j["active"] == 1, + ); + + /// a rough display price — no FX, no locale. Your UI does the pretty bit. + String get displayPrice { + if (isFree) return "Free"; + return "${currency.toUpperCase()} ${(priceMinor / 100.0).toStringAsFixed(2)}"; + } +} + +/// Everything this package throws. Network errors from the authed client come +/// up as themselves. +class EntitlementsError implements Exception { + EntitlementsError(this.message, {this.code}); + final String message; + final String? code; + + @override + String toString() => code == null ? message : "$message ($code)"; +} diff --git a/garage_entitlements/pubspec.yaml b/garage_entitlements/pubspec.yaml new file mode 100644 index 0000000..7674b2a --- /dev/null +++ b/garage_entitlements/pubspec.yaml @@ -0,0 +1,36 @@ +name: garage_entitlements +description: "Offline licence keys for Garage apps — fetch a key per entitlement, verify it against the published JWKS, and gate features with no network." +version: 0.1.0 +publish_to: 'none' + +environment: + sdk: ^3.5.0 + flutter: ">=3.5.0" + +dependencies: + flutter: + sdk: flutter + + garage_auth: + path: ../garage_auth + + http: ^1.5.0 + + # verify reuses the backend's rsa/asn.1 stack, so a key is checked on the + # client the exact way it was signed on the store. + pointycastle: ^4.0.0 + + # keys + jwks live next to the tokens garage_auth allready keeps there. + flutter_secure_storage: ">=9.2.2 <12.0.0" + + # deliberately NOT here: flutter_stripe and url_launcher. this package has to + # build clean on desktop, and neither of them is any of its business — + # portalUrl hands you a Uri and you launch it however you allready do. + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^6.0.0 + +flutter: diff --git a/garage_entitlements/test/jwks_verify_test.dart b/garage_entitlements/test/jwks_verify_test.dart new file mode 100644 index 0000000..4464feb --- /dev/null +++ b/garage_entitlements/test/jwks_verify_test.dart @@ -0,0 +1,187 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:garage_entitlements/src/jwks_verify.dart"; +import "package:garage_entitlements/src/models.dart"; +import "package:pointycastle/export.dart"; + +import "keys.dart"; + +void main() { + late RSAPublicKey pub; + late RSAPrivateKey priv; + late Map jwks; + + setUpAll(() { + final pair = genKey(1); + pub = pair.publicKey as RSAPublicKey; + priv = pair.privateKey as RSAPrivateKey; + jwks = jwksOf(pub); + }); + + GarageKey verify(String token, { + Map? doc, + String iss = "https://pay.imbenji.net", + String project = "field-notes", + String sku = "pro", + String sub = "user-123", + String mode = "live", + }) => + verifyKey( + token, + doc ?? jwks, + expectedIssuer: iss, + expectedProject: project, + expectedSku: sku, + expectedSub: sub, + expectedMode: mode, + ); + + String? codeOf(Object? e) => e is EntitlementsError ? e.code : null; + + test("verifies a signed key and reads every claim", () { + final ends = "2027-03-01T00:00:00.000Z"; + final key = verify( + keyToken( + priv, + kind: "subscription", + entitlementExpiresAt: ends, + ), + ); + + expect(key.subject, "user-123"); + expect(key.issuer, "https://pay.imbenji.net"); + expect(key.project, "field-notes"); + expect(key.sku, "pro"); + expect(key.kind, "subscription"); + expect(key.mode, "live"); + expect(key.isSubscription, isTrue); + expect(key.isExpired, isFalse); + + // the two clocks are separate things and both survive the round trip + expect(key.entitlementExpiresAt, DateTime.parse(ends).toUtc()); + expect(key.expiresAt.isBefore(DateTime.parse(ends)), isTrue); + }); + + test("a one-off owned outright carries no entitlement expiry", () { + final key = verify(keyToken(priv)); + expect(key.kind, "one_off"); + expect(key.entitlementExpiresAt, isNull); + }); + + test("a tampered signature is refused", () { + final good = keyToken(priv); + final tampered = "${good.substring(0, good.length - 4)}AAAA"; + expect( + () => verify(tampered), + throwsA(predicate((e) => codeOf(e) == "bad_signature")), + ); + }); + + test("a key signed by somebody elses key is refused", () { + final other = genKey(9); + final token = keyToken(other.privateKey as RSAPrivateKey); + expect( + () => verify(token), + throwsA(predicate((e) => codeOf(e) == "bad_signature")), + ); + }); + + test("iss mismatch", () { + final token = keyToken(priv, iss: "https://not-us.example"); + expect( + () => verify(token), + throwsA(predicate((e) => codeOf(e) == "iss_mismatch")), + ); + }); + + test("aud mismatch — right project, wrong sku", () { + // the exact thing aud exists to stop: a key for the cheap tier being + // handed to the lock on the expensive one. + final token = keyToken(priv, sku: "basic"); + expect( + () => verify(token, sku: "pro"), + throwsA(predicate((e) => codeOf(e) == "aud_mismatch")), + ); + }); + + test("aud mismatch — right sku, wrong project", () { + final token = keyToken(priv, project: "someone-else", sku: "pro"); + expect( + () => verify(token, project: "field-notes"), + throwsA(predicate((e) => codeOf(e) == "aud_mismatch")), + ); + }); + + test("aud that isnt project/sku at all", () { + final token = keyToken(priv, audOverride: "field-notes"); + expect( + () => verify(token), + throwsA(predicate((e) => codeOf(e) == "aud_mismatch")), + ); + }); + + test("sub mismatch — somebody elses key on this device", () { + final token = keyToken(priv, sub: "user-999"); + expect( + () => verify(token, sub: "user-123"), + throwsA(predicate((e) => codeOf(e) == "sub_mismatch")), + ); + }); + + test("mode mismatch — a sandbox key never satisfies a live check", () { + final token = keyToken(priv, mode: "sandbox"); + expect( + () => verify(token, mode: "live"), + throwsA(predicate((e) => codeOf(e) == "mode_mismatch")), + ); + + // and the other way, so nobody can force live data into a sandbox build + final live = keyToken(priv, mode: "live"); + expect( + () => verify(live, mode: "sandbox"), + throwsA(predicate((e) => codeOf(e) == "mode_mismatch")), + ); + }); + + test("an expired key is refused", () { + final token = keyToken(priv, life: const Duration(hours: -1)); + expect( + () => verify(token), + throwsA(predicate((e) => codeOf(e) == "expired")), + ); + }); + + test("an unknown kid is refused rather than guessed at", () { + final token = keyToken(priv, kid: "rotated-away"); + expect( + () => verify(token), + throwsA(predicate((e) => codeOf(e) == "kid_not_found")), + ); + }); + + test("rotation: the jwks carrying both keys still verifies the old one", () { + final next = genKey(4); + final rotated = { + "keys": [ + ...(jwksOf(next.publicKey as RSAPublicKey, kid: "k2")["keys"] as List), + ...(jwks["keys"] as List), + ], + }; + final old = keyToken(priv, kid: "k1"); + expect(verify(old, doc: rotated).sku, "pro"); + }); + + test("a malformed token is refused, not thrown past", () { + expect( + () => verify("not.a.jwt.at.all"), + throwsA(isA()), + ); + expect(() => verify("rubbish"), throwsA(isA())); + }); + + test("peekAudience splits project and sku without verifying", () { + final aud = peekAudience(keyToken(priv, project: "p", sku: "s")); + expect(aud?.project, "p"); + expect(aud?.sku, "s"); + expect(peekAudience("rubbish"), isNull); + }); +} diff --git a/garage_entitlements/test/keys.dart b/garage_entitlements/test/keys.dart new file mode 100644 index 0000000..dc4718b --- /dev/null +++ b/garage_entitlements/test/keys.dart @@ -0,0 +1,96 @@ +import "dart:convert"; +import "dart:typed_data"; + +import "package:pointycastle/export.dart"; + +// A keypair + a signer, shared by the tests. Lifted from garage_iap's verify +// test — generating a real 2048 bit RSA key beats a fixture, since the whole +// point is that we verify the same way the backend signs. + +String b64uBig(BigInt n) { + final bytes = []; + var v = n; + while (v > BigInt.zero) { + bytes.insert(0, (v & BigInt.from(0xff)).toInt()); + v = v >> 8; + } + return base64Url.encode(Uint8List.fromList(bytes)).replaceAll("=", ""); +} + +String b64uStr(String s) => base64Url.encode(utf8.encode(s)).replaceAll("=", ""); + +String b64uBytes(List b) => base64Url.encode(b).replaceAll("=", ""); + +AsymmetricKeyPair genKey(int seed) { + final rng = SecureRandom("Fortuna") + ..seed( + KeyParameter(Uint8List.fromList(List.generate(32, (i) => (i + seed) & 0xff))), + ); + final gen = RSAKeyGenerator() + ..init( + ParametersWithRandom( + RSAKeyGeneratorParameters(BigInt.parse("65537"), 2048, 64), + rng, + ), + ); + return gen.generateKeyPair(); +} + +// build a compact RS256 JWT the same way the backend does — header.payload +// signed PKCS1v15 SHA-256. +String signJwt( + RSAPrivateKey priv, + Map header, + Map payload, +) { + final h = b64uStr(jsonEncode(header)); + final p = b64uStr(jsonEncode(payload)); + final input = utf8.encode("$h.$p"); + final signer = Signer("SHA-256/RSA") as RSASigner; + signer.init(true, PrivateKeyParameter(priv)); + final sig = signer.generateSignature(Uint8List.fromList(input)); + return "$h.$p.${b64uBytes(sig.bytes)}"; +} + +Map jwksOf(RSAPublicKey pub, {String kid = "k1"}) => { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": b64uBig(pub.modulus!), + "e": b64uBig(pub.exponent!), + }, + ], +}; + +/// A key the way the store mints them. Everything is overridable so a test can +/// break exactly one claim. +String keyToken( + RSAPrivateKey priv, { + String kid = "k1", + String sub = "user-123", + String iss = "https://pay.imbenji.net", + String project = "field-notes", + String sku = "pro", + String kind = "one_off", + String mode = "live", + String? entitlementExpiresAt, + Duration life = const Duration(hours: 1), + String? audOverride, +}) { + final now = DateTime.now().toUtc(); + return signJwt(priv, {"alg": "RS256", "kid": kid, "typ": "JWT"}, { + "sub": sub, + "iss": iss, + "aud": audOverride ?? "$project/$sku", + "project": project, + "sku": sku, + "kind": kind, + "mode": mode, + if (entitlementExpiresAt != null) "expires_at": entitlementExpiresAt, + "iat": now.millisecondsSinceEpoch ~/ 1000, + "exp": now.add(life).millisecondsSinceEpoch ~/ 1000, + }); +} diff --git a/garage_entitlements/test/refresh_test.dart b/garage_entitlements/test/refresh_test.dart new file mode 100644 index 0000000..48b421e --- /dev/null +++ b/garage_entitlements/test/refresh_test.dart @@ -0,0 +1,282 @@ +import "dart:convert"; + +import "package:flutter_test/flutter_test.dart"; +import "package:garage_auth/garage_auth.dart"; +import "package:garage_entitlements/garage_entitlements.dart"; +import "package:http/http.dart" as http; +import "package:http/testing.dart"; +import "package:pointycastle/export.dart"; + +import "keys.dart"; + +const _issuer = "https://hub.test/auth-api"; +const _api = "https://pay.test/api"; +const _project = "field-notes"; + +void main() { + late RSAPublicKey pub; + late RSAPrivateKey priv; + late Map jwks; + + // what the next /v1/licences call answers with, sku -> token. + late Map served; + + // set to a body to return instead, for the failure cases. + String? servedRaw; + + int licenceCalls = 0; + + setUpAll(() { + final pair = genKey(2); + pub = pair.publicKey as RSAPublicKey; + priv = pair.privateKey as RSAPrivateKey; + jwks = jwksOf(pub); + }); + + setUp(() { + served = {}; + servedRaw = null; + licenceCalls = 0; + }); + + Future signedInAuth() async { + final store = MemoryTokenStore(); + await store.write("ga.access.test-client", "oauth_fake"); + + final mock = MockClient((req) async { + final path = req.url.path; + + if (path.endsWith("/.well-known/openid-configuration")) { + return http.Response( + jsonEncode({ + "authorization_endpoint": "$_issuer/oauth/authorize", + "token_endpoint": "$_issuer/oauth/token", + "userinfo_endpoint": "$_issuer/oauth/userinfo", + }), + 200, + headers: {"content-type": "application/json"}, + ); + } + + if (path.endsWith("/oauth/userinfo")) { + return http.Response( + jsonEncode({"sub": "user-123"}), + 200, + headers: {"content-type": "application/json"}, + ); + } + + if (path.endsWith("/v1/licences")) { + licenceCalls++; + if (servedRaw != null) return http.Response(servedRaw!, 200); + return http.Response( + jsonEncode({ + "licences": [ + for (final e in served.entries) + {"sku": e.key, "licence": e.value, "expires_in": 3600}, + ], + "jwks": jwks, + }), + 200, + headers: {"content-type": "application/json"}, + ); + } + + return http.Response(jsonEncode({"error": "nope"}), 404); + }); + + final auth = GarageAuth( + issuer: _issuer, + clientId: "test-client", + redirectUri: "test://cb", + httpClient: mock, + tokenStore: store, + ); + await auth.restore(); + return auth; + } + + Future subject({KeyCache? cache}) async => GarageEntitlements( + auth: await signedInAuth(), + projectSlug: _project, + apiBaseUrl: _api, + cache: cache ?? MemoryKeyCache(), + ); + + String tokenFor(String sku, {Duration life = const Duration(hours: 1)}) => + keyToken(priv, project: _project, sku: sku, life: life); + + test("refresh verifies and holds every key it got", () async { + final ent = await subject(); + served = {"pro": tokenFor("pro"), "extras": tokenFor("extras")}; + + await ent.refresh(); + + expect(ent.has("pro"), isTrue); + expect(ent.has("extras"), isTrue); + expect(ent.has("never-bought"), isFalse); + expect(ent.key("pro")!.sku, "pro"); + }); + + // THE important one. a cancelled subscription stops coming back in the + // response; if a refresh merged, its key would sit there working untill its + // own exp — which could be a day. + test("refresh REPLACES the set, it does not merge", () async { + final cache = MemoryKeyCache(); + final ent = await subject(cache: cache); + + served = {"pro": tokenFor("pro"), "extras": tokenFor("extras")}; + await ent.refresh(); + expect(ent.has("extras"), isTrue); + + // they cancelled "extras". it simply isnt in the response any more. + served = {"pro": tokenFor("pro")}; + await ent.refresh(); + + expect(ent.has("pro"), isTrue); + expect(ent.has("extras"), isFalse, reason: "a dropped key must be evicted"); + + // and it is gone from disk too, not just from memory — otherwise the next + // cold boot would bring it back. + final blob = await cache.read(_project); + expect(blob!.keys.keys, ["pro"]); + }); + + test("everything gone means everything gone", () async { + final ent = await subject(); + served = {"pro": tokenFor("pro")}; + await ent.refresh(); + expect(ent.has("pro"), isTrue); + + served = {}; + await ent.refresh(); + expect(ent.keys, isEmpty); + }); + + test("a response with one bad key changes nothing", () async { + final cache = MemoryKeyCache(); + final ent = await subject(cache: cache); + + served = {"pro": tokenFor("pro")}; + await ent.refresh(); + + // second call carries a key signed by somebody else entirely + final rogue = genKey(11).privateKey as RSAPrivateKey; + served = { + "pro": tokenFor("pro"), + "extras": keyToken(rogue, project: _project, sku: "extras"), + }; + + await expectLater(ent.refresh(), throwsA(isA())); + + // the good set from before is untouched — no half applied refresh. + expect(ent.has("pro"), isTrue); + final blob = await cache.read(_project); + expect(blob!.keys.keys, ["pro"]); + }); + + test("no jwks in the response is refused", () async { + final ent = await subject(); + servedRaw = jsonEncode({"licences": []}); + await expectLater( + ent.refresh(), + throwsA(predicate((e) => e is EntitlementsError && e.code == "no_jwks")), + ); + }); + + test("cached() reads the blob back with no network at all", () async { + final cache = MemoryKeyCache(); + final first = await subject(cache: cache); + served = {"pro": tokenFor("pro")}; + await first.refresh(); + + final callsAfterRefresh = licenceCalls; + + final second = await subject(cache: cache); + await second.cached(); + + expect(second.has("pro"), isTrue); + // the second instance has its own mock, so this only proves the first one + // wasnt asked again — which is the bit that matters. + expect(licenceCalls, callsAfterRefresh); + }); + + test("cached() drops an expired key and keeps the rest", () async { + final cache = MemoryKeyCache(); + + // write a blob by hand: one live key, one that went stale on disk. + await cache.write( + _project, + CachedKeys( + keys: { + "pro": tokenFor("pro"), + "trial": tokenFor("trial", life: const Duration(hours: -1)), + }, + jwks: jwks, + ), + ); + + final ent = await subject(cache: cache); + await ent.cached(); + + expect(ent.has("pro"), isTrue); + expect(ent.has("trial"), isFalse); + }); + + test("cached() with nothing stored is simply empty", () async { + final ent = await subject(); + await ent.cached(); + expect(ent.keys, isEmpty); + expect(ent.has("pro"), isFalse); + }); + + test("clear() empties memory and disk", () async { + final cache = MemoryKeyCache(); + final ent = await subject(cache: cache); + served = {"pro": tokenFor("pro")}; + await ent.refresh(); + + await ent.clear(); + expect(ent.has("pro"), isFalse); + expect(await cache.read(_project), isNull); + }); + + test("it notifies, so a ListenableBuilder redraws the gates", () async { + final ent = await subject(); + var fired = 0; + ent.addListener(() => fired++); + + served = {"pro": tokenFor("pro")}; + await ent.refresh(); + expect(fired, 1); + + served = {}; + await ent.refresh(); + expect(fired, 2); + }); + + test("not signed in is an error, not an empty set", () async { + final auth = GarageAuth( + issuer: _issuer, + clientId: "test-client", + redirectUri: "test://cb", + httpClient: MockClient((_) async => http.Response("{}", 200)), + tokenStore: MemoryTokenStore(), + ); + await auth.restore(); + + final ent = GarageEntitlements( + auth: auth, + projectSlug: _project, + apiBaseUrl: _api, + cache: MemoryKeyCache(), + ); + + await expectLater( + ent.refresh(), + throwsA( + predicate((e) => e is EntitlementsError && e.code == "not_signed_in"), + ), + ); + }); +} diff --git a/garage_iap/LICENSE b/garage_iap/LICENSE new file mode 100644 index 0000000..72fab54 --- /dev/null +++ b/garage_iap/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 IMBENJI.NET LTD + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/garage_iap/example/lib/main.dart b/garage_iap/example/lib/main.dart new file mode 100644 index 0000000..f210fba --- /dev/null +++ b/garage_iap/example/lib/main.dart @@ -0,0 +1,142 @@ +import "package:flutter/material.dart"; +import "package:garage_auth/garage_auth.dart"; +import "package:garage_iap/garage_iap.dart"; + +// A tiny store screen: sign in, list products, buy one, show what you own. The +// real wiring (deep-link callback for completeSignIn etc.) is the host app's +// job — see garage_auth's README. This just shows the iap surface. + +late final GarageAuth auth; +late final GarageIap iap; + +void main() { + auth = GarageAuth( + issuer: "https://hub.imbenji.net/auth-api", + clientId: "example-app", + redirectUri: "exampleapp://auth/callback", + ); + + iap = GarageIap( + auth: auth, + appSlug: "example-app", + apiBaseUrl: "https://store.imbenji.net/api", + ); + + runApp(const ExampleApp()); +} + +class ExampleApp extends StatelessWidget { + const ExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: "garage_iap example", + theme: ThemeData(useMaterial3: true), + home: const StorePage(), + ); + } +} + +class StorePage extends StatefulWidget { + const StorePage({super.key}); + + @override + State createState() => _StorePageState(); +} + +class _StorePageState extends State { + List _products = []; + String _status = ""; + bool _busy = false; + + @override + void initState() { + super.initState(); + _boot(); + } + + Future _boot() async { + await auth.restore(); + + // populate the offline gate cheaply before any network — cachedLicence reads + // straight from secure storage and verifies locally. + try { + await iap.cachedLicence(); + } catch (e) { + // expired-offline etc. — fine, just means not entitled until we re-fetch. + print("cached licence not usable: $e"); + } + + if (auth.isSignedIn) { + await _load(); + } + if (mounted) setState(() {}); + } + + Future _load() async { + setState(() => _busy = true); + try { + _products = await iap.products(); + await iap.entitlements(); + await iap.licence(); // refresh + cache the offline licence + _status = "loaded"; + } catch (e) { + _status = "load failed: $e"; + } + if (mounted) setState(() => _busy = false); + } + + Future _buy(GarageProduct p) async { + setState(() => _busy = true); + try { + final outcome = await iap.purchase(p, mode: PurchaseMode.sheet); + _status = "purchase: ${outcome.name}"; + } on IapError catch (e) { + _status = "purchase error: ${e.message}"; + } catch (e) { + _status = "purchase failed: $e"; + } + if (mounted) setState(() => _busy = false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Store")), + body: !auth.isSignedIn + ? Center( + child: ElevatedButton( + onPressed: () => auth.signIn(), + child: const Text("Sign in with Garage"), + ), + ) + : Column( + children: [ + if (_busy) const LinearProgressIndicator(), + Padding( + padding: const EdgeInsets.all(12), + child: Text(_status), + ), + Expanded( + child: ListView( + children: [ + for (final p in _products) + ListTile( + title: Text(p.name), + subtitle: Text(p.displayPrice), + trailing: iap.has(p.sku) + ? const Chip(label: Text("Owned")) + : FilledButton( + onPressed: () => _buy(p), + child: const Text("Buy"), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/garage_iap/example/pubspec.yaml b/garage_iap/example/pubspec.yaml new file mode 100644 index 0000000..52f1313 --- /dev/null +++ b/garage_iap/example/pubspec.yaml @@ -0,0 +1,23 @@ +name: garage_iap_example +description: "Minimal example wiring garage_auth + garage_iap together." +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: ^3.5.0 + flutter: ">=3.5.0" + +dependencies: + flutter: + sdk: flutter + + garage_auth: + path: ../../garage_auth + garage_iap: + path: ../ + +dev_dependencies: + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/garage_iap/lib/garage_iap.dart b/garage_iap/lib/garage_iap.dart new file mode 100644 index 0000000..78cb4f3 --- /dev/null +++ b/garage_iap/lib/garage_iap.dart @@ -0,0 +1,25 @@ +// In-app purchasing for Garage apps — layered on garage_auth. +// +// Hand it a signed-in GarageAuth and the store API base url; it lists the app's +// products, runs both purchase modes (embedded Stripe sheet + browser handoff), +// and keeps a short-lived signed licence you can verify offline. +// +// final iap = GarageIap(auth: auth, appSlug: "my-app", +// apiBaseUrl: "https://store.imbenji.net/api"); +// final products = await iap.products(); +// await iap.purchase(products.first, mode: PurchaseMode.sheet); +// final pro = iap.has("pro"); +library; + +export "src/garage_iap.dart" show GarageIap; +export "src/models.dart" + show + GarageProduct, + GarageEntitlement, + GarageLicence, + LicenceProduct, + PurchaseMode, + PurchaseOutcome, + IapError; +export "src/licence_cache.dart" + show LicenceCache, SecureLicenceCache, MemoryLicenceCache, CachedLicence; diff --git a/garage_iap/lib/src/garage_iap.dart b/garage_iap/lib/src/garage_iap.dart new file mode 100644 index 0000000..64d21ff --- /dev/null +++ b/garage_iap/lib/src/garage_iap.dart @@ -0,0 +1,499 @@ +import "dart:async"; +import "dart:convert"; + +import "package:flutter/foundation.dart"; +import "package:garage_auth/garage_auth.dart"; +import "package:http/http.dart" as http; +import "package:url_launcher/url_launcher.dart"; + +import "jwks_verify.dart"; +import "licence_cache.dart"; +import "models.dart"; +import "sheet/sheet.dart"; + +// In-app purchasing for one app. Takes a signed-in GarageAuth and the store API +// base url, lists products, runs both purchase flows, and keeps an offline +// licence. It never touches sign-in — that's garage_auth's job; we just borrow +// its authed client so the bearer + refresh are handled for us. +// +// final iap = GarageIap( +// auth: auth, +// appSlug: "my-app", +// apiBaseUrl: "https://store.imbenji.net/api", +// ); +// final products = await iap.products(); +// await iap.purchase(products.first, mode: PurchaseMode.sheet); +// final pro = iap.has("pro"); +// +// it's a ChangeNotifier so UIs can rebuild when entitlements move. +/// Where the payment portal lives. Overridable per call, but this is the one +/// buyers actually get sent to. +const String kGaragePortalBaseUrl = String.fromEnvironment( + "GARAGE_PORTAL_BASE_URL", + defaultValue: "https://pay.imbenji.net", +); + +class GarageIap extends ChangeNotifier { + GarageIap({ + required this.auth, + required this.appSlug, + required this.apiBaseUrl, + this.merchantName = "Garage", + LicenceCache? licenceCache, + Duration pollTimeout = const Duration(minutes: 3), + }) : _cache = licenceCache ?? SecureLicenceCache(), + _pollTimeout = pollTimeout; + + final GarageAuth auth; + final String appSlug; + final String apiBaseUrl; + final String merchantName; + + final LicenceCache _cache; + final Duration _pollTimeout; + + // last known entitlements from an online fetch. has() reads this first. + List _entitlements = []; + List get cachedEntitlements => + List.unmodifiable(_entitlements); + + // product id -> sku, learned from products(). entitlements are keyed by id, so + // we need this to answer has(sku) off an online entitlement. + final Map _skuById = {}; + + // the last verified offline licence, if we've loaded one this session. + GarageLicence? _licence; + + Uri _u(String path, [Map? q]) { + final base = apiBaseUrl.endsWith("/") + ? apiBaseUrl.substring(0, apiBaseUrl.length - 1) + : apiBaseUrl; + return Uri.parse("$base$path").replace(queryParameters: q); + } + + // ------------------------------------------------------------------------- + // catalogue + // ------------------------------------------------------------------------- + + // this app's buyable products (paywall + IAP items). active only for buyers. + Future> products() async { + _requireSignedIn(); + + final resp = await auth.client.get(_u("/v1/apps/$appSlug/products")); + if (resp.statusCode != 200) { + throw IapError("Could not load products (${resp.statusCode}).", + code: "products_failed"); + } + + final body = jsonDecode(resp.body) as Map; + final list = (body["products"] as List? ?? []); + final out = list + .whereType() + .map((m) => GarageProduct.fromJson(Map.from(m))) + .toList(); + + for (final p in out) { + _skuById[p.id] = p.sku; + } + return out; + } + + // ------------------------------------------------------------------------- + // entitlements + // ------------------------------------------------------------------------- + + // the caller's grants across every app. caches the result so has() can answer + // synchronously afterwards. throws on network failure (offline -> use the + // licence path instead). + Future> entitlements() async { + _requireSignedIn(); + + final resp = await auth.client.get(_u("/v1/entitlements")); + if (resp.statusCode != 200) { + throw IapError("Could not load entitlements (${resp.statusCode}).", + code: "entitlements_failed"); + } + + final body = jsonDecode(resp.body) as Map; + final list = (body["entitlements"] as List? ?? []); + _entitlements = list + .whereType() + .map((m) => GarageEntitlement.fromJson(Map.from(m))) + .toList(); + + notifyListeners(); + return cachedEntitlements; + } + + // quick own/not-own for this app's paywall product. lighter than entitlements(). + Future owned() async { + _requireSignedIn(); + + final resp = await auth.client.get(_u("/v1/apps/$appSlug/entitlement")); + if (resp.statusCode != 200) { + throw IapError("Could not check entitlement (${resp.statusCode}).", + code: "entitlement_failed"); + } + final body = jsonDecode(resp.body) as Map; + return body["entitled"] == true; + } + + // ------------------------------------------------------------------------- + // purchase + // ------------------------------------------------------------------------- + + // buy [product]. sheet tries the embedded flutter_stripe sheet and falls back + // to the browser handoff (subscriptions, or platforms with no native sdk). + // handoff always uses the browser. either way we poll entitlements after the + // user-facing step — the webhook is the real source of truth, the redirect / + // sheet-close is just a nudge. + // ------------------------------------------------------------------------- + // the payment portal + // ------------------------------------------------------------------------- + + /// Send the buyer to the Garage Payment Portal for [productId]. + /// + /// The portal is a different origin, so it cant see this app's token. We mint + /// a one-shot handoff code off the signed-in session and hang it on the url — + /// the portal trades it for the session and the buyer never sees a login. + /// + /// [returnUrl] has to be registered on the project or checkout refuses it. + Future portalUrl( + String productId, { + String? returnUrl, + String? portalBaseUrl, + }) async { + _requireSignedIn(); + + final base = portalBaseUrl ?? kGaragePortalBaseUrl; + final trimmed = + base.endsWith("/") ? base.substring(0, base.length - 1) : base; + + final query = {}; + if (returnUrl != null && returnUrl.isNotEmpty) { + query["return_url"] = returnUrl; + } + + final code = await _mintHandoffCode(); + if (code != null) query["handoff"] = code; + + return Uri.parse( + "$trimmed/pay/$productId", + ).replace(queryParameters: query.isEmpty ? null : query); + } + + /// One-shot code from auth, or null when we couldnt get one — the portal + /// still works then, the buyer just has to sign in when they land. + Future _mintHandoffCode() async { + try { + final resp = await http.post( + Uri.parse("${auth.issuer}/auth/handoff/issue"), + headers: {"Authorization": "Bearer ${auth.accessToken}"}, + ); + if (resp.statusCode != 200) { + print( + "[garage_iap] handoff issue refused (${resp.statusCode}): ${resp.body}"); + return null; + } + final json = jsonDecode(resp.body) as Map; + return json["handoff_code"] as String?; + } catch (error, stack) { + print("[garage_iap] couldnt mint a handoff code: $error"); + print(stack); + return null; + } + } + + Future purchase( + GarageProduct product, { + PurchaseMode mode = PurchaseMode.sheet, + String? returnUrl, + }) async { + _requireSignedIn(); + + if (mode == PurchaseMode.handoff) { + return _handoff(product, returnUrl); + } + return _sheet(product, returnUrl); + } + + // browser handoff: POST /checkout, open the hosted url, poll until granted. + Future _handoff( + GarageProduct product, String? returnUrl) async { + final resp = await auth.client.post( + _u("/v1/checkout"), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "product_id": product.id, + if (returnUrl != null) "return_url": returnUrl, + }), + ); + + if (resp.statusCode != 200) { + throw _checkoutError(resp); + } + + final body = jsonDecode(resp.body) as Map; + final url = body["checkout_url"] as String?; + if (url == null || url.isEmpty) { + throw IapError("Checkout returned no url.", code: "no_checkout_url"); + } + + final opened = await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + if (!opened) { + throw IapError("Could not open the checkout page.", + code: "launch_failed"); + } + + return _pollForGrant(product); + } + + // embedded sheet: stripe-config -> payment-intent -> present. on a subscription + // (or no native sheet) the server / platform tells us to hand off instead. + Future _sheet( + GarageProduct product, String? returnUrl) async { + // grab the publishable key up front (also confirms config is reachable). + final cfg = await auth.client.get(_u("/v1/stripe-config")); + if (cfg.statusCode != 200) { + throw IapError("Could not load stripe config (${cfg.statusCode}).", + code: "stripe_config_failed"); + } + final pubKey = (jsonDecode(cfg.body) + as Map)["publishable_key"] as String?; + + final resp = await auth.client.post( + _u("/v1/payment-intent"), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "product_id": product.id, + if (returnUrl != null) "return_url": returnUrl, + }), + ); + + if (resp.statusCode != 200) { + throw _checkoutError(resp); + } + + final body = jsonDecode(resp.body) as Map; + + // subscription -> server handed back a hosted url. open it like a handoff. + if (body["fallback"] == "handoff") { + final url = body["checkout_url"] as String?; + if (url == null || url.isEmpty) { + throw IapError("Handoff fallback with no url.", + code: "no_checkout_url"); + } + final opened = + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + if (!opened) { + throw IapError("Could not open the checkout page.", + code: "launch_failed"); + } + return _pollForGrant(product); + } + + final clientSecret = body["client_secret"] as String?; + final publishable = (body["publishable_key"] as String?) ?? pubKey; + if (clientSecret == null || publishable == null) { + throw IapError("Payment intent missing client_secret.", + code: "no_client_secret"); + } + + final result = await presentSheet(SheetParams( + clientSecret: clientSecret, + publishableKey: publishable, + // third-party PI lives on the connected account. + stripeAccount: body["stripe_account"] as String?, + customer: body["customer"] as String?, + ephemeralKey: body["ephemeral_key"] as String?, + merchantName: merchantName, + )); + + switch (result) { + case SheetResult.unsupported: + // no native sheet here -> degrade to the browser handoff. + return _handoff(product, returnUrl); + case SheetResult.canceled: + return PurchaseOutcome.canceled; + case SheetResult.completed: + return _pollForGrant(product); + } + } + + // poll /entitlements until the product shows up active, with a backoff. the + // webhook can lag the redirect by a few seconds, hence the wait. returns + // pending (not an error) if the grant doesnt land before the timeout — it may + // still arrive, the caller can re-check later. + Future _pollForGrant(GarageProduct product) async { + final deadline = DateTime.now().add(_pollTimeout); + var wait = const Duration(seconds: 2); + + while (DateTime.now().isBefore(deadline)) { + try { + final ents = await entitlements(); + final granted = ents.any((e) => e.productId == product.id && e.active); + if (granted) return PurchaseOutcome.granted; + } catch (e) { + // a transient error mid-poll shouldnt kill the whole wait. + print("garage_iap poll error: $e"); + } + + await Future.delayed(wait); + // gentle backoff, capped so we still check reasonably often. + final next = wait.inMilliseconds * 2; + wait = Duration(milliseconds: next > 8000 ? 8000 : next); + } + + return PurchaseOutcome.pending; + } + + // ------------------------------------------------------------------------- + // offline licence + // ------------------------------------------------------------------------- + + // Returns a verified licence for this app. Online: fetches the licence JWT AND + // the JWKS in the same trip, caches both, verifies, returns it. Offline (the + // fetch throws): falls back to the cached licence and verifies it locally. + // + // No cached licence + offline => null (treated as not entitled until the first + // online fetch). Cached but past exp while offline => throws expired, also not + // entitled until we can re-fetch. + Future licence({bool forceRefresh = false}) async { + _requireSignedIn(); + + if (!forceRefresh) { + // try a fresh online fetch first; fall through to cache on any failure. + try { + return await _fetchAndCacheLicence(); + } catch (e) { + print("garage_iap licence online fetch failed, trying cache: $e"); + } + } else { + return _fetchAndCacheLicence(); + } + + return _loadCachedLicence(); + } + + // the cached, offline-verified licence — no network at all. handy for a fast + // boot-time gate before you've been back online. + Future cachedLicence() => _loadCachedLicence(); + + Future _fetchAndCacheLicence() async { + // licence + jwks in the same online trip, so offline always has the key. + final licResp = await auth.client.get(_u("/v1/licence", {"app": appSlug})); + if (licResp.statusCode != 200) { + throw IapError("Licence fetch failed (${licResp.statusCode}).", + code: "licence_failed"); + } + final licBody = jsonDecode(licResp.body) as Map; + final token = licBody["licence"] as String?; + if (token == null || token.isEmpty) { + throw IapError("No licence in response.", code: "no_licence"); + } + + // jwks is public, no auth needed — but the authed client works fine for it. + final jwksResp = await auth.client.get(_u("/v1/licence/jwks.json")); + if (jwksResp.statusCode != 200) { + throw IapError("JWKS fetch failed (${jwksResp.statusCode}).", + code: "jwks_failed"); + } + final jwks = jsonDecode(jwksResp.body) as Map; + + final sub = await _subject(); + final verified = + verifyLicence(token, jwks, expectedApp: appSlug, expectedSub: sub); + + // only cache once it verifies — never persist a bad licence. + await _cache.write(appSlug, CachedLicence(token: token, jwks: jwks)); + _licence = verified; + notifyListeners(); + return verified; + } + + Future _loadCachedLicence() async { + final cached = await _cache.read(appSlug); + if (cached == null) return null; + + final sub = await _subject(); + // throws on expiry / bad sig — let it propagate so the caller can tell + // "no licence" (null) from "expired offline" (throw). + final verified = verifyLicence(cached.token, cached.jwks, + expectedApp: appSlug, expectedSub: sub); + _licence = verified; + return verified; + } + + // resolve the user id we expect in the licence. profile() round-trips userinfo + // — fine, it's cached behind the authed client and only needed at fetch time. + String? _cachedSub; + Future _subject() async { + if (_cachedSub != null) return _cachedSub!; + final me = await auth.profile(); + final sub = me?["sub"] as String?; + if (sub == null || sub.isEmpty) { + throw IapError("No subject in profile — cant match the licence.", + code: "no_subject"); + } + _cachedSub = sub; + return sub; + } + + // ------------------------------------------------------------------------- + // has() — the one-liner the apps actually call + // ------------------------------------------------------------------------- + + // true if [sku] is owned. checks the last online entitlements first, then the + // in-memory verified licence. it's synchronous on purpose — call entitlements() + // or licence() to refresh, then has() to read. for a cold offline start, call + // cachedLicence() once to populate, then has(). + bool has(String sku) { + // online: an active entitlement whose product resolves to this sku. needs + // products() to have run so we know the id->sku map. + for (final e in _entitlements) { + if (!e.active) continue; + if (_skuById[e.productId] == sku) return true; + } + + // offline: the verified, unexpired licence vouches for the sku directly. + final lic = _licence; + if (lic != null && !lic.isExpired && lic.grants(sku)) { + return true; + } + + return false; + } + + // ------------------------------------------------------------------------- + + Future clearLicence() => _cache.clear(appSlug); + + void _requireSignedIn() { + if (!auth.isSignedIn) { + throw IapError("Not signed in — call auth.signIn() first.", + code: "not_signed_in"); + } + } + + IapError _checkoutError(http.Response resp) { + try { + final b = jsonDecode(resp.body) as Map; + final code = b["error"] as String?; + final msg = b["message"] as String? ?? "Checkout failed."; + // surface the meaningful ones the backend can return. + if (resp.statusCode == 451) { + return IapError(msg, code: code ?? "region_blocked"); + } + if (resp.statusCode == 409) { + return IapError(msg, code: code ?? "connect_not_ready"); + } + return IapError(msg, code: code); + } catch (_) { + return IapError("Checkout failed (${resp.statusCode}).", + code: "checkout_failed"); + } + } +} diff --git a/garage_iap/lib/src/jwks_verify.dart b/garage_iap/lib/src/jwks_verify.dart new file mode 100644 index 0000000..7438e4b --- /dev/null +++ b/garage_iap/lib/src/jwks_verify.dart @@ -0,0 +1,166 @@ +import "dart:convert"; +import "dart:typed_data"; + +import "package:pointycastle/export.dart"; + +import "models.dart"; + +// Offline licence verification. The store signs licences RS256 with its own key +// and publishes the matching public key as a JWKS — we only ever hold the public +// half, so we can verify a licence but never forge one. +// +// We deliberately do NOT pull in a heavy jwt lib here: a JWKS RSA verify is just +// "split the compact token, rebuild the public key from n/e, check the PKCS1v15 +// SHA-256 signature over header.payload". pointycastle (the same stack the +// backend signs with) gives us exactly that. + +// verifies `token` against `jwks`, then checks the licence is for `expectedApp` +// and `expectedSub` and isnt past exp. throws IapError on any failure so the +// caller can treat "not entitled" uniformly. +GarageLicence verifyLicence( + String token, + Map jwks, { + required String expectedApp, + required String expectedSub, +}) { + final parts = token.split("."); + if (parts.length != 3) { + throw IapError("Malformed licence token.", code: "bad_token"); + } + + final header = _decodeJsonSegment(parts[0]); + final payload = _decodeJsonSegment(parts[1]); + + final alg = header["alg"] as String?; + if (alg != "RS256") { + throw IapError("Unexpected licence alg: $alg", code: "bad_alg"); + } + + final kid = header["kid"] as String?; + final key = _findKey(jwks, kid); + if (key == null) { + // rotation: the licence's kid isnt in the cached jwks. a fresh online fetch + // refreshes the jwks, so this resolves itself next time we're online. + throw IapError("No JWKS key for kid '$kid'.", code: "kid_not_found"); + } + + final signingInput = utf8.encode("${parts[0]}.${parts[1]}"); + final signature = _b64UrlBytes(parts[2]); + + if (!_verifyRs256(key, Uint8List.fromList(signingInput), signature)) { + throw IapError("Licence signature failed.", code: "bad_signature"); + } + + // claims + final sub = payload["sub"] as String?; + final app = payload["app"] as String?; + if (sub == null || sub != expectedSub) { + throw IapError("Licence subject mismatch.", code: "sub_mismatch"); + } + if (app == null || app != expectedApp) { + throw IapError("Licence app mismatch.", code: "app_mismatch"); + } + + final iat = _epoch(payload["iat"]); + final exp = _epoch(payload["exp"]); + if (exp == null) { + throw IapError("Licence has no exp.", code: "no_exp"); + } + + final products = []; + final rawProducts = payload["products"]; + if (rawProducts is List) { + for (final p in rawProducts) { + if (p is Map) { + products.add(LicenceProduct.fromClaim(Map.from(p))); + } + } + } + + final licence = GarageLicence( + subject: sub, + app: app, + products: products, + issuedAt: iat ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), + expiresAt: exp, + ); + + // exp check trusts the device clock when offline — an accepted limitation. + if (licence.isExpired) { + throw IapError("Licence expired.", code: "expired"); + } + + return licence; +} + +// ---- key lookup ---- + +// pull the RSA public key out of the jwks for a given kid. if the licence header +// carried no kid and there's exactly one key, use that. +RSAPublicKey? _findKey(Map jwks, String? kid) { + final keys = jwks["keys"]; + if (keys is! List || keys.isEmpty) return null; + + Map? match; + for (final k in keys) { + if (k is! Map) continue; + final m = Map.from(k); + if (m["kty"] != "RSA") continue; + if (kid == null || m["kid"] == kid) { + match = m; + break; + } + } + + if (match == null) return null; + + final nB = match["n"] as String?; + final eB = match["e"] as String?; + if (nB == null || eB == null) return null; + + final n = _bytesToBigInt(_b64UrlBytes(nB)); + final e = _bytesToBigInt(_b64UrlBytes(eB)); + return RSAPublicKey(n, e); +} + +bool _verifyRs256(RSAPublicKey key, Uint8List input, Uint8List sig) { + final verifier = Signer("SHA-256/RSA") as RSASigner; + verifier.init(false, PublicKeyParameter(key)); + try { + return verifier.verifySignature(input, RSASignature(sig)); + } catch (e) { + // a malformed signature can throw rather than just returning false. + print("garage_iap licence verify threw: $e"); + return false; + } +} + +// ---- small codec helpers ---- + +Map _decodeJsonSegment(String seg) { + final bytes = _b64UrlBytes(seg); + return jsonDecode(utf8.decode(bytes)) as Map; +} + +Uint8List _b64UrlBytes(String s) { + // jwt segments are base64url with the padding stripped — put it back. + var out = s.replaceAll("-", "+").replaceAll("_", "/"); + final pad = out.length % 4; + if (pad > 0) out = out.padRight(out.length + (4 - pad), "="); + return base64.decode(out); +} + +BigInt _bytesToBigInt(List bytes) { + var r = BigInt.zero; + for (final b in bytes) { + r = (r << 8) | BigInt.from(b & 0xff); + } + return r; +} + +DateTime? _epoch(Object? v) { + if (v == null) return null; + final n = v is int ? v : int.tryParse("$v"); + if (n == null) return null; + return DateTime.fromMillisecondsSinceEpoch(n * 1000, isUtc: true); +} diff --git a/garage_iap/lib/src/licence_cache.dart b/garage_iap/lib/src/licence_cache.dart new file mode 100644 index 0000000..54d770a --- /dev/null +++ b/garage_iap/lib/src/licence_cache.dart @@ -0,0 +1,91 @@ +import "dart:convert"; + +import "package:flutter_secure_storage/flutter_secure_storage.dart"; + +// Where the offline licence lives. We cache BOTH the licence JWT and the JWKS +// public key in the same trip so offline verification has everything it needs — +// there's deliberately no "licence but no key" state. Keyed by app slug so two +// apps in the same process dont collide. +// +// Reuses secure storage (same backend garage_auth keeps tokens in) — a licence +// is low-value (public-key verifiable, short lived) but keeping it next to the +// tokens means one consistent place for sdk state. + +// the small seam, so tests / odd platforms can swap the backend out. +abstract class LicenceCache { + Future read(String appSlug); + Future write(String appSlug, CachedLicence value); + Future clear(String appSlug); +} + +class CachedLicence { + CachedLicence({required this.token, required this.jwks}); + + // the raw compact licence JWT + final String token; + + // the jwks doc as fetched ( {"keys":[...]} ) + final Map jwks; + + Map toJson() => {"token": token, "jwks": jwks}; + + factory CachedLicence.fromJson(Map j) => CachedLicence( + token: j["token"] as String, + jwks: Map.from(j["jwks"] as Map), + ); +} + +class SecureLicenceCache implements LicenceCache { + SecureLicenceCache({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + final FlutterSecureStorage _storage; + + String _key(String slug) => "gi.licence.$slug"; + + @override + Future read(String appSlug) async { + try { + final raw = await _storage.read(key: _key(appSlug)); + if (raw == null || raw.isEmpty) return null; + return CachedLicence.fromJson(jsonDecode(raw) as Map); + } catch (e) { + print("garage_iap licence cache read failed: $e"); + return null; + } + } + + @override + Future write(String appSlug, CachedLicence value) async { + try { + await _storage.write( + key: _key(appSlug), value: jsonEncode(value.toJson())); + } catch (e) { + print("garage_iap licence cache write failed: $e"); + } + } + + @override + Future clear(String appSlug) async { + try { + await _storage.delete(key: _key(appSlug)); + } catch (e) { + print("garage_iap licence cache clear failed: $e"); + } + } +} + +// in-memory, handy for tests or where you explicitly dont want persistence. +class MemoryLicenceCache implements LicenceCache { + final Map _m = {}; + + @override + Future read(String appSlug) async => _m[appSlug]; + + @override + Future write(String appSlug, CachedLicence value) async => + _m[appSlug] = value; + + @override + Future clear(String appSlug) async => _m.remove(appSlug); +} diff --git a/garage_iap/lib/src/models.dart b/garage_iap/lib/src/models.dart new file mode 100644 index 0000000..f1c98c9 --- /dev/null +++ b/garage_iap/lib/src/models.dart @@ -0,0 +1,162 @@ +// The buyer-facing data shapes. These mirror the store API json (see +// `productJson` / `entitlementJson` in the backend) but only carry what a +// client actually wants — no created_at/updated_at churn, no raw provider blobs. + +// how a purchase is taken. sheet = our embedded flutter_stripe sheet; handoff = +// the system browser to hosted checkout. sheet quietly degrades to handoff for +// subscriptions and on platforms with no native stripe sdk. +enum PurchaseMode { sheet, handoff } + +// where a purchase ended up. granted means the entitlement landed (the webhook +// fired and we saw it on poll). pending means we finished the user-facing bit +// but the grant hadnt shown up before we gave up waiting — it may still arrive. +enum PurchaseOutcome { granted, pending, canceled } + +class GarageProduct { + GarageProduct({ + required this.id, + required this.sku, + required this.name, + required this.kind, + required this.priceMinor, + required this.currency, + this.description, + this.providerPriceId, + this.trialDays, + this.active = true, + }); + + final String id; + final String sku; + final String name; + final String? description; + + // 'app' | 'subscription'. consumables are shelved server side. + final String kind; + + final String? providerPriceId; + + // price in the currency's minor unit (pence, cents...). + final int priceMinor; + final String currency; + + final int? trialDays; + final bool active; + + bool get isSubscription => kind == "subscription"; + bool get isFree => priceMinor <= 0; + bool get hasTrial => (trialDays ?? 0) > 0; + + factory GarageProduct.fromJson(Map j) { + return GarageProduct( + id: j["id"] as String, + sku: j["sku"] as String, + name: j["name"] as String, + description: j["description"] as String?, + kind: j["kind"] as String? ?? "app", + providerPriceId: j["provider_price_id"] as String?, + priceMinor: (j["price_minor"] as num?)?.toInt() ?? 0, + currency: (j["currency"] as String? ?? "usd"), + trialDays: (j["trial_days"] as num?)?.toInt(), + active: j["active"] == true || j["active"] == 1, + ); + } + + // a rough display price. no FX, no locale — the storefront does the pretty + // formatting, this is just a sane default for quick UIs. + String get displayPrice { + if (isFree) return "Free"; + final major = priceMinor / 100.0; + return "${currency.toUpperCase()} ${major.toStringAsFixed(2)}"; + } +} + +class GarageEntitlement { + GarageEntitlement({ + required this.productId, + required this.kind, + required this.status, + required this.active, + this.appId, + this.source, + this.expiresAt, + }); + + final String? appId; + final String productId; + final String kind; + + // 'active' | 'trialing' | 'expired' | 'refunded' | 'revoked' + final String status; + final String? source; + + // null = perpetual. + final DateTime? expiresAt; + + // the server's own verdict (status + expiry) — we trust it rather than + // recomputing the rule on the client. + final bool active; + + factory GarageEntitlement.fromJson(Map j) { + final exp = j["expires_at"] as String?; + return GarageEntitlement( + appId: j["app_id"] as String?, + productId: j["product_id"] as String? ?? "", + kind: j["kind"] as String? ?? "app", + status: j["status"] as String? ?? "expired", + source: j["source"] as String?, + expiresAt: exp == null || exp.isEmpty ? null : DateTime.tryParse(exp), + active: j["active"] == true, + ); + } +} + +// a verified, decoded licence. the products list is the sku/kind set the licence +// vouches for; `has` reads off this when offline. +class GarageLicence { + GarageLicence({ + required this.subject, + required this.app, + required this.products, + required this.issuedAt, + required this.expiresAt, + }); + + final String subject; // the user id (`sub`) + final String app; // the app slug + final List products; + final DateTime issuedAt; + final DateTime expiresAt; + + bool get isExpired => DateTime.now().toUtc().isAfter(expiresAt); + + bool grants(String sku) => products.any((p) => p.sku == sku); +} + +class LicenceProduct { + LicenceProduct({required this.sku, required this.kind, this.expiresAt}); + + final String sku; + final String kind; + final DateTime? expiresAt; + + factory LicenceProduct.fromClaim(Map j) { + final exp = j["expires_at"] as String?; + return LicenceProduct( + sku: j["sku"] as String? ?? "", + kind: j["kind"] as String? ?? "app", + expiresAt: exp == null || exp.isEmpty ? null : DateTime.tryParse(exp), + ); + } +} + +// thrown for the iap-specific failures. network errors from the authed client +// bubble up as-is. +class IapError implements Exception { + IapError(this.message, {this.code}); + final String message; + final String? code; + + @override + String toString() => code == null ? message : "$message ($code)"; +} diff --git a/garage_iap/lib/src/sheet/sheet.dart b/garage_iap/lib/src/sheet/sheet.dart new file mode 100644 index 0000000..c6ec4fb --- /dev/null +++ b/garage_iap/lib/src/sheet/sheet.dart @@ -0,0 +1,35 @@ +// The embedded purchase sheet seam. flutter_stripe only has a native sheet on +// iOS / Android (and web has its own thing), so the actual impl is conditionally +// imported — native pulls in flutter_stripe, everything else gets a stub that +// reports "unsupported" and lets GarageIap fall back to the browser handoff. + +export "sheet_stub.dart" if (dart.library.io) "sheet_io.dart"; + +// the result of trying to present the sheet. +// completed — the user paid (sheet closed on success). poll entitlements. +// canceled — the user dismissed it. +// unsupported— no native sheet on this platform; caller should handoff. +enum SheetResult { completed, canceled, unsupported } + +// what the sheet needs to init + present. mirrors the payment-intent response. +class SheetParams { + SheetParams({ + required this.clientSecret, + required this.publishableKey, + this.stripeAccount, + this.customer, + this.ephemeralKey, + this.merchantName = "Garage", + }); + + final String clientSecret; + final String publishableKey; + + // set for third-party (direct charge) sales — confirm on the connected acct. + final String? stripeAccount; + + final String? customer; + final String? ephemeralKey; + + final String merchantName; +} diff --git a/garage_iap/lib/src/sheet/sheet_io.dart b/garage_iap/lib/src/sheet/sheet_io.dart new file mode 100644 index 0000000..8f96571 --- /dev/null +++ b/garage_iap/lib/src/sheet/sheet_io.dart @@ -0,0 +1,49 @@ +import "dart:io"; + +import "package:flutter_stripe/flutter_stripe.dart"; + +import "sheet.dart"; + +// Native embedded sheet via flutter_stripe. Only iOS + Android actually carry +// the PaymentSheet — desktop dart:io platforms (linux/macos/windows) report +// unsupported so GarageIap drops to the browser handoff there. +Future presentSheet(SheetParams params) async { + if (!(Platform.isIOS || Platform.isAndroid)) { + return SheetResult.unsupported; + } + + // publishable key drives which stripe account the SDK talks to. for a + // third-party direct charge the PI lives on the connected account, so we set + // stripeAccountId too. + Stripe.publishableKey = params.publishableKey; + if (params.stripeAccount != null && params.stripeAccount!.isNotEmpty) { + Stripe.stripeAccountId = params.stripeAccount; + } else { + Stripe.stripeAccountId = null; + } + await Stripe.instance.applySettings(); + + try { + await Stripe.instance.initPaymentSheet( + paymentSheetParameters: SetupPaymentSheetParameters( + paymentIntentClientSecret: params.clientSecret, + merchantDisplayName: params.merchantName, + customerId: params.customer, + customerEphemeralKeySecret: params.ephemeralKey, + ), + ); + + await Stripe.instance.presentPaymentSheet(); + + // present completes without throwing -> payment succeeded (or is processing + // and will be finalised by the webhook). either way we go poll entitlements. + return SheetResult.completed; + } on StripeException catch (e) { + // the user backing out is the common, non-error path. + if (e.error.code == FailureCode.Canceled) { + return SheetResult.canceled; + } + print("garage_iap payment sheet error: ${e.error.localizedMessage}"); + rethrow; + } +} diff --git a/garage_iap/lib/src/sheet/sheet_stub.dart b/garage_iap/lib/src/sheet/sheet_stub.dart new file mode 100644 index 0000000..4756649 --- /dev/null +++ b/garage_iap/lib/src/sheet/sheet_stub.dart @@ -0,0 +1,7 @@ +import "sheet.dart"; + +// Web / linux / anywhere without a native flutter_stripe sheet. Always reports +// unsupported so GarageIap transparently falls back to the browser handoff. +Future presentSheet(SheetParams params) async { + return SheetResult.unsupported; +} diff --git a/garage_iap/pubspec.yaml b/garage_iap/pubspec.yaml new file mode 100644 index 0000000..b151ebe --- /dev/null +++ b/garage_iap/pubspec.yaml @@ -0,0 +1,40 @@ +name: garage_iap +description: "In-app purchasing for Garage apps — products, both purchase modes (handoff + embedded Stripe sheet), and an offline-verifiable signed licence. Layers on garage_auth." +version: 0.1.0 +publish_to: 'none' + +environment: + sdk: ^3.5.0 + flutter: ">=3.5.0" + +dependencies: + flutter: + sdk: flutter + + garage_auth: + path: ../garage_auth + + http: ^1.5.0 + crypto: ^3.0.6 + + # offline licence verify reuses the backend's asn.1 / rsa stack so a licence + # verifies on the client the exact way it was signed on the store. + pointycastle: ^4.0.0 + + # licence + jwks cache. tokens already live in secure storage via garage_auth. + flutter_secure_storage: ">=9.2.2 <12.0.0" + + # the system-browser handoff for hosted checkout + the subscription fallback. + url_launcher: ^6.3.1 + + # the embedded white-label purchase sheet. falls back to handoff where there's + # no native sdk (linux, web). + flutter_stripe: ^11.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^6.0.0 + +flutter: diff --git a/garage_iap/test/jwks_verify_test.dart b/garage_iap/test/jwks_verify_test.dart new file mode 100644 index 0000000..b602ca7 --- /dev/null +++ b/garage_iap/test/jwks_verify_test.dart @@ -0,0 +1,152 @@ +import "dart:convert"; +import "dart:typed_data"; + +import "package:flutter_test/flutter_test.dart"; +import "package:garage_iap/src/jwks_verify.dart"; +import "package:garage_iap/src/models.dart"; +import "package:pointycastle/export.dart"; + +String _b64uBig(BigInt n) { + final bytes = []; + var v = n; + while (v > BigInt.zero) { + bytes.insert(0, (v & BigInt.from(0xff)).toInt()); + v = v >> 8; + } + return base64Url.encode(Uint8List.fromList(bytes)).replaceAll("=", ""); +} + +String _b64uStr(String s) => + base64Url.encode(utf8.encode(s)).replaceAll("=", ""); + +String _b64uBytes(List b) => base64Url.encode(b).replaceAll("=", ""); + +AsymmetricKeyPair _genKey(int seed) { + final rng = SecureRandom("Fortuna") + ..seed(KeyParameter( + Uint8List.fromList(List.generate(32, (i) => (i + seed) & 0xff)))); + final gen = RSAKeyGenerator() + ..init(ParametersWithRandom( + RSAKeyGeneratorParameters(BigInt.parse("65537"), 2048, 64), rng)); + return gen.generateKeyPair(); +} + +// build a compact RS256 JWT the same way the backend does — header.payload +// signed with PKCS1v15 SHA-256. +String _signJwt(RSAPrivateKey priv, Map header, + Map payload) { + final h = _b64uStr(jsonEncode(header)); + final p = _b64uStr(jsonEncode(payload)); + final input = utf8.encode("$h.$p"); + final signer = Signer("SHA-256/RSA") as RSASigner; + signer.init(true, PrivateKeyParameter(priv)); + final sig = signer.generateSignature(Uint8List.fromList(input)); + return "$h.$p.${_b64uBytes(sig.bytes)}"; +} + +void main() { + test("verifies a signed licence and reads products", () { + final pair = _genKey(1); + final pub = pair.publicKey as RSAPublicKey; + final priv = pair.privateKey as RSAPrivateKey; + + final jwks = { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "k1", + "n": _b64uBig(pub.modulus!), + "e": _b64uBig(pub.exponent!) + } + ] + }; + + final now = DateTime.now().toUtc(); + final token = _signJwt(priv, { + "alg": "RS256", + "kid": "k1" + }, { + "sub": "user-123", + "app": "my-app", + "products": [ + {"sku": "pro", "kind": "app"} + ], + "exp": now.add(const Duration(hours: 24)).millisecondsSinceEpoch ~/ 1000, + }); + + final lic = verifyLicence(token, jwks, + expectedApp: "my-app", expectedSub: "user-123"); + expect(lic.grants("pro"), isTrue); + expect(lic.grants("nope"), isFalse); + expect(lic.isExpired, isFalse); + }); + + test("rejects wrong app, wrong sub, and a tampered signature", () { + final pair = _genKey(7); + final pub = pair.publicKey as RSAPublicKey; + final priv = pair.privateKey as RSAPrivateKey; + final jwks = { + "keys": [ + { + "kty": "RSA", + "kid": "k1", + "alg": "RS256", + "n": _b64uBig(pub.modulus!), + "e": _b64uBig(pub.exponent!) + } + ] + }; + + final now = DateTime.now().toUtc(); + final exp = + now.add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000; + final good = _signJwt(priv, {"alg": "RS256", "kid": "k1"}, + {"sub": "u", "app": "my-app", "products": [], "exp": exp}); + + expect( + () => verifyLicence(good, jwks, expectedApp: "other", expectedSub: "u"), + throwsA(isA())); + expect( + () => verifyLicence(good, jwks, + expectedApp: "my-app", expectedSub: "someone-else"), + throwsA(isA())); + + final tampered = "${good.substring(0, good.length - 4)}AAAA"; + expect( + () => verifyLicence(tampered, jwks, + expectedApp: "my-app", expectedSub: "u"), + throwsA(isA())); + }); + + test("rejects an expired licence", () { + final pair = _genKey(3); + final pub = pair.publicKey as RSAPublicKey; + final priv = pair.privateKey as RSAPrivateKey; + final jwks = { + "keys": [ + { + "kty": "RSA", + "kid": "k1", + "alg": "RS256", + "n": _b64uBig(pub.modulus!), + "e": _b64uBig(pub.exponent!) + } + ] + }; + + final past = DateTime.now() + .toUtc() + .subtract(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000; + final token = _signJwt(priv, {"alg": "RS256", "kid": "k1"}, + {"sub": "u", "app": "my-app", "products": [], "exp": past}); + + expect( + () => + verifyLicence(token, jwks, expectedApp: "my-app", expectedSub: "u"), + throwsA(predicate((e) => e is IapError && e.code == "expired"))); + }); +} diff --git a/garage_ui/LICENSE b/garage_ui/LICENSE new file mode 100644 index 0000000..72fab54 --- /dev/null +++ b/garage_ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 IMBENJI.NET LTD + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/garage_ui/lib/app.dart b/garage_ui/lib/app.dart new file mode 100644 index 0000000..fa69cfc --- /dev/null +++ b/garage_ui/lib/app.dart @@ -0,0 +1,111 @@ +import "package:flutter/widgets.dart"; + +import "package:garage_ui/scrollbar.dart"; +import "package:garage_ui/theme/garage_theme.dart"; + +/// The application shell for Garage UI apps. +/// +/// This deliberately uses [WidgetsApp] rather than [MaterialApp]. Garage UI +/// owns the visual system while Flutter still supplies routing, overlays, +/// media queries, directionality, and localization plumbing. +class GarageApp extends StatelessWidget { + const GarageApp.router({ + super.key, + required this.routerConfig, + required this.theme, + this.title = "", + this.builder, + this.locale, + this.localizationsDelegates, + this.supportedLocales = const [Locale("en", "US")], + this.debugShowCheckedModeBanner = false, + }); + + final RouterConfig routerConfig; + final ThemeData theme; + final String title; + final TransitionBuilder? builder; + final Locale? locale; + final Iterable>? localizationsDelegates; + final Iterable supportedLocales; + final bool debugShowCheckedModeBanner; + + @override + Widget build(BuildContext context) { + return WidgetsApp.router( + key: key, + title: title, + routerConfig: routerConfig, + color: theme.colorScheme.background, + locale: locale, + localizationsDelegates: localizationsDelegates, + supportedLocales: supportedLocales, + debugShowCheckedModeBanner: debugShowCheckedModeBanner, + // the Builder matters: without it `builder` is handed the context ABOVE + // this GarageTheme, so GarageTheme.of() asserts inside the very callback + // you were given for theming. + // ScrollConfiguration, not WidgetsApp's `scrollBehavior` - that + // parameter only exists on MaterialApp/CupertinoApp. + builder: (context, child) => ScrollConfiguration( + behavior: const GarageScrollBehavior(), + child: GarageTheme( + data: theme, + child: Builder( + builder: (themedContext) => + builder?.call(themedContext, child) ?? + child ?? + const SizedBox.shrink(), + ), + ), + ), + ); + } +} + +/// Scrolling without Material's overscroll glow. +/// +/// The default ScrollBehavior hangs a GlowingOverscrollIndicator off every +/// scrollable on Android and Fuchsia - the grey arc that swells out of the +/// top or bottom edge when you drag past the end. Its a Material idiom, and +/// this kit isnt a Material app: it paints a big soft shape over flat chrome +/// and reads as a rendering fault rather than as feedback. +/// +/// Physics and drag devices are untouched, so a list still throws and settles +/// the way the OS expects - it just doesnt glow at the ends. iOS and desktop +/// never had the glow in the first place, so this only levels the other +/// platforms up to what they already do. +/// +/// The scrollbar is ours too. The base behaviour hands desktop a stock +/// [RawScrollbar] - 8px, square ends, a hardcoded grey that knows nothing +/// about the scheme - and thats the pale bar that used to sit beside every +/// nav list. [GarageScrollbar] replaces it here, once, for every scrollable +/// in the app. Touch platforms keep no bar at all, same as the default. +class GarageScrollBehavior extends ScrollBehavior { + const GarageScrollBehavior(); + + @override + Widget buildOverscrollIndicator( + BuildContext context, + Widget child, + ScrollableDetails details, + ) => child; + + @override + Widget buildScrollbar( + BuildContext context, + Widget child, + ScrollableDetails details, + ) { + switch (getPlatform(context)) { + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return GarageScrollbar(controller: details.controller, child: child); + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + return child; + } + } +} diff --git a/garage_ui/lib/app_frame_capture.dart b/garage_ui/lib/app_frame_capture.dart new file mode 100644 index 0000000..ceec13a --- /dev/null +++ b/garage_ui/lib/app_frame_capture.dart @@ -0,0 +1,115 @@ +import "dart:ui" as ui; + +import "package:flutter/foundation.dart"; +import "package:flutter/rendering.dart"; +import "package:flutter/widgets.dart"; + +// Wraps the whole app in a RepaintBoundary we can snapshot on demand, so an +// eyedropper has something to read pixels out of on platforms with no system +// colour sampler (see platform/eyedropper.dart for the ones that do). +// +// This obviously only covers whats inside the flutter window - thats the +// tradeoff for it working everywhere. Mounted once, in main.dart, just under +// CustomCursorLayer so the fake cursor doesnt end up baked into the snapshot +// and get sampled by the very thing thats drawing it. +class AppFrameCapture extends StatelessWidget { + const AppFrameCapture({super.key, required this.child}); + + static final GlobalKey _boundaryKey = GlobalKey( + debugLabel: "app frame capture", + ); + + final Widget child; + + @override + Widget build(BuildContext context) { + return RepaintBoundary(key: _boundaryKey, child: child); + } + + /// Grabs the current frame. Returns null (and logs why) if theres nothing + /// to grab - no boundary mounted yet, or the raster came back empty. + static Future capture() async { + final object = _boundaryKey.currentContext?.findRenderObject(); + if (object is! RenderRepaintBoundary) { + debugPrint( + "AppFrameCapture.capture: no boundary mounted, is AppFrameCapture in " + "the tree?", + ); + return null; + } + + ui.Image? image; + try { + // pixelRatio 1 on purpose - it keeps image pixels and logical pixels the + // same thing, so colorAt can index straight off a pointer position with + // no dpr maths, and it keeps the byte buffer a quarter of the size on a + // retina display. + image = await object.toImage(); + final width = image.width; + final height = image.height; + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + if (bytes == null) { + debugPrint("AppFrameCapture.capture: toByteData returned null"); + return null; + } + + return AppFrameSnapshot._( + bytes.buffer.asUint8List(), + width, + height, + object.localToGlobal(Offset.zero), + ); + } catch (error, stack) { + debugPrint("AppFrameCapture.capture failed: $error\n$stack"); + return null; + } finally { + image?.dispose(); + } + } +} + +/// A frozen copy of the app window you can read single pixels out of. +class AppFrameSnapshot { + AppFrameSnapshot._(this._pixels, this.width, this.height, this.origin); + + /// straight-from-bytes ctor, only so the sampling maths can be probed + /// without standing up a whole render tree. + @visibleForTesting + AppFrameSnapshot.fromRawRgba({ + required Uint8List pixels, + required this.width, + required this.height, + this.origin = Offset.zero, + }) : _pixels = pixels; + + final Uint8List _pixels; + final int width; + final int height; + + /// where the captured boundary sits in global coordinates. normally zero, + /// but not worth assuming. + final Offset origin; + + /// The colour at [globalPosition], or null if thats outside the captured + /// area or lands on a fully transparent pixel. + Color? colorAt(Offset globalPosition) { + final local = globalPosition - origin; + final x = local.dx.floor(); + final y = local.dy.floor(); + if (x < 0 || y < 0 || x >= width || y >= height) return null; + + final i = (y * width + x) * 4; + final a = _pixels[i + 3]; + if (a == 0) return null; + + // rawRgba is premultiplied, so anything drawn over a translucent layer + // reads darker than it looks unless we undo that first. + int channel(int offset) { + final value = _pixels[i + offset]; + if (a == 255) return value; + return (value * 255 / a).round().clamp(0, 255); + } + + return Color.fromARGB(255, channel(0), channel(1), channel(2)); + } +} diff --git a/garage_ui/lib/app_menu.dart b/garage_ui/lib/app_menu.dart new file mode 100644 index 0000000..f1b188a --- /dev/null +++ b/garage_ui/lib/app_menu.dart @@ -0,0 +1,235 @@ +import "package:flutter/widgets.dart"; + +sealed class AppMenuItem { + const AppMenuItem(); +} + +class AppMenuGroup extends AppMenuItem { + const AppMenuGroup({ + required this.label, + required this.children, + this.icon, + this.visible = true, + }); + + final String label; + final IconData? icon; + final List children; + final bool visible; + + List get visibleChildren => children.where((item) { + return switch (item) { + AppMenuGroup group => group.visible, + AppMenuAction action => action.visible, + _ => true, + }; + }).toList(); +} + +class AppMenuAction extends AppMenuItem { + const AppMenuAction({ + required this.label, + this.icon, + this.trailingIcon, + this.onTap, + this.shortcut, + this.visible = true, + }); + + final String label; + final IconData? icon; + final IconData? trailingIcon; + final VoidCallback? onTap; + final SingleActivator? shortcut; + final bool visible; + + bool get enabled => onTap != null; +} + +class AppMenuCheck extends AppMenuItem { + const AppMenuCheck({ + required this.label, + required this.checked, + this.checkedLabel, + this.onToggle, + this.shortcut, + }); + + final String label; + final String? checkedLabel; + final bool checked; + final VoidCallback? onToggle; + final SingleActivator? shortcut; +} + +class AppMenuPlatformProvided extends AppMenuItem { + const AppMenuPlatformProvided(this.type); + + final PlatformProvidedMenuItemType type; +} + +class AppMenuSeparator extends AppMenuItem { + const AppMenuSeparator({this.visible = true}); + + final bool visible; +} + +/// Converts the shared menu model to Flutter's native platform menu model. +class AppMenuNativeRenderer { + static List build( + List menus, { + String appName = "Garage App", + }) { + final appItems = [ + for (final type in [ + PlatformProvidedMenuItemType.about, + PlatformProvidedMenuItemType.servicesSubmenu, + PlatformProvidedMenuItemType.hide, + PlatformProvidedMenuItemType.hideOtherApplications, + PlatformProvidedMenuItemType.showAllApplications, + PlatformProvidedMenuItemType.quit, + ]) + if (PlatformProvidedMenuItem.hasMenu(type)) + PlatformProvidedMenuItem(type: type), + ]; + + return [ + if (appItems.isNotEmpty) PlatformMenu(label: appName, menus: appItems), + for (final group in menus) + if (group.visible) + PlatformMenu( + label: group.label, + menus: _convertItems(group.visibleChildren), + ), + ]; + } + + static String signature(List menus) { + final buffer = StringBuffer(); + for (final group in menus) { + if (!group.visible) continue; + _writeSignature(buffer, group); + buffer.write(";"); + } + return buffer.toString(); + } + + static List _convertItems(List items) { + final groups = >[[]]; + for (final item in items) { + if (item case AppMenuSeparator(visible: true)) { + groups.add([]); + } else if (item is! AppMenuSeparator) { + groups.last.add(item); + } + } + + final result = []; + for (var index = 0; index < groups.length; index++) { + final native = groups[index] + .map(_toNativeItem) + .whereType(); + final items = native.toList(); + if (items.isEmpty) continue; + if (index == 0) { + result.addAll(items); + } else { + result.add(PlatformMenuItemGroup(members: items)); + } + } + return result; + } + + static PlatformMenuItem? _toNativeItem(AppMenuItem item) { + return switch (item) { + AppMenuGroup group when group.visible => PlatformMenu( + label: group.label, + menus: _convertItems(group.visibleChildren), + ), + AppMenuAction action when action.visible => PlatformMenuItem( + label: action.label, + shortcut: action.shortcut, + onSelected: action.onTap, + ), + AppMenuCheck check => PlatformMenuItem( + label: check.checked && check.checkedLabel != null + ? check.checkedLabel! + : check.label, + shortcut: check.shortcut, + onSelected: check.onToggle, + ), + AppMenuPlatformProvided provided + when PlatformProvidedMenuItem.hasMenu(provided.type) => + PlatformProvidedMenuItem(type: provided.type), + _ => null, + }; + } + + static void _writeSignature(StringBuffer buffer, AppMenuItem item) { + switch (item) { + case AppMenuGroup group: + if (!group.visible) return; + buffer + ..write("G(") + ..write(group.label) + ..write("|") + ..write(group.icon?.codePoint ?? -1) + ..write(")["); + for (final child in group.visibleChildren) { + _writeSignature(buffer, child); + buffer.write(","); + } + buffer.write("]"); + case AppMenuAction action: + if (!action.visible) return; + buffer + ..write("A(") + ..write(action.label) + ..write("|") + ..write(action.icon?.codePoint ?? -1) + ..write("|") + ..write(action.enabled ? 1 : 0) + ..write("|") + ..write(_shortcutSignature(action.shortcut)) + ..write(")"); + case AppMenuCheck check: + buffer + ..write("C(") + ..write(check.label) + ..write("|") + ..write(check.checkedLabel ?? "-") + ..write("|") + ..write(check.checked ? 1 : 0) + ..write("|") + ..write(check.onToggle != null ? 1 : 0) + ..write("|") + ..write(_shortcutSignature(check.shortcut)) + ..write(")"); + case AppMenuPlatformProvided provided: + buffer + ..write("P(") + ..write(provided.type.name) + ..write(")"); + case AppMenuSeparator separator: + if (separator.visible) buffer.write("S"); + } + } + + // SingleActivator has no value-based toString (it's Diagnosticable, so the + // default one embeds the object's identity hash) - and menu_def.dart builds + // a fresh activator on every rebuild, so hashing the instance itself would + // make the signature change every frame and defeat the whole point of this + // gate. build it off the actual key/modifier fields instead, those are what + // we care about being stable. + static String _shortcutSignature(SingleActivator? shortcut) { + if (shortcut == null) return "-"; + return [ + shortcut.trigger.keyLabel, + shortcut.trigger.keyId.toRadixString(16), + shortcut.alt ? "a" : "", + shortcut.control ? "c" : "", + shortcut.meta ? "m" : "", + shortcut.shift ? "s" : "", + ].join(":"); + } +} diff --git a/garage_ui/lib/app_menu_notifier.dart b/garage_ui/lib/app_menu_notifier.dart new file mode 100644 index 0000000..9147d49 --- /dev/null +++ b/garage_ui/lib/app_menu_notifier.dart @@ -0,0 +1,67 @@ +import "package:flutter/foundation.dart"; +import "package:flutter/widgets.dart"; + +class AppMenuNotifier extends ChangeNotifier { + List _menus = const []; + + List get menus => _menus; + + void update(List menus) { + _menus = menus; + notifyListeners(); + } +} + +/// Keeps one native platform menu host above an application's provider tree. +class PlatformMenuHost extends StatefulWidget { + const PlatformMenuHost({ + super.key, + required this.notifier, + required this.child, + }); + + final AppMenuNotifier notifier; + final Widget child; + + @override + State createState() => _PlatformMenuHostState(); +} + +class _PlatformMenuHostState extends State { + List _menus = const []; + + static bool get _supported => + !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS; + + @override + void initState() { + super.initState(); + _menus = widget.notifier.menus; + widget.notifier.addListener(_onMenusChanged); + } + + @override + void didUpdateWidget(PlatformMenuHost oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.notifier == oldWidget.notifier) return; + oldWidget.notifier.removeListener(_onMenusChanged); + widget.notifier.addListener(_onMenusChanged); + _menus = widget.notifier.menus; + } + + @override + void dispose() { + widget.notifier.removeListener(_onMenusChanged); + super.dispose(); + } + + void _onMenusChanged() { + if (mounted) setState(() => _menus = widget.notifier.menus); + } + + @override + Widget build(BuildContext context) { + if (!_supported) return widget.child; + return PlatformMenuBar(menus: _menus, child: widget.child); + } +} diff --git a/garage_ui/lib/button.dart b/garage_ui/lib/button.dart new file mode 100644 index 0000000..d2e1908 --- /dev/null +++ b/garage_ui/lib/button.dart @@ -0,0 +1,2414 @@ +// GarageUI button family — hand rolled replacement for shadcn's button.dart. +// +// this replicates shadcn_flutter 0.0.52 control/button.dart (+ clickable state +// machinery + focus_outline) as close to pixel identical as i could get it. +// colours/typography/radii all come from the SAME source shadcn reads: +// GarageTheme.of(context) (imported with the `shad` prefix below), so the look matches. +// +// only the widgets the app actually uses live here. some rarely used shadcn +// params/variants were dropped — see the report / notes near the bottom. + +import "dart:math"; + +import "package:flutter/scheduler.dart"; +import "package:flutter/services.dart"; +import "package:flutter/widgets.dart"; +import "package:garage_ui/theme/garage_theme.dart"; +import "package:garage_ui/theme/support.dart"; + +// shadcn multiplies the existing alpha by a factor (not withOpacity which +// replaces it). keep that behaviour so faded states look the same. +Color _scaleAlpha(Color c, double factor) { + return c.withValues(alpha: c.a * factor); +} + +bool _isMobile(TargetPlatform platform) { + return platform == TargetPlatform.iOS || + platform == TargetPlatform.android || + platform == TargetPlatform.fuchsia; +} + +// --------------------------------------------------------------------------- +// size / density / shape +// --------------------------------------------------------------------------- + +/// relative scale factor for a button (text, icon, padding all scale by this). +class ButtonSize { + final double scale; + const ButtonSize(this.scale); + + static const ButtonSize normal = ButtonSize(1); + static const ButtonSize xSmall = ButtonSize(1 / 2); + static const ButtonSize small = ButtonSize(3 / 4); + static const ButtonSize large = ButtonSize(2); + static const ButtonSize xLarge = ButtonSize(3); +} + +/// Collapses padding to its smallest side, so an icon-only control comes out +/// square instead of inheriting the asymmetric control padding. +EdgeInsets _squarePadding(EdgeInsets padding) { + return EdgeInsets.all( + min(padding.top, min(padding.bottom, min(padding.left, padding.right))), + ); +} + +/// The single padding resolution every control in the family goes through — +/// buttons, icon buttons, and the select trigger. Keep it that way: the reason +/// four different control heights existed was three places each doing their own +/// version of this maths. +/// +/// A null [density] means "follow the theme", which is what everything should +/// pass unless a call site deliberately wants to break out of the app density. +EdgeInsets resolveControlPadding( + ThemeData theme, { + ControlDensity? density, + bool includesBorder = false, + bool squarePadding = false, + double sizeScale = 1.0, +}) { + final resolved = density ?? ControlDensity.of(theme); + final base = squarePadding + ? resolved.resolveIcon(theme) + : resolved.resolve(theme); + final scaled = base * sizeScale; + if (!includesBorder) return scaled; + + // Border dimensions are added by Container after its explicit padding. + // Remove the unscaled stroke from each side after applying ButtonSize so + // every density/size keeps the same intended outer dimensions. + final border = resolved.tokens(theme).controlBorderWidth; + return EdgeInsets.fromLTRB( + max(0.0, scaled.left - border), + max(0.0, scaled.top - border), + max(0.0, scaled.right - border), + max(0.0, scaled.bottom - border), + ); +} + +/// rectangle (rounded corners) or a full circle. +enum ButtonShape { rectangle, circle } + +// --------------------------------------------------------------------------- +// style abstraction +// --------------------------------------------------------------------------- + +typedef ButtonStateProperty = + T Function(BuildContext context, Set states); + +typedef ButtonStatePropertyDelegate = + T Function(BuildContext context, Set states, T value); + +/// contract every button style implements — state aware getters for each prop. +abstract class AbstractButtonStyle { + ButtonStateProperty get decoration; + ButtonStateProperty get mouseCursor; + ButtonStateProperty get padding; + ButtonStateProperty get textStyle; + ButtonStateProperty get iconTheme; + ButtonStateProperty get margin; + + /// null = follow the theme's [ControlDensity]. + ControlDensity? get density; + bool get squarePadding; + bool get includesBorder; +} + +/// concrete style variant — holds the actual state property functions. +class ButtonVariance implements AbstractButtonStyle { + // a bare variance has no opinion on density, so it follows the theme. it used + // to claim ControlDensity.normal, which nothing read (ButtonStyle overrides + // padding wholesale) but was a nasty lie if you ever looked at it. + @override + ControlDensity? get density => null; + + @override + bool get squarePadding => false; + + @override + final bool includesBorder; + + static const AbstractButtonStyle primary = ButtonVariance( + decoration: _buttonPrimaryDecoration, + mouseCursor: _buttonMouseCursor, + textStyle: _buttonPrimaryTextStyle, + iconTheme: _buttonPrimaryIconTheme, + margin: _buttonZeroMargin, + includesBorder: true, + ); + + static const AbstractButtonStyle secondary = ButtonVariance( + decoration: _buttonSecondaryDecoration, + mouseCursor: _buttonMouseCursor, + textStyle: _buttonSecondaryTextStyle, + iconTheme: _buttonSecondaryIconTheme, + margin: _buttonZeroMargin, + includesBorder: true, + ); + + static const AbstractButtonStyle outline = ButtonVariance( + decoration: _buttonOutlineDecoration, + mouseCursor: _buttonMouseCursor, + textStyle: _buttonOutlineTextStyle, + iconTheme: _buttonOutlineIconTheme, + margin: _buttonZeroMargin, + includesBorder: true, + ); + + static const AbstractButtonStyle ghost = ButtonVariance( + decoration: _buttonGhostDecoration, + mouseCursor: _buttonMouseCursor, + textStyle: _buttonGhostTextStyle, + iconTheme: _buttonGhostIconTheme, + margin: _buttonZeroMargin, + ); + + static const AbstractButtonStyle link = ButtonVariance( + decoration: _buttonLinkDecoration, + mouseCursor: _buttonMouseCursor, + textStyle: _buttonLinkTextStyle, + iconTheme: _buttonLinkIconTheme, + margin: _buttonZeroMargin, + ); + + static const AbstractButtonStyle text = ButtonVariance( + decoration: _buttonTextDecoration, + mouseCursor: _buttonMouseCursor, + textStyle: _buttonTextTextStyle, + iconTheme: _buttonTextIconTheme, + margin: _buttonZeroMargin, + ); + + static const AbstractButtonStyle destructive = ButtonVariance( + decoration: _buttonDestructiveDecoration, + mouseCursor: _buttonMouseCursor, + textStyle: _buttonDestructiveTextStyle, + iconTheme: _buttonDestructiveIconTheme, + margin: _buttonZeroMargin, + ); + + @override + final ButtonStateProperty decoration; + @override + final ButtonStateProperty mouseCursor; + @override + final ButtonStateProperty textStyle; + @override + final ButtonStateProperty iconTheme; + @override + final ButtonStateProperty margin; + + // padding is NOT a per-variance field. it used to be, and every variance was + // handed the same _buttonPadding function that ignored the border inset — so + // a bare `Button(style: ButtonVariance.outline)` came out 2px bigger on both + // axes than the identical `Button.outline`. it goes through the shared + // resolution now, same as ButtonStyle. + @override + ButtonStateProperty get padding => _variancePadding; + + EdgeInsets _variancePadding(BuildContext context, Set states) { + return resolveControlPadding( + GarageTheme.of(context), + includesBorder: includesBorder, + ); + } + + const ButtonVariance({ + required this.decoration, + required this.mouseCursor, + required this.textStyle, + required this.iconTheme, + required this.margin, + this.includesBorder = false, + }); +} + +/// composable style = a variance + size + density + shape modifiers. +class ButtonStyle implements AbstractButtonStyle { + final AbstractButtonStyle variance; + final ButtonSize size; + @override + final ControlDensity? density; + final ButtonShape shape; + + // collapse padding to its smallest side so an icon-only control is square. + @override + final bool squarePadding; + + @override + bool get includesBorder => variance.includesBorder; + + // hard padding override - wins over the density. [IconButton] uses this to + // zero the padding, because it sizes its own box to the control height + // instead and just centres the icon in it. + final EdgeInsetsGeometry? paddingOverride; + + const ButtonStyle({ + required this.variance, + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = false, + this.paddingOverride, + }); + + const ButtonStyle.primary({ + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = false, + this.paddingOverride, + }) : variance = ButtonVariance.primary; + + const ButtonStyle.secondary({ + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = false, + this.paddingOverride, + }) : variance = ButtonVariance.secondary; + + const ButtonStyle.outline({ + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = false, + this.paddingOverride, + }) : variance = ButtonVariance.outline; + + const ButtonStyle.ghost({ + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = false, + this.paddingOverride, + }) : variance = ButtonVariance.ghost; + + const ButtonStyle.link({ + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = false, + this.paddingOverride, + }) : variance = ButtonVariance.link; + + const ButtonStyle.text({ + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = false, + this.paddingOverride, + }) : variance = ButtonVariance.text; + + const ButtonStyle.destructive({ + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = false, + this.paddingOverride, + }) : variance = ButtonVariance.destructive; + + // icon flavours — same as above but default to icon density (square padding) + const ButtonStyle.primaryIcon({ + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = true, + this.paddingOverride, + }) : variance = ButtonVariance.primary; + + const ButtonStyle.secondaryIcon({ + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = true, + this.paddingOverride, + }) : variance = ButtonVariance.secondary; + + const ButtonStyle.outlineIcon({ + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = true, + this.paddingOverride, + }) : variance = ButtonVariance.outline; + + const ButtonStyle.ghostIcon({ + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = true, + this.paddingOverride, + }) : variance = ButtonVariance.ghost; + + const ButtonStyle.destructiveIcon({ + this.size = ButtonSize.normal, + this.density, + this.shape = ButtonShape.rectangle, + this.squarePadding = true, + this.paddingOverride, + }) : variance = ButtonVariance.destructive; + + @override + ButtonStateProperty get decoration { + if (shape == ButtonShape.circle) { + return _resolveCircleDecoration; + } + return variance.decoration; + } + + Decoration _resolveCircleDecoration( + BuildContext context, + Set states, + ) { + var decoration = variance.decoration(context, states); + if (decoration is BoxDecoration) { + return BoxDecoration( + color: decoration.color, + image: decoration.image, + border: decoration.border, + borderRadius: null, + boxShadow: decoration.boxShadow, + gradient: decoration.gradient, + shape: BoxShape.circle, + backgroundBlendMode: decoration.backgroundBlendMode, + ); + } + // only box decorations are used by our variances, but stay safe + return decoration; + } + + @override + ButtonStateProperty get mouseCursor => variance.mouseCursor; + + // padding comes from the density (+ size scale), not the variance - every + // variance shares the same control padding, only the density changes it. + @override + ButtonStateProperty get padding => _resolvePadding; + + EdgeInsetsGeometry _resolvePadding( + BuildContext context, + Set states, + ) { + if (paddingOverride != null) return paddingOverride!; + return resolveControlPadding( + GarageTheme.of(context), + density: density, + includesBorder: includesBorder, + squarePadding: squarePadding, + sizeScale: size.scale, + ); + } + + @override + ButtonStateProperty get textStyle { + if (size == ButtonSize.normal) { + return variance.textStyle; + } + return _resolveTextStyle; + } + + TextStyle _resolveTextStyle(BuildContext context, Set states) { + var fontSize = variance.textStyle(context, states).fontSize; + if (fontSize == null) { + final textStyle = DefaultTextStyle.of(context).style; + fontSize = textStyle.fontSize ?? 14; + } + return variance + .textStyle(context, states) + .copyWith(fontSize: fontSize * size.scale); + } + + @override + ButtonStateProperty get iconTheme { + if (size == ButtonSize.normal) { + return variance.iconTheme; + } + return _resolveIconTheme; + } + + IconThemeData _resolveIconTheme( + BuildContext context, + Set states, + ) { + var iconSize = variance.iconTheme(context, states).size; + iconSize ??= IconTheme.of(context).size ?? 24; + return variance + .iconTheme(context, states) + .copyWith(size: iconSize * size.scale); + } + + @override + ButtonStateProperty get margin => variance.margin; +} + +/// copyWith for styles — lets you layer state-aware overrides on top of a base +/// style (Toggle relies on this to dim its foreground on hover). +extension ButtonStyleExtension on AbstractButtonStyle { + AbstractButtonStyle copyWith({ + ButtonStatePropertyDelegate? decoration, + ButtonStatePropertyDelegate? mouseCursor, + ButtonStatePropertyDelegate? padding, + ButtonStatePropertyDelegate? textStyle, + ButtonStatePropertyDelegate? iconTheme, + ButtonStatePropertyDelegate? margin, + }) { + if (decoration == null && + mouseCursor == null && + padding == null && + textStyle == null && + iconTheme == null && + margin == null) { + return this; + } + return _CopyWithButtonStyle( + this, + decoration, + mouseCursor, + padding, + textStyle, + iconTheme, + margin, + ); + } +} + +class _CopyWithButtonStyle implements AbstractButtonStyle { + final ButtonStatePropertyDelegate? _decoration; + final ButtonStatePropertyDelegate? _mouseCursor; + final ButtonStatePropertyDelegate? _padding; + final ButtonStatePropertyDelegate? _textStyle; + final ButtonStatePropertyDelegate? _iconTheme; + final ButtonStatePropertyDelegate? _margin; + final AbstractButtonStyle _delegate; + + const _CopyWithButtonStyle( + this._delegate, + this._decoration, + this._mouseCursor, + this._padding, + this._textStyle, + this._iconTheme, + this._margin, + ); + + @override + ControlDensity? get density => _delegate.density; + + @override + bool get squarePadding => _delegate.squarePadding; + + @override + bool get includesBorder => _delegate.includesBorder; + + @override + ButtonStateProperty get iconTheme { + if (_iconTheme == null) return _delegate.iconTheme; + return (context, states) => + _iconTheme(context, states, _delegate.iconTheme(context, states)); + } + + @override + ButtonStateProperty get textStyle { + if (_textStyle == null) return _delegate.textStyle; + return (context, states) => + _textStyle(context, states, _delegate.textStyle(context, states)); + } + + @override + ButtonStateProperty get padding { + if (_padding == null) return _delegate.padding; + return (context, states) => + _padding(context, states, _delegate.padding(context, states)); + } + + @override + ButtonStateProperty get mouseCursor { + if (_mouseCursor == null) return _delegate.mouseCursor; + return (context, states) => + _mouseCursor(context, states, _delegate.mouseCursor(context, states)); + } + + @override + ButtonStateProperty get decoration { + if (_decoration == null) return _delegate.decoration; + return (context, states) => + _decoration(context, states, _delegate.decoration(context, states)); + } + + @override + ButtonStateProperty get margin { + if (_margin == null) return _delegate.margin; + return (context, states) => + _margin(context, states, _delegate.margin(context, states)); + } +} + +// --------------------------------------------------------------------------- +// concrete state property functions (copied verbatim from shadcn) +// --------------------------------------------------------------------------- + +EdgeInsets _buttonZeroMargin(BuildContext context, Set states) { + return EdgeInsets.zero; +} + +MouseCursor _buttonMouseCursor(BuildContext context, Set states) { + return states.contains(WidgetState.disabled) + ? SystemMouseCursors.basic + : SystemMouseCursors.click; +} + +// PRIMARY +Decoration _buttonPrimaryDecoration( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + if (states.contains(WidgetState.disabled)) { + return BoxDecoration( + color: themeData.colorScheme.mutedForeground, + border: Border.all( + color: themeData.colorScheme.controlBorder, + width: themeData.density.controlBorderWidth, + ), + borderRadius: BorderRadius.circular(themeData.radiusMd), + ); + } + if (states.contains(WidgetState.hovered)) { + return BoxDecoration( + color: themeData.colorScheme.primaryHovered, + border: Border.all( + color: themeData.colorScheme.controlBorder, + width: themeData.density.controlBorderWidth, + ), + borderRadius: BorderRadius.circular(themeData.radiusMd), + ); + } + return BoxDecoration( + color: themeData.colorScheme.primary, + border: Border.all( + color: themeData.colorScheme.controlBorder, + width: themeData.density.controlBorderWidth, + ), + borderRadius: BorderRadius.circular(themeData.radiusMd), + ); +} + +TextStyle _buttonPrimaryTextStyle( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + // semiBold, where every other variant is medium. Primary is the one style + // that puts DARK text on a LIGHT fill, and that polarity reads thinner: a + // light ground bleeds into the strokes during antialiasing where a dark one + // bleeds out of them. Same weight either way, so the two looked mismatched + // sat next to each other. This is optical compensation, not emphasis - it + // buys back what the polarity takes, which is why it isn't applied to the + // variants that already sit light-on-dark. + return themeData.typography.small + .merge(themeData.typography.semiBold) + .copyWith(color: themeData.colorScheme.primaryForeground); +} + +IconThemeData _buttonPrimaryIconTheme( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + return IconThemeData( + color: themeData.colorScheme.primaryForeground, + size: themeData.iconTheme.small.size, + ); +} + +// SECONDARY +Decoration _buttonSecondaryDecoration( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + final scheme = themeData.colorScheme; + if (states.contains(WidgetState.hovered)) { + return BoxDecoration( + color: scheme.secondaryHovered, + border: Border.all( + color: scheme.controlBorder, + width: themeData.density.controlBorderWidth, + ), + borderRadius: BorderRadius.circular(themeData.radiusMd), + ); + } + return BoxDecoration( + color: scheme.secondary, + border: Border.all( + color: scheme.controlBorder, + width: themeData.density.controlBorderWidth, + ), + borderRadius: BorderRadius.circular(themeData.radiusMd), + ); +} + +TextStyle _buttonSecondaryTextStyle( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + return themeData.typography.small.copyWith( + color: states.contains(WidgetState.disabled) + ? themeData.colorScheme.mutedForeground + : themeData.colorScheme.secondaryForeground, + ); +} + +IconThemeData _buttonSecondaryIconTheme( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + return IconThemeData( + color: states.contains(WidgetState.disabled) + ? themeData.colorScheme.mutedForeground + : themeData.colorScheme.secondaryForeground, + size: themeData.iconTheme.small.size, + ); +} + +// OUTLINE +// same tokens TextField's own decoration uses (controlFill/Hovered + +// controlBorder + radiusMd) - the explorer's search field + add button are the +// reference look or this, and outline is the "standard" button/field border +// style app-wide, so they read as one family instead of each getting styled +// by hand per call site. +Decoration _buttonOutlineDecoration( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + final scheme = themeData.colorScheme; + if (states.contains(WidgetState.disabled)) { + return BoxDecoration( + color: scheme.controlFill, + // no strokeAlign override - defaults to inside, same as TextField's own + // border. Center-aligned (the old value) paints half the stroke + // outside the box, which is what made this read a pixel bigger than + // an equivalent field on every side. + border: Border.all( + color: scheme.controlBorder, + width: themeData.density.controlBorderWidth, + ), + borderRadius: BorderRadius.circular(themeData.radiusMd), + ); + } + if (states.contains(WidgetState.hovered)) { + return BoxDecoration( + color: scheme.controlFillHovered, + // no strokeAlign override - defaults to inside, same as TextField's own + // border. Center-aligned (the old value) paints half the stroke + // outside the box, which is what made this read a pixel bigger than + // an equivalent field on every side. + border: Border.all( + color: scheme.controlBorder, + width: themeData.density.controlBorderWidth, + ), + borderRadius: BorderRadius.circular(themeData.radiusMd), + ); + } + return BoxDecoration( + color: scheme.controlFill, + border: Border.all( + color: scheme.controlBorder, + width: themeData.density.controlBorderWidth, + ), + borderRadius: BorderRadius.circular(themeData.radiusMd), + ); +} + +TextStyle _buttonOutlineTextStyle( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + return themeData.typography.small.copyWith( + color: states.contains(WidgetState.disabled) + ? themeData.colorScheme.mutedForeground + : themeData.colorScheme.foreground, + ); +} + +// muted, not foreground - the explorer's tool button is the reference for +// this style and its icon sat at mutedForeground. +IconThemeData _buttonOutlineIconTheme( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + return IconThemeData( + color: themeData.colorScheme.mutedForeground, + size: themeData.iconTheme.small.size, + ); +} + +// GHOST +/// Ghost TINTS what its standing on, rather than painting a colour over it. +/// +/// It used to fill with `muted` at 0.8 alpha - near enough opaque, and picked +/// against one surface. Put the same button on a different one (the properties +/// actions band, which is itself `muted`; a panel; a rail) and the fill either +/// vanished into the surface or sat on it as a slab, which is the opposite of +/// what ghost means. +/// +/// A low-alpha wash of the foreground works anywhere: on a dark surface it +/// lifts, on a light one it darkens, and it always reads as the SAME surface +/// with a highlight rather than a different colour laid on top. +Decoration _buttonGhostDecoration( + BuildContext context, + Set states, +) { + final themeData = GarageTheme.of(context); + final radius = BorderRadius.circular(themeData.radiusMd); + final tint = themeData.colorScheme.foreground; + + if (states.contains(WidgetState.disabled)) { + return BoxDecoration(color: const Color(0x00000000), borderRadius: radius); + } + if (states.contains(WidgetState.pressed)) { + return BoxDecoration( + color: tint.withValues(alpha: 0.12), + borderRadius: radius, + ); + } + if (states.contains(WidgetState.hovered)) { + return BoxDecoration( + color: tint.withValues(alpha: 0.07), + borderRadius: radius, + ); + } + return BoxDecoration(color: const Color(0x00000000), borderRadius: radius); +} + +TextStyle _buttonGhostTextStyle(BuildContext context, Set states) { + var themeData = GarageTheme.of(context); + return themeData.typography.small.copyWith( + color: states.contains(WidgetState.disabled) + ? themeData.colorScheme.mutedForeground + : themeData.colorScheme.foreground, + ); +} + +IconThemeData _buttonGhostIconTheme( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + return IconThemeData( + color: states.contains(WidgetState.disabled) + ? themeData.colorScheme.mutedForeground + : themeData.colorScheme.foreground, + size: themeData.iconTheme.small.size, + ); +} + +// LINK +Decoration _buttonLinkDecoration( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + return BoxDecoration(borderRadius: BorderRadius.circular(themeData.radiusMd)); +} + +TextStyle _buttonLinkTextStyle(BuildContext context, Set states) { + var themeData = GarageTheme.of(context); + return themeData.typography.small.copyWith( + color: states.contains(WidgetState.disabled) + ? themeData.colorScheme.mutedForeground + : themeData.colorScheme.foreground, + decoration: states.contains(WidgetState.hovered) + ? TextDecoration.underline + : TextDecoration.none, + ); +} + +IconThemeData _buttonLinkIconTheme( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + return IconThemeData( + color: states.contains(WidgetState.disabled) + ? themeData.colorScheme.mutedForeground + : themeData.colorScheme.foreground, + size: themeData.iconTheme.small.size, + ); +} + +// TEXT +Decoration _buttonTextDecoration( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + return BoxDecoration(borderRadius: BorderRadius.circular(themeData.radiusMd)); +} + +TextStyle _buttonTextTextStyle(BuildContext context, Set states) { + var themeData = GarageTheme.of(context); + return themeData.typography.small.copyWith( + color: states.contains(WidgetState.hovered) + ? themeData.colorScheme.primary + : themeData.colorScheme.mutedForeground, + ); +} + +IconThemeData _buttonTextIconTheme( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + return IconThemeData( + color: states.contains(WidgetState.hovered) + ? themeData.colorScheme.primary + : themeData.colorScheme.mutedForeground, + size: themeData.iconTheme.small.size, + ); +} + +// DESTRUCTIVE +Decoration _buttonDestructiveDecoration( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + if (states.contains(WidgetState.disabled)) { + return BoxDecoration( + color: themeData.colorScheme.primaryForeground, + borderRadius: BorderRadius.circular(themeData.radiusMd), + ); + } + if (states.contains(WidgetState.hovered)) { + return BoxDecoration( + color: _scaleAlpha(themeData.colorScheme.destructive, 0.8), + borderRadius: BorderRadius.circular(themeData.radiusMd), + ); + } + return BoxDecoration( + color: _scaleAlpha(themeData.colorScheme.destructive, 0.5), + borderRadius: BorderRadius.circular(themeData.radiusMd), + ); +} + +TextStyle _buttonDestructiveTextStyle( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + return themeData.typography.small.copyWith( + // yeah ik, its straight up white regardless of light or dark mode + color: states.contains(WidgetState.disabled) + ? themeData.colorScheme.mutedForeground + : const Color(0xFFFFFFFF), + ); +} + +IconThemeData _buttonDestructiveIconTheme( + BuildContext context, + Set states, +) { + var themeData = GarageTheme.of(context); + return IconThemeData( + color: states.contains(WidgetState.disabled) + ? themeData.colorScheme.mutedForeground + : const Color(0xFFFFFFFF), + size: themeData.iconTheme.small.size, + ); +} + +// --------------------------------------------------------------------------- +// button group border merging +// --------------------------------------------------------------------------- + +// corner radius multipliers so adjacent buttons in a group look joined. +class ButtonGroupCorners { + final double topStart; + final double topEnd; + final double bottomStart; + final double bottomEnd; + + const ButtonGroupCorners( + this.topStart, + this.topEnd, + this.bottomStart, + this.bottomEnd, + ); + + static ButtonGroupCorners horizontalIndex(int i, int len) { + if (len <= 1) return const ButtonGroupCorners(1, 1, 1, 1); + if (i == 0) return const ButtonGroupCorners(1, 0, 1, 0); // start + if (i == len - 1) return const ButtonGroupCorners(0, 1, 0, 1); // end + return const ButtonGroupCorners(0, 0, 0, 0); + } + + static ButtonGroupCorners verticalIndex(int i, int len) { + if (len <= 1) return const ButtonGroupCorners(1, 1, 1, 1); + if (i == 0) return const ButtonGroupCorners(1, 1, 0, 0); // top + if (i == len - 1) return const ButtonGroupCorners(0, 0, 1, 1); // bottom + return const ButtonGroupCorners(0, 0, 0, 0); + } + + /// Both groups' say on a corner, for a group nested in another group. The + /// factors are 0-or-1 keeps, so "round only where BOTH agree" is a product: + /// a field that is bottom row of a vertical group AND start of a horizontal + /// one keeps its bottom-start corner and nothing else. + ButtonGroupCorners merge(ButtonGroupCorners other) => ButtonGroupCorners( + topStart * other.topStart, + topEnd * other.topEnd, + bottomStart * other.bottomStart, + bottomEnd * other.bottomEnd, + ); + + BorderRadius applyTo( + BorderRadiusGeometry radius, + TextDirection textDirection, + ) { + final ltr = textDirection == TextDirection.ltr; + final tl = ltr ? topStart : topEnd; + final tr = ltr ? topEnd : topStart; + final bl = ltr ? bottomStart : bottomEnd; + final br = ltr ? bottomEnd : bottomStart; + final r = radius.resolve(textDirection); + return BorderRadius.only( + topLeft: Radius.elliptical(r.topLeft.x * tl, r.topLeft.y * tl), + topRight: Radius.elliptical(r.topRight.x * tr, r.topRight.y * tr), + bottomLeft: Radius.elliptical(r.bottomLeft.x * bl, r.bottomLeft.y * bl), + bottomRight: Radius.elliptical( + r.bottomRight.x * br, + r.bottomRight.y * br, + ), + ); + } +} + +class ButtonGroupScope extends InheritedWidget { + final ButtonGroupCorners corners; + + /// Edges a preceding member of the group already painted. Stored as flags + /// rather than worked out from an axis and an index, because a group nested + /// in another group can be told to drop BOTH - the password row's field + /// gives up its top edge to the email above it and its end edge to the eye + /// beside it, and one axis-and-index pair cant say that. + final bool hideTop; + final bool hideStart; + + const ButtonGroupScope({ + super.key, + required this.corners, + this.hideTop = false, + this.hideStart = false, + required super.child, + }); + + static ButtonGroupScope? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + /// Removes the edges already painted by a preceding group member, so + /// neighbours share one stroke instead of stacking two. + Border mergedBorder(Border border, TextDirection textDirection) { + final ltr = textDirection == TextDirection.ltr; + return Border( + top: hideTop ? BorderSide.none : border.top, + right: (hideStart && !ltr) ? BorderSide.none : border.right, + bottom: border.bottom, + left: (hideStart && ltr) ? BorderSide.none : border.left, + ); + } + + @override + bool updateShouldNotify(ButtonGroupScope oldWidget) { + return oldWidget.corners != corners || + oldWidget.hideTop != hideTop || + oldWidget.hideStart != hideStart; + } +} + +// --------------------------------------------------------------------------- +// the base Button (clickable machinery lives in its state) +// --------------------------------------------------------------------------- + +/// the foundational interactive button. wraps content in state aware +/// decoration/padding/text/icon styling with hover + press + focus handling. +class Button extends StatefulWidget { + final bool? enabled; + final bool disableTransition; + final Widget? leading; + final Widget? trailing; + final double? leadingGap; + final double? trailingGap; + final Widget child; + final VoidCallback? onPressed; + final FocusNode? focusNode; + final AlignmentGeometry? alignment; + final AbstractButtonStyle style; + final ValueChanged? onHover; + final ValueChanged? onFocus; + final bool? enableFeedback; + final GestureTapDownCallback? onTapDown; + final GestureTapUpCallback? onTapUp; + final GestureTapCancelCallback? onTapCancel; + final GestureTapDownCallback? onSecondaryTapDown; + final GestureTapUpCallback? onSecondaryTapUp; + final GestureTapCancelCallback? onSecondaryTapCancel; + final GestureTapDownCallback? onTertiaryTapDown; + final GestureTapUpCallback? onTertiaryTapUp; + final GestureTapCancelCallback? onTertiaryTapCancel; + final GestureLongPressStartCallback? onLongPressStart; + final GestureLongPressUpCallback? onLongPressUp; + final GestureLongPressMoveUpdateCallback? onLongPressMoveUpdate; + final GestureLongPressEndCallback? onLongPressEnd; + final GestureLongPressUpCallback? onSecondaryLongPress; + final GestureLongPressUpCallback? onTertiaryLongPress; + final bool disableHoverEffect; + final WidgetStatesController? statesController; + final AlignmentGeometry? marginAlignment; + final bool disableFocusOutline; + + /// screen reader label. Only set this when the button has no text child to + /// read — an icon-only button, basically. If you set it on a button that + /// does have a Text child you get both read out, which is just noise. + final String? semanticLabel; + + /// when non-null the button announces as a toggle in the given state rather + /// than as a plain button. Toggle passes this through. + final bool? semanticToggled; + + /// when non-null the button announces as one option of a segmented control + /// (a mutually exclusive group) in the given state. Use this for two-or-more + /// buttons that are modes of a single setting; use [semanticToggled] for a + /// standalone on/off. + final bool? semanticSelected; + + const Button({ + super.key, + this.statesController, + this.leading, + this.trailing, + this.leadingGap, + this.trailingGap, + required this.child, + this.onPressed, + this.focusNode, + this.alignment, + required this.style, + this.enabled, + this.disableTransition = false, + this.onFocus, + this.onHover, + this.disableHoverEffect = false, + this.enableFeedback, + this.onTapDown, + this.onTapUp, + this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, + this.onTertiaryTapDown, + this.onTertiaryTapUp, + this.onTertiaryTapCancel, + this.onLongPressStart, + this.onLongPressUp, + this.onLongPressMoveUpdate, + this.onLongPressEnd, + this.onSecondaryLongPress, + this.onTertiaryLongPress, + this.semanticLabel, + this.semanticToggled, + this.semanticSelected, + this.marginAlignment, + this.disableFocusOutline = false, + }); + + const Button.primary({ + super.key, + this.statesController, + this.leading, + this.trailing, + this.leadingGap, + this.trailingGap, + required this.child, + this.onPressed, + this.focusNode, + this.alignment, + this.enabled, + this.style = const ButtonStyle.primary(), + this.disableTransition = false, + this.onFocus, + this.onHover, + this.disableHoverEffect = false, + this.enableFeedback, + this.onTapDown, + this.onTapUp, + this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, + this.onTertiaryTapDown, + this.onTertiaryTapUp, + this.onTertiaryTapCancel, + this.onLongPressStart, + this.onLongPressUp, + this.onLongPressMoveUpdate, + this.onLongPressEnd, + this.onSecondaryLongPress, + this.onTertiaryLongPress, + this.semanticLabel, + this.semanticToggled, + this.semanticSelected, + this.marginAlignment, + this.disableFocusOutline = false, + }); + + const Button.secondary({ + super.key, + this.statesController, + this.leading, + this.trailing, + this.leadingGap, + this.trailingGap, + required this.child, + this.onPressed, + this.focusNode, + this.alignment, + this.enabled, + this.style = const ButtonStyle.secondary(), + this.disableTransition = false, + this.onFocus, + this.onHover, + this.disableHoverEffect = false, + this.enableFeedback, + this.onTapDown, + this.onTapUp, + this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, + this.onTertiaryTapDown, + this.onTertiaryTapUp, + this.onTertiaryTapCancel, + this.onLongPressStart, + this.onLongPressUp, + this.onLongPressMoveUpdate, + this.onLongPressEnd, + this.onSecondaryLongPress, + this.onTertiaryLongPress, + this.semanticLabel, + this.semanticToggled, + this.semanticSelected, + this.marginAlignment, + this.disableFocusOutline = false, + }); + + const Button.outline({ + super.key, + this.statesController, + this.leading, + this.trailing, + this.leadingGap, + this.trailingGap, + required this.child, + this.onPressed, + this.focusNode, + this.alignment, + this.enabled, + this.style = const ButtonStyle.outline(), + this.disableTransition = false, + this.onFocus, + this.onHover, + this.disableHoverEffect = false, + this.enableFeedback, + this.onTapDown, + this.onTapUp, + this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, + this.onTertiaryTapDown, + this.onTertiaryTapUp, + this.onTertiaryTapCancel, + this.onLongPressStart, + this.onLongPressUp, + this.onLongPressMoveUpdate, + this.onLongPressEnd, + this.onSecondaryLongPress, + this.onTertiaryLongPress, + this.semanticLabel, + this.semanticToggled, + this.semanticSelected, + this.marginAlignment, + this.disableFocusOutline = false, + }); + + const Button.ghost({ + super.key, + this.statesController, + this.leading, + this.trailing, + this.leadingGap, + this.trailingGap, + required this.child, + this.onPressed, + this.focusNode, + this.alignment, + this.enabled, + this.style = const ButtonStyle.ghost(), + this.disableTransition = false, + this.onFocus, + this.onHover, + this.disableHoverEffect = false, + this.enableFeedback, + this.onTapDown, + this.onTapUp, + this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, + this.onTertiaryTapDown, + this.onTertiaryTapUp, + this.onTertiaryTapCancel, + this.onLongPressStart, + this.onLongPressUp, + this.onLongPressMoveUpdate, + this.onLongPressEnd, + this.onSecondaryLongPress, + this.onTertiaryLongPress, + this.semanticLabel, + this.semanticToggled, + this.semanticSelected, + this.marginAlignment, + this.disableFocusOutline = false, + }); + + const Button.destructive({ + super.key, + this.statesController, + this.leading, + this.trailing, + this.leadingGap, + this.trailingGap, + required this.child, + this.onPressed, + this.focusNode, + this.alignment, + this.enabled, + this.style = const ButtonStyle.destructive(), + this.disableTransition = false, + this.onFocus, + this.onHover, + this.disableHoverEffect = false, + this.enableFeedback, + this.onTapDown, + this.onTapUp, + this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, + this.onTertiaryTapDown, + this.onTertiaryTapUp, + this.onTertiaryTapCancel, + this.onLongPressStart, + this.onLongPressUp, + this.onLongPressMoveUpdate, + this.onLongPressEnd, + this.onSecondaryLongPress, + this.onTertiaryLongPress, + this.semanticLabel, + this.semanticToggled, + this.semanticSelected, + this.marginAlignment, + this.disableFocusOutline = false, + }); + + @override + State