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