mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 10:03:39 +02:00
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:
parent
f5d79a6a57
commit
0ce35ded4f
@ -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() {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
85
tool/device_flow_check.dart
Normal file
85
tool/device_flow_check.dart
Normal 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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user