Fix device-flow connect: sync immediately and reload settings

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 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-15 22:38:30 +02:00
parent f5d79a6a57
commit 0ce35ded4f
4 changed files with 171 additions and 6 deletions

View File

@ -175,7 +175,7 @@ class _CaptureScreenState extends State<CaptureScreen>
Future<void> _openSettings() async {
final current = _settings ?? await SyncSettings.load();
if (!mounted) return;
final result = await Navigator.of(context).push<SyncSettings>(
await Navigator.of(context).push<SyncSettings>(
MaterialPageRoute(
builder: (_) => SettingsScreen(
initial: current,
@ -184,7 +184,12 @@ class _CaptureScreenState extends State<CaptureScreen>
),
),
);
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() {

View File

@ -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<SettingsScreen> {
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<SettingsScreen> {
}
}
/// Runs a sync right after connecting so the user's notes download
/// immediately and they get clear confirmation it worked.
Future<void> _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<void> _test() async {
setState(() {
_testing = true;

View File

@ -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 {

View File

@ -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<void> 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<String, dynamic>;
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<void>.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<String, dynamic>;
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);
}