From 0ce35ded4fb8cae76dcbb05b86f28bcd213723c3 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 15 Jun 2026 22:38:30 +0200 Subject: [PATCH] Fix device-flow connect: sync immediately and reload settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connecting via the device flow saved the token to prefs but had no effect: the capture screen cached its settings from launch and only re-adopted them when Settings was popped *with a result*, which the connect flow never did. So auto-sync kept using stale (token-less) settings (notes never downloaded) and reopening Settings re-seeded the fields from the stale settings (empty token → Test connection failed, Connect restarted every time). - settings: on a successful connect, save then run a sync right away and report the result ("Connected and synced …") so notes download and the user gets real confirmation, instead of the inert "Token saved on Save". - capture: always reload settings from storage after returning from Settings, so a device-flow connect (which saves without popping a result) is picked up. - tool/device_flow_check.dart: standalone end-to-end device-flow probe used to confirm the OAuth App + token + repo-access chain is healthy (it is); the bug was purely app-side token application. 152 tests, 100% line coverage. Co-Authored-By: Claude Opus 4.8 --- lib/ui/capture_screen.dart | 9 +++- lib/ui/settings_screen.dart | 30 +++++++++++- test/settings_screen_test.dart | 53 +++++++++++++++++++-- tool/device_flow_check.dart | 85 ++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 tool/device_flow_check.dart diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index eb3efe8..181e916 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -175,7 +175,7 @@ class _CaptureScreenState extends State Future _openSettings() async { final current = _settings ?? await SyncSettings.load(); if (!mounted) return; - final result = await Navigator.of(context).push( + await Navigator.of(context).push( MaterialPageRoute( builder: (_) => SettingsScreen( initial: current, @@ -184,7 +184,12 @@ class _CaptureScreenState extends State ), ), ); - if (result != null && mounted) setState(() => _settings = result); + if (!mounted) return; + // Always reload from storage: a device-flow "Connect" saves the token + // without popping a result, so relying on the pop value would miss it and + // leave us syncing with stale (token-less) settings. + final fresh = await SyncSettings.load(); + setState(() => _settings = fresh); } void _openList() { diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index fa053d4..430b27e 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -12,6 +12,7 @@ import '../data/note_repository.dart'; import '../sync/github_client.dart'; import '../sync/github_device_auth.dart'; import '../sync/notes_markdown.dart'; +import '../sync/sync_service.dart'; import '../sync/sync_settings.dart'; /// Settings screen for GitHub sync configuration and note backup. @@ -94,9 +95,10 @@ class _SettingsScreenState extends State { if (token != null && token.isNotEmpty) { setState(() { _token.text = token; - _status = 'Connected via GitHub. Token saved on Save.'; + _status = 'Connected — syncing…'; }); await _current.save(); + await _syncAfterConnect(); } } catch (e) { if (mounted) setState(() => _status = 'Could not start device flow: $e'); @@ -105,6 +107,32 @@ class _SettingsScreenState extends State { } } + /// Runs a sync right after connecting so the user's notes download + /// immediately and they get clear confirmation it worked. + Future _syncAfterConnect() async { + final s = _current; + final client = GitHubClient( + owner: s.owner, + repo: s.repo, + token: s.token, + httpClient: widget.httpClient, + ); + try { + final result = await const SyncService().sync(widget.repository, client); + if (mounted) { + setState( + () => _status = + 'Connected and synced (merged ${result.mergedDevices} ' + 'device(s)). Your notes are up to date.', + ); + } + } catch (e) { + if (mounted) setState(() => _status = 'Connected, but sync failed: $e'); + } finally { + client.close(); + } + } + Future _test() async { setState(() { _testing = true; diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart index 0d63712..f7694c9 100644 --- a/test/settings_screen_test.dart +++ b/test/settings_screen_test.dart @@ -219,11 +219,58 @@ void main() { await tester.pump(); // dialog builds, shows the user code expect(find.text('WXYZ-1234'), findsOneWidget); - // Let the dialog poll (interval 0) and resolve the token. + // Let the dialog poll (interval 0) and resolve the token, then the + // post-connect sync runs against the mock (list → empty, then PUT). await tester.pump(const Duration(milliseconds: 50)); - await tester.pump(); + for (var i = 0; i < 6; i++) { + await tester.pump(); + } - expect(find.textContaining('Connected via GitHub'), findsOneWidget); + expect(find.textContaining('Connected and synced'), findsOneWidget); + }); + + testWidgets('device flow connects but surfaces a post-connect sync failure', ( + tester, + ) async { + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + if (req.url.path.contains('login/oauth/access_token')) { + return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); + } + return http.Response('boom', 500); // the sync's repo calls fail + }); + + await pumpSettings( + tester, + initial: const SyncSettings( + owner: 'o', + repo: 'r', + token: '', + clientId: 'cid', + ), + httpClient: mock, + ); + + await tester.tap(find.text('Connect GitHub')); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + for (var i = 0; i < 6; i++) { + await tester.pump(); + } + + expect(find.textContaining('sync failed'), findsOneWidget); }); testWidgets('Export notes writes the backlog file (desktop)', (tester) async { diff --git a/tool/device_flow_check.dart b/tool/device_flow_check.dart new file mode 100644 index 0000000..1b72fe9 --- /dev/null +++ b/tool/device_flow_check.dart @@ -0,0 +1,85 @@ +// Standalone end-to-end check of the GitHub OAuth device flow used by the app, +// to isolate whether a failure is in GitHub/OAuth-App config or in the app. +// +// Run: dart run tool/device_flow_check.dart +// It prints a user code, you authorize it in the browser, and it reports +// whether polling yields a token and whether that token can read the sync repo. +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +const _clientId = 'Ov23li9tF2R46PqzJgch'; +const _scope = 'repo'; +const _owner = 'kuhyx'; +const _repo = 'todo-sync'; + +Future main() async { + final code = await http.post( + Uri.parse('https://github.com/login/device/code'), + headers: {'Accept': 'application/json'}, + body: {'client_id': _clientId, 'scope': _scope}, + ); + if (code.statusCode != 200) { + stderr.writeln('device/code FAILED ${code.statusCode}: ${code.body}'); + exit(1); + } + final dc = jsonDecode(code.body) as Map; + stdout.writeln( + '>>> Open ${dc['verification_uri']} and enter: ${dc['user_code']}', + ); + stdout.writeln('Polling for the token...'); + + final deviceCode = dc['device_code'] as String; + var interval = (dc['interval'] as int?) ?? 5; + final deadline = DateTime.now().add( + Duration(seconds: (dc['expires_in'] as int?) ?? 900), + ); + + while (DateTime.now().isBefore(deadline)) { + await Future.delayed(Duration(seconds: interval)); + final res = await http.post( + Uri.parse('https://github.com/login/oauth/access_token'), + headers: {'Accept': 'application/json'}, + body: { + 'client_id': _clientId, + 'device_code': deviceCode, + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + }, + ); + final body = jsonDecode(res.body) as Map; + final token = body['access_token'] as String?; + if (token != null) { + stdout.writeln('TOKEN OK (length ${token.length})'); + final repo = await http.get( + Uri.parse('https://api.github.com/repos/$_owner/$_repo'), + headers: { + 'Authorization': 'Bearer $token', + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'todo-app-sync', + }, + ); + stdout.writeln('repo $_owner/$_repo access status: ${repo.statusCode}'); + stdout.writeln( + repo.statusCode == 200 + ? 'CHAIN OK — device flow + token + repo access all work.' + : 'TOKEN CANNOT READ REPO: ${repo.body}', + ); + exit(repo.statusCode == 200 ? 0 : 2); + } + switch (body['error'] as String?) { + case 'authorization_pending': + stdout.write('.'); + case 'slow_down': + interval = (body['interval'] as int?) ?? interval + 5; + case final String e: + stderr.writeln('\nTOKEN ERROR: $e — ${body['error_description']}'); + exit(1); + case null: + stderr.writeln('\nUnexpected: ${res.body}'); + exit(1); + } + } + stderr.writeln('\nExpired before authorization.'); + exit(1); +}