The Garage SDKs, in the open

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

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