From b9abbf7043ee0b96f8f43a90343e5c442f5f3b70 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 16:16:17 +0200 Subject: [PATCH] phone_app: use the shared github_device_auth package The inline `_DeviceCodeDialog` (86 lines) was byte-for-byte identical to diet-guard's, and `services/github_device_auth.dart` was a fourth copy of the auth client. Both now come from `github_device_auth` in the kuhyx/utils monorepo. This app's client was not merely a drifted copy -- it carried a fix the other three lacked: `pollForToken` swallowed a transient socket failure and kept polling, because GitHub can close the connection at the exact moment the user authorizes. Extracting todo's version would have silently deleted that. The package took this behaviour instead, so the fix now protects all four apps rather than just this one. It is expressed as `on http.ClientException` rather than the local `SocketException` arm, because the package is also imported by diet-guard's **web** build where `dart:io` will not compile. Verified that nothing is lost: package:http wraps a dead socket as `_ClientSocketException`, which implements both types. 19 tests pass, analyze clean, and the release APK is installed on the phone and launching. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno --- phone_app/analysis_options.yaml | 8 + .../lib/screens/github_mirror_screen.dart | 93 +--------- .../lib/services/github_device_auth.dart | 166 ------------------ phone_app/pubspec.lock | 25 ++- phone_app/pubspec.yaml | 5 + 5 files changed, 32 insertions(+), 265 deletions(-) delete mode 100644 phone_app/lib/services/github_device_auth.dart diff --git a/phone_app/analysis_options.yaml b/phone_app/analysis_options.yaml index a6117f6..2712af3 100644 --- a/phone_app/analysis_options.yaml +++ b/phone_app/analysis_options.yaml @@ -10,6 +10,14 @@ analyzer: unnecessary_null_comparison: error dead_code: error invalid_annotation_target: error + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** linter: rules: diff --git a/phone_app/lib/screens/github_mirror_screen.dart b/phone_app/lib/screens/github_mirror_screen.dart index 54f5583..15368a0 100644 --- a/phone_app/lib/screens/github_mirror_screen.dart +++ b/phone_app/lib/screens/github_mirror_screen.dart @@ -1,10 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import 'package:wake_alarm_sync/services/github_device_auth.dart'; +import 'package:github_device_auth/github_device_auth.dart'; import 'package:wake_alarm_sync/services/sync_settings.dart'; /// The GitHub mirror screen: cutover-only sync transport, not a peer of @@ -46,7 +43,7 @@ class _GitHubMirrorScreenState extends State { final token = await showDialog( context: context, barrierDismissible: false, - builder: (_) => _DeviceCodeDialog(device: device, auth: auth), + builder: (_) => DeviceCodeDialog(device: device, auth: auth), ); if (token == null || token.isEmpty) return; @@ -152,89 +149,3 @@ class _GitHubMirrorScreenState extends State { ); } } - -/// Dialog shown while waiting for the user to authorize on GitHub. -class _DeviceCodeDialog extends StatefulWidget { - const _DeviceCodeDialog({required this.device, required this.auth}); - - final DeviceCodeResponse device; - final GitHubDeviceAuth auth; - - @override - State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState(); -} - -class _DeviceCodeDialogState extends State<_DeviceCodeDialog> { - String? _error; - - @override - void initState() { - super.initState(); - unawaited(_poll()); - } - - Future _poll() async { - try { - final token = await widget.auth.pollForToken(widget.device); - if (mounted) Navigator.of(context).pop(token); - } on Exception catch (e) { - if (mounted) setState(() => _error = '$e'); - } - } - - Future _openPage() async { - await Clipboard.setData(ClipboardData(text: widget.device.userCode)); - await launchUrl( - Uri.parse(widget.device.verificationUri), - mode: LaunchMode.externalApplication, - ); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Authorize on GitHub'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Enter this code on GitHub:'), - const SizedBox(height: 8), - SelectableText( - widget.device.userCode, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - if (_error == null) - const Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - SizedBox(width: 12), - Expanded(child: Text('Waiting for authorization…')), - ], - ) - else - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton.icon( - onPressed: _openPage, - icon: const Icon(Icons.open_in_new), - label: const Text('Open GitHub & copy code'), - ), - ], - ); - } -} diff --git a/phone_app/lib/services/github_device_auth.dart b/phone_app/lib/services/github_device_auth.dart deleted file mode 100644 index 5485d52..0000000 --- a/phone_app/lib/services/github_device_auth.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:http/http.dart' as http; - -/// First-stage response of the GitHub OAuth Device Flow. -class DeviceCodeResponse { - /// Creates a [DeviceCodeResponse]. - const DeviceCodeResponse({ - required this.deviceCode, - required this.userCode, - required this.verificationUri, - required this.interval, - required this.expiresIn, - }); - - /// Creates a [DeviceCodeResponse] from a GitHub API JSON response. - factory DeviceCodeResponse.fromJson(Map json) { - return DeviceCodeResponse( - deviceCode: json['device_code'] as String, - userCode: json['user_code'] as String, - verificationUri: json['verification_uri'] as String, - interval: (json['interval'] as int?) ?? 5, - expiresIn: (json['expires_in'] as int?) ?? 900, - ); - } - - /// Opaque code the client polls with (not shown to the user). - final String deviceCode; - - /// Short code the user enters on the verification page. - final String userCode; - - /// Page the user opens to enter [userCode]. - final String verificationUri; - - /// Minimum seconds to wait between polls. - final int interval; - - /// Seconds until [deviceCode] expires. - final int expiresIn; -} - -/// Raised when the device-flow authorization fails or is declined. -class DeviceAuthException implements Exception { - /// Creates a [DeviceAuthException]. - DeviceAuthException(this.code, this.message); - - /// GitHub error code, e.g. `access_denied`, `expired_token`. - final String code; - - /// Human-readable description of the error. - final String message; - - @override - String toString() => 'DeviceAuthException($code): $message'; -} - -/// Implements the GitHub OAuth **Device Flow** so the user can authorize the -/// app by visiting a URL and entering a short code — no token pasting. -/// -/// Device flow needs only a public `client_id` (no client secret), which -/// makes it safe for a distributed app. The resulting access token is then -/// used exactly like a PAT by the crdt_sync GitHub-Contents transport. -class GitHubDeviceAuth { - /// Creates a [GitHubDeviceAuth] instance. - GitHubDeviceAuth({ - required this.clientId, - this.scope = 'repo', - http.Client? httpClient, - Future Function(Duration)? delay, - }) : _http = httpClient ?? http.Client(), - _delay = delay ?? Future.delayed; - - /// OAuth App client ID (public identifier, not a secret). - final String clientId; - - /// OAuth scope requested. `repo` is required for the Contents API to - /// read/write the private `kuhyx/syncs` repo (Gists are no longer used). - final String scope; - - final http.Client _http; - final Future Function(Duration) _delay; - - static const _deviceCodeUrl = - 'https://github.com/login/device/code'; - static const _tokenUrl = - 'https://github.com/login/oauth/access_token'; - static const _grantType = - 'urn:ietf:params:oauth:grant-type:device_code'; - - /// Step 1: ask GitHub for a device + user code. - Future requestDeviceCode() async { - final res = await _http.post( - Uri.parse(_deviceCodeUrl), - headers: const {'Accept': 'application/json'}, - body: {'client_id': clientId, 'scope': scope}, - ); - if (res.statusCode != 200) { - throw DeviceAuthException('http_${res.statusCode}', res.body); - } - return DeviceCodeResponse.fromJson( - jsonDecode(res.body) as Map, - ); - } - - /// Step 2: poll until the user authorizes, returning the access token. - /// - /// Honors GitHub's pacing protocol: `authorization_pending` keeps - /// polling, `slow_down` increases the interval, and terminal errors - /// throw a [DeviceAuthException]. - Future pollForToken(DeviceCodeResponse device) async { - var intervalSeconds = device.interval; - final deadline = DateTime.now().add( - Duration(seconds: device.expiresIn), - ); - - while (DateTime.now().isBefore(deadline)) { - await _delay(Duration(seconds: intervalSeconds)); - http.Response res; - try { - res = await _http.post( - Uri.parse(_tokenUrl), - headers: const {'Accept': 'application/json'}, - body: { - 'client_id': clientId, - 'device_code': device.deviceCode, - 'grant_type': _grantType, - }, - ); - } on http.ClientException catch (_) { - // GitHub can close the connection after the user authorizes; retry. - continue; - } on SocketException catch (_) { - continue; - } - final json = jsonDecode(res.body) as Map; - - final token = json['access_token'] as String?; - if (token != null) return token; - - switch (json['error'] as String?) { - case 'authorization_pending': - continue; - case 'slow_down': - intervalSeconds = - (json['interval'] as int?) ?? intervalSeconds + 5; - case final String error: - throw DeviceAuthException( - error, - (json['error_description'] as String?) ?? error, - ); - case null: - throw DeviceAuthException( - 'unknown', - 'Unexpected response: $json', - ); - } - } - throw DeviceAuthException('expired_token', 'Device code expired.'); - } - - /// Closes the underlying HTTP client. - void close() => _http.close(); -} diff --git a/phone_app/pubspec.lock b/phone_app/pubspec.lock index 42904c2..d1236f0 100644 --- a/phone_app/pubspec.lock +++ b/phone_app/pubspec.lock @@ -217,6 +217,15 @@ packages: description: flutter source: sdk version: "0.0.0" + github_device_auth: + dependency: "direct main" + description: + path: github_device_auth + ref: "github_device_auth-v0.3.0" + resolved-ref: "254e3b00c7f41a54c588ec32564017f96f328252" + url: "https://github.com/kuhyx/utils" + source: git + version: "0.3.0" google_identity_services_web: dependency: transitive description: @@ -365,10 +374,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -381,10 +390,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" objective_c: dependency: transitive description: @@ -619,10 +628,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" typed_data: dependency: transitive description: @@ -707,10 +716,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" very_good_analysis: dependency: "direct dev" description: diff --git a/phone_app/pubspec.yaml b/phone_app/pubspec.yaml index db0ba32..b18cbf8 100644 --- a/phone_app/pubspec.yaml +++ b/phone_app/pubspec.yaml @@ -21,6 +21,11 @@ dependencies: # One-tap sign-in for the sync account. App-side only: crdt_sync stays pure # Dart so it keeps working on Linux desktop and headless. + github_device_auth: + git: + url: https://github.com/kuhyx/utils + ref: github_device_auth-v0.3.0 + path: github_device_auth google_sign_in: ^7.2.0 http: ^1.2.0 path: ^1.9.0