firebase_auth_rest_test.dart 525 -> 153 + _google 158 + _tokens 206 + _uid 141
sync_revisions_test.dart 451 -> 223 + _peers 228 + _backends 158
+ _order 165 + _persistence 215
sync_settings_screen_test 457 -> 207 + _backup 143 + _google 145
mirror_store_test.dart 452 -> 209 + _reads 172 + _fallback 196 + _revisions 244
firebase_client_test.dart 374 -> 198 + _writes 227
firebase_config_test.dart 360 -> 138 + _client 148 + _client_google 164
device_auth_test.dart 307 -> 128 + _poll 201
github_client_test.dart 291 -> 143 + _writes 201
sync_test.dart 269 -> 160 + _skips 175
Two traps this hit, both invisible to a line count:
- _FakePersistence is declared *after* main(), so a naive cut at the last
brace dropped it; sync_revisions_persistence_test.dart failed to load
until it was carried across. Audited every other split file for trailing
top-level declarations — this was the only one.
- Copying the preamble into each sibling left helpers the sibling never
uses. `dart analyze` flagged 12; all removed rather than left as dead
code, and the analyzer is now clean.
crdt_sync_dart 280 passed, sync_settings_ui 29 passed, github_device_auth
analyze clean — all matching a clean HEAD worktree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
|
||
|---|---|---|
| .. | ||
| lib | ||
| scripts | ||
| test | ||
| .gitignore | ||
| analysis_options.yaml | ||
| pubspec.lock | ||
| pubspec.yaml | ||
| README.md | ||
github_device_auth
GitHub OAuth Device Flow for kuhy's Flutter apps: the auth client and the modal that drives it.
Device flow needs only a public client_id (no client secret), which is what
makes it safe for a distributed app. The resulting access token is then used
exactly like a PAT.
Why this is its own package
Four apps each carried a copy of both halves:
| App | Client | Dialog |
|---|---|---|
| todo | lib/sync/github_device_auth.dart (154) |
lib/ui/device_code_dialog.dart (110) |
| home_inventory | lib/sync/github_device_auth.dart (154, byte-identical to todo's) |
inline |
| diet-guard | app/lib/services/github_device_auth.dart (175) |
inline (~84, byte-identical to wake-alarm's) |
| wake-alarm | phone_app/lib/services/github_device_auth.dart (166) |
inline (~84) |
~650 lines, two pairs of them byte-for-byte identical.
It is deliberately not part of sync_settings_ui. That package is
Firebase-only by an explicit decision (2026-08-12), recorded with "do not
re-add a GitHub section without kuhy asking again". Folding a GitHub flow into
it would reverse that. The GitHub mirror in these apps is legacy-but-
load-bearing code, so it gets its own home rather than being merged into the
Firebase path — or deleted, which was never authorised.
Use
dependencies:
github_device_auth:
git:
url: https://github.com/kuhyx/utils
ref: github_device_auth-v0.3.0
path: github_device_auth
final auth = GitHubDeviceAuth(clientId: '<oauth app client id>');
final device = await auth.requestDeviceCode();
final token = await showDialog<String>(
context: context,
builder: (_) => DeviceCodeDialog(device: device, auth: auth),
);
auth.close();
Configurable endpoints (web builds)
deviceCodeUrl / tokenUrl default to GitHub's own URLs, which is right for
mobile and desktop. A web build must point both at a local proxy instead:
GitHub's device-flow endpoints send no CORS headers, so a page cannot call
them at all. diet-guard's desktop web build does exactly that, which is why
this knob exists — it was in diet-guard's local copy and would have been lost
had the package shipped only todo's simpler version.
Transient-network retry
pollForToken retries on ClientException rather than failing. GitHub can
close the connection at the exact moment the user authorizes, and a phone can
drop its network mid-poll — losing an approved grant to a socket blip is a real
bug wake-alarm had already hit and fixed locally. The other three copies had
not, so they inherit the fix here.
ClientException alone is enough and dart:io is deliberately not imported:
this package is used by a web build, and package:http wraps a dead socket as
_ClientSocketException, which implements ClientException — verified, not
assumed.
One behaviour change from the extracted copies
The dialog now catches on Object, not on Exception.
An Error is not an Exception in Dart, so on Exception let ArgumentError
(notably from URI parsing) escape _poll() — leaving the dialog spinning on
"Waiting for authorization…" forever with nothing shown to the user. This is
the same trap that silently killed the Firebase sync tick across these apps in
2026-08. Fixed once here rather than four times. test/device_code_dialog_test.dart
pins it with a mock that throws an Error rather than an Exception.
Commands
flutter pub get
flutter test # 24 tests
flutter test --coverage # 100% of lines
flutter analyze # very_good_analysis, clean