From 381afd1d77a7412f79c9c67d67ff7a39a5bfd725 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 14 Aug 2026 16:29:35 +0200 Subject: [PATCH] Split the last three oversized lib/bin files github_mirror_screen.dart 368 -> 232 + device_code_dialog.dart (109) and a widget part wrapper_server.dart 336 -> 223 + wrapper_sync_routes.dart, the two credential-provisioning routes bin/todo_desktop.dart 253 -> 199 + lib/desktop/browser_launcher.dart The fixed port moved into browser_launcher.dart as kWrapperPort, with the entry point aliasing it, so the "must not move" reason is stated once next to the code that acts on it. lib/ and bin/ are now entirely under the cap. The two remaining analyze infos (bin/todo_desktop.dart cascade_invocations, capture_screen_sync.dart unnecessary_unawaited) are pre-existing -- verified by analyzing e0eb8b9 in a worktree, which reports the same two. Verified: 313 tests pass, coverage unchanged. Co-Authored-By: Claude Opus 4.8 --- bin/todo_desktop.dart | 72 ++---------- lib/desktop/browser_launcher.dart | 71 ++++++++++++ lib/desktop/wrapper_server.dart | 117 +------------------ lib/desktop/wrapper_sync_routes.dart | 124 +++++++++++++++++++++ lib/ui/device_code_dialog.dart | 110 ++++++++++++++++++ lib/ui/github_mirror_screen.dart | 142 +----------------------- lib/ui/github_mirror_screen_widget.dart | 54 +++++++++ pubspec.yaml | 2 +- 8 files changed, 374 insertions(+), 318 deletions(-) create mode 100644 lib/desktop/browser_launcher.dart create mode 100644 lib/desktop/wrapper_sync_routes.dart create mode 100644 lib/ui/device_code_dialog.dart create mode 100644 lib/ui/github_mirror_screen_widget.dart diff --git a/bin/todo_desktop.dart b/bin/todo_desktop.dart index 96f98bd..8576374 100644 --- a/bin/todo_desktop.dart +++ b/bin/todo_desktop.dart @@ -6,13 +6,13 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import 'package:todo/desktop/browser_launcher.dart'; import 'package:todo/desktop/port_guard.dart'; import 'package:todo/desktop/wrapper_server.dart'; -/// Port must stay fixed: the browser keys `localStorage` (the GitHub token) and -/// IndexedDB (the notes) by origin, so a different port looks like a different -/// app with no token and no notes. Kept in step with lib/sync/desktop_wrapper. -const _port = 8730; +/// The fixed wrapper port; see [kWrapperPort] for why it cannot move. +/// Kept in step with lib/sync/desktop_wrapper. +const int _port = kWrapperPort; /// `errno` for EADDRINUSE. The wrapper is Linux-only (the desktop app is the /// web build served locally), so the numeric value is stable here. @@ -69,7 +69,7 @@ Future main(List args) async { // A bare flag, not a valued option: requiring a dummy value meant passing a // stray positional, which the AOT runtime tries to interpret as a snapshot. if (!args.contains('--no-browser')) { - final ranLongEnough = await _launchBrowser(home); + final ranLongEnough = await launchBrowser(home); if (!ranLongEnough) { // Chrome exits immediately when it hands the URL to an instance that // already owns the profile directory (or when a stale SingletonLock is @@ -113,7 +113,7 @@ Future _recoverPort( String home, List args, ) async { - final guard = PortGuard(profileDir: _profileDir(home)); + final guard = PortGuard(profileDir: profileDir(home)); switch (await guard.resolve(_port)) { case AbortWithOwner(:final description): stderr.writeln('port $_port is already in use by $description.'); @@ -138,7 +138,7 @@ Future _recoverPort( 'todo is already running (pid $pid)' '${opening ? '; opening a window.' : '.'}', ); - if (opening) await _launchBrowser(home); + if (opening) await launchBrowser(home); return false; case ReapThenBind(:final pid): stdout.writeln('clearing a stale todo wrapper (pid $pid) on port $_port'); @@ -154,17 +154,6 @@ Future _recoverPort( exit(1); } -/// Browser profile directory for the desktop app. -/// -/// Must stay stable: the GitHub token (localStorage) and the notes (IndexedDB) -/// live inside it, so a changing path silently logs the user out and hides -/// their notes. It doubles as the marker for "a window is still attached". -String _profileDir(String home) => - p.join(home, '.local', 'share', 'todo-desktop', 'profile'); - -/// Launches the app in a Chrome-family browser with a **stable** profile -/// directory, since the token and notes live in that profile. -/// Returns true when the browser ran long enough to have owned the session. /// Loads the app once headlessly so it provisions its own sync account. /// /// The account lives in origin-keyed localStorage, which only code running in @@ -173,7 +162,7 @@ String _profileDir(String home) => /// /// Returns whether the account is present afterwards. Future _provisionSyncAccount(String home) async { - final browser = _findBrowser(); + final browser = findBrowser(); if (browser.isEmpty) { stderr.writeln('No Chrome-family browser found; cannot provision.'); return false; @@ -185,7 +174,7 @@ Future _provisionSyncAccount(String home) async { final process = await Process.start(browser, [ '--headless=new', '--disable-gpu', - '--user-data-dir=${_profileDir(home)}', + '--user-data-dir=${profileDir(home)}', // Surface the page's own log() output, so a failure inside the app is // visible in the terminal that started the provisioning run. '--enable-logging=stderr', @@ -203,49 +192,6 @@ Future _provisionSyncAccount(String home) async { return true; } -/// Returns the first Chrome-family browser on this machine, or ''. -String _findBrowser() { - // Ordered by preference, and deliberately broad: this machine runs Thorium - // behind /opt/google/chrome, and has a policy that uninstalls the `chromium` - // package, so assuming any single browser is wrong. TODO_BROWSER overrides. - final candidates = [ - Platform.environment['TODO_BROWSER'] ?? '', - '/opt/google/chrome/chrome', - '/opt/thorium-browser/thorium-browser', - '/usr/bin/google-chrome-stable', - '/usr/bin/chromium', - '/usr/bin/brave', - ]; - return candidates.firstWhere( - (path) => path.isNotEmpty && File(path).existsSync(), - orElse: () => '', - ); -} - -Future _launchBrowser(String home) async { - final browser = _findBrowser(); - if (browser.isEmpty) { - stderr.writeln( - 'No Chrome-family browser found; open ' - 'http://localhost:$_port manually.', - ); - return false; - } - final process = await Process.start(browser, [ - '--app=http://localhost:$_port', - '--user-data-dir=${_profileDir(home)}', - // Sets WM_CLASS, which the .desktop entry matches on via StartupWMClass. - // Without it the window inherits the browser's class and the taskbar shows - // a browser icon instead of todo's. - '--class=todo', - '--no-first-run', - ]); - - final started = DateTime.now(); - await process.exitCode; - return DateTime.now().difference(started) > const Duration(seconds: 5); -} - String? _argValue(List args, String name) { final index = args.indexOf(name); if (index == -1 || index + 1 >= args.length) return null; diff --git a/lib/desktop/browser_launcher.dart b/lib/desktop/browser_launcher.dart new file mode 100644 index 0000000..274f90f --- /dev/null +++ b/lib/desktop/browser_launcher.dart @@ -0,0 +1,71 @@ +/// Finding and launching the Chrome window the desktop app runs in. +/// +/// Split out of `bin/todo_desktop.dart` for file size. The profile directory +/// is fixed on purpose: the GitHub token (localStorage) and the notes +/// (IndexedDB) are keyed by origin and live in that profile, so a moving +/// profile dir would silently log the user out and hide their notes. +library; + +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Port must stay fixed: the browser keys `localStorage` (the GitHub token) +/// and IndexedDB (the notes) by origin, so a different port looks like a +/// different app with no token and no notes. +const kWrapperPort = 8730; + +/// Browser profile directory for the desktop app. +/// +/// Must stay stable: the GitHub token (localStorage) and the notes (IndexedDB) +/// live inside it, so a changing path silently logs the user out and hides +/// their notes. It doubles as the marker for "a window is still attached". +String profileDir(String home) => + p.join(home, '.local', 'share', 'todo-desktop', 'profile'); + +/// Returns the first Chrome-family browser on this machine, or ''. +String findBrowser() { + // Ordered by preference, and deliberately broad: this machine runs Thorium + // behind /opt/google/chrome, and has a policy that uninstalls the `chromium` + // package, so assuming any single browser is wrong. TODO_BROWSER overrides. + final candidates = [ + Platform.environment['TODO_BROWSER'] ?? '', + '/opt/google/chrome/chrome', + '/opt/thorium-browser/thorium-browser', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/brave', + ]; + return candidates.firstWhere( + (path) => path.isNotEmpty && File(path).existsSync(), + orElse: () => '', + ); +} + +/// Launches the app in a Chrome-family browser with a **stable** profile +/// directory, since the token and notes live in that profile. +/// +/// Returns true when the browser ran long enough to have owned the session. +Future launchBrowser(String home) async { + final browser = findBrowser(); + if (browser.isEmpty) { + stderr.writeln( + 'No Chrome-family browser found; open ' + 'http://localhost:$kWrapperPort manually.', + ); + return false; + } + final process = await Process.start(browser, [ + '--app=http://localhost:$kWrapperPort', + '--user-data-dir=${profileDir(home)}', + // Sets WM_CLASS, which the .desktop entry matches on via StartupWMClass. + // Without it the window inherits the browser's class and the taskbar shows + // a browser icon instead of todo's. + '--class=todo', + '--no-first-run', + ]); + + final started = DateTime.now(); + await process.exitCode; + return DateTime.now().difference(started) > const Duration(seconds: 5); +} diff --git a/lib/desktop/wrapper_server.dart b/lib/desktop/wrapper_server.dart index e775d12..fcaaf33 100644 --- a/lib/desktop/wrapper_server.dart +++ b/lib/desktop/wrapper_server.dart @@ -5,6 +5,8 @@ import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:path/path.dart' as p; +part 'wrapper_sync_routes.dart'; + /// Route serving `todo`'s own seeded Firebase session (a `FirebaseCredentials` /// JSON blob), alongside [kSyncAccountPath]'s email/password pair. /// @@ -140,121 +142,6 @@ class WrapperServer { return _static(request, path); } - /// Serves the shared sync account so a desktop install can self-provision. - /// - /// Off unless CRDT_SYNC_SERVE_ACCOUNT is set: this hands out a credential - /// with database write access to anything that can reach the port, so it is - /// something you switch on once to set an install up, not a standing route. - /// 404 (rather than 403) when disabled, so a probe cannot tell the route - /// exists. - /// - /// The account is read from the same ~/.config/crdt-sync/ pair the Python - /// daemons already use, so there is one source of truth per machine rather - /// than a per-app copy. - Future _syncAccount(HttpRequest request) async { - if (!serveSyncAccount) { - request.response.statusCode = HttpStatus.notFound; - return; - } - final configFile = File(p.join(syncConfigDir, 'firebase.json')); - final passwordFile = File(p.join(syncConfigDir, 'password')); - if (!configFile.existsSync() || !passwordFile.existsSync()) { - stderr.writeln( - 'Sync account requested but ~/.config/crdt-sync/ is incomplete — ' - 'the desktop app will keep asking for credentials.', - ); - request.response.statusCode = HttpStatus.notFound; - return; - } - final email = - (jsonDecode(await configFile.readAsString()) - as Map)['email']; - if (email is! String || email.isEmpty) { - stderr.writeln('firebase.json has no usable "email" — not serving it.'); - request.response.statusCode = HttpStatus.notFound; - return; - } - request.response.headers.contentType = ContentType.json; - request.response.write( - jsonEncode({ - 'email': email, - 'password': (await passwordFile.readAsString()).trim(), - }), - ); - } - - /// Serves `todo`'s own seeded Firebase session, so a fresh desktop install - /// can authenticate without ever seeing a password field and without a - /// human setting an env var first. - /// - /// Reachable by default (unlike [_syncAccount], which stays behind - /// [serveSyncAccount]/[kSyncAccountEnvVar]) but only until the first - /// successful serve: this hands out a credential with database write - /// access to anything that can reach the port, shared with five other - /// apps, so the window it is actually reachable in must be as small as - /// "until the app finishes booting", not "for as long as the wrapper - /// runs". 404 (not 403) both when already served and when disabled, so a - /// probe cannot tell which case it hit. - /// - /// Consumed once by `loadAccount()`'s wrapper fallback and written - /// straight into the keystore; never a standing source the app re-reads on - /// every launch. That matters here specifically: `seed_session.py` may - /// later rotate this file's refresh token for an unrelated app's re-seed - /// run, and Firebase invalidates the previous token when it does -- - /// serving a stale token to a device that already adopted the live one - /// would just hand back a token neither side can use. - Future _syncCredentials(HttpRequest request) async { - if (_todoCredentialsServed) { - request.response.statusCode = HttpStatus.notFound; - return; - } - final credentialsFile = File(todoCredentialsPath); - if (!credentialsFile.existsSync()) { - request.response.statusCode = HttpStatus.notFound; - return; - } - final Map credentials; - try { - credentials = - jsonDecode(await credentialsFile.readAsString()) - as Map; - } on FormatException { - stderr.writeln( - '$todoCredentialsPath is not valid JSON — not serving it.', - ); - request.response.statusCode = HttpStatus.notFound; - return; - } - if (credentials['id_token'] is! String || - credentials['refresh_token'] is! String || - credentials['expires_at'] is! String) { - stderr.writeln( - '$todoCredentialsPath is missing id_token/refresh_token/expires_at ' - '— not serving it.', - ); - request.response.statusCode = HttpStatus.notFound; - return; - } - // The email is a display nicety (so Settings shows an address instead of - // a blank), not a credential -- read from the shared config, not from - // todoCredentialsPath, which has no email field of its own. - String? email; - final configFile = File(p.join(syncConfigDir, 'firebase.json')); - if (configFile.existsSync()) { - final config = - jsonDecode(await configFile.readAsString()) as Map; - final configEmail = config['email']; - if (configEmail is String && configEmail.isNotEmpty) { - email = configEmail; - } - } - request.response.headers.contentType = ContentType.json; - request.response.write(jsonEncode({...credentials, 'email': email})); - // After, not before writing the response: a request that fails partway - // through must remain retryable rather than burning the one serve. - _todoCredentialsServed = true; - } - /// GET returns the file's contents (404 when absent); POST overwrites it. Future _file(HttpRequest request, String filePath) async { final file = File(filePath); diff --git a/lib/desktop/wrapper_sync_routes.dart b/lib/desktop/wrapper_sync_routes.dart new file mode 100644 index 0000000..d55bb0c --- /dev/null +++ b/lib/desktop/wrapper_sync_routes.dart @@ -0,0 +1,124 @@ +/// The wrapper's two credential-provisioning routes. +/// +/// Split out of `wrapper_server.dart` for file size. Both serve a file from +/// the desktop's own config directory to the web app running in Chrome -- +/// the seam that lets a desktop install provision itself instead of the +/// account being retyped per machine. +part of 'wrapper_server.dart'; + +extension _SyncRoutes on WrapperServer { + /// Serves the shared sync account so a desktop install can self-provision. + /// + /// Off unless CRDT_SYNC_SERVE_ACCOUNT is set: this hands out a credential + /// with database write access to anything that can reach the port, so it is + /// something you switch on once to set an install up, not a standing route. + /// 404 (rather than 403) when disabled, so a probe cannot tell the route + /// exists. + /// + /// The account is read from the same ~/.config/crdt-sync/ pair the Python + /// daemons already use, so there is one source of truth per machine rather + /// than a per-app copy. + Future _syncAccount(HttpRequest request) async { + if (!serveSyncAccount) { + request.response.statusCode = HttpStatus.notFound; + return; + } + final configFile = File(p.join(syncConfigDir, 'firebase.json')); + final passwordFile = File(p.join(syncConfigDir, 'password')); + if (!configFile.existsSync() || !passwordFile.existsSync()) { + stderr.writeln( + 'Sync account requested but ~/.config/crdt-sync/ is incomplete — ' + 'the desktop app will keep asking for credentials.', + ); + request.response.statusCode = HttpStatus.notFound; + return; + } + final email = + (jsonDecode(await configFile.readAsString()) + as Map)['email']; + if (email is! String || email.isEmpty) { + stderr.writeln('firebase.json has no usable "email" — not serving it.'); + request.response.statusCode = HttpStatus.notFound; + return; + } + request.response.headers.contentType = ContentType.json; + request.response.write( + jsonEncode({ + 'email': email, + 'password': (await passwordFile.readAsString()).trim(), + }), + ); + } + + /// Serves `todo`'s own seeded Firebase session, so a fresh desktop install + /// can authenticate without ever seeing a password field and without a + /// human setting an env var first. + /// + /// Reachable by default (unlike [_syncAccount], which stays behind + /// [serveSyncAccount]/[kSyncAccountEnvVar]) but only until the first + /// successful serve: this hands out a credential with database write + /// access to anything that can reach the port, shared with five other + /// apps, so the window it is actually reachable in must be as small as + /// "until the app finishes booting", not "for as long as the wrapper + /// runs". 404 (not 403) both when already served and when disabled, so a + /// probe cannot tell which case it hit. + /// + /// Consumed once by `loadAccount()`'s wrapper fallback and written + /// straight into the keystore; never a standing source the app re-reads on + /// every launch. That matters here specifically: `seed_session.py` may + /// later rotate this file's refresh token for an unrelated app's re-seed + /// run, and Firebase invalidates the previous token when it does -- + /// serving a stale token to a device that already adopted the live one + /// would just hand back a token neither side can use. + Future _syncCredentials(HttpRequest request) async { + if (_todoCredentialsServed) { + request.response.statusCode = HttpStatus.notFound; + return; + } + final credentialsFile = File(todoCredentialsPath); + if (!credentialsFile.existsSync()) { + request.response.statusCode = HttpStatus.notFound; + return; + } + final Map credentials; + try { + credentials = + jsonDecode(await credentialsFile.readAsString()) + as Map; + } on FormatException { + stderr.writeln( + '$todoCredentialsPath is not valid JSON — not serving it.', + ); + request.response.statusCode = HttpStatus.notFound; + return; + } + if (credentials['id_token'] is! String || + credentials['refresh_token'] is! String || + credentials['expires_at'] is! String) { + stderr.writeln( + '$todoCredentialsPath is missing id_token/refresh_token/expires_at ' + '— not serving it.', + ); + request.response.statusCode = HttpStatus.notFound; + return; + } + // The email is a display nicety (so Settings shows an address instead of + // a blank), not a credential -- read from the shared config, not from + // todoCredentialsPath, which has no email field of its own. + String? email; + final configFile = File(p.join(syncConfigDir, 'firebase.json')); + if (configFile.existsSync()) { + final config = + jsonDecode(await configFile.readAsString()) as Map; + final configEmail = config['email']; + if (configEmail is String && configEmail.isNotEmpty) { + email = configEmail; + } + } + request.response.headers.contentType = ContentType.json; + request.response.write(jsonEncode({...credentials, 'email': email})); + // After, not before writing the response: a request that fails partway + // through must remain retryable rather than burning the one serve. + _todoCredentialsServed = true; + } +} diff --git a/lib/ui/device_code_dialog.dart b/lib/ui/device_code_dialog.dart new file mode 100644 index 0000000..0021cee --- /dev/null +++ b/lib/ui/device_code_dialog.dart @@ -0,0 +1,110 @@ +/// The GitHub device-flow dialog: shows the user code, opens the page, and +/// polls until the grant is approved. +/// +/// Split out of `github_mirror_screen.dart` for file size; public only +/// because Dart privacy is library-scoped. +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:todo/sync/github_device_auth.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Dialog shown during the device flow: displays the user code, opens the +/// verification page, and polls until authorized — popping the token (or +/// null if cancelled / failed). +class DeviceCodeDialog extends StatefulWidget { + /// Creates the dialog for a pending device-flow grant. + const DeviceCodeDialog({ + required this.device, + required this.auth, + super.key, + }); + + /// The pending grant: user code, verification URL, poll interval. + final DeviceCodeResponse device; + + /// Auth client used to poll for the approved token. + final GitHubDeviceAuth auth; + + @override + State createState() => _DeviceCodeDialogState(); +} + +class _DeviceCodeDialogState extends State { + 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/lib/ui/github_mirror_screen.dart b/lib/ui/github_mirror_screen.dart index 49b26a9..cfab411 100644 --- a/lib/ui/github_mirror_screen.dart +++ b/lib/ui/github_mirror_screen.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:todo/analytics/analytics_service.dart'; import 'package:todo/data/app_settings.dart'; @@ -10,56 +9,9 @@ import 'package:todo/data/note_repository.dart'; import 'package:todo/sync/github_device_auth.dart'; import 'package:todo/sync/run_sync.dart'; import 'package:todo/sync/sync_settings.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:todo/ui/device_code_dialog.dart'; -/// The GitHub mirror screen: cutover-only sync transport, not a peer of -/// Firebase. -/// -/// Kept app-local rather than folded into the shared `sync_settings_ui` -/// package because connecting here also triggers an actual note sync via -/// [runSync] -- unlike the shared package's Firebase/Backup sections, which -/// only save settings. See `lib/ui/settings_screen.dart` for the "Enable -/// advanced" toggle and the link to this screen and to the shared Sync -/// settings screen. -class GitHubMirrorScreen extends StatefulWidget { - /// Creates a [GitHubMirrorScreen] pre-filled with [initial] settings. - const GitHubMirrorScreen({ - required this.initial, - required this.repository, - required this.appSettings, - this.analytics, - this.httpClient, - this.firebaseFactory, - this.stateStore, - super.key, - }); - - /// The sync settings loaded when this screen was opened. - final SyncSettings initial; - - /// The store a post-connect sync reads from and writes to. - final NoteRepository repository; - - /// App-wide preferences, reconciled after a post-connect sync. - final ValueNotifier appSettings; - - /// Interaction-only usage analytics. Null in tests that don't exercise it. - final AnalyticsService? analytics; - - /// Optional HTTP client for the GitHub calls (test-connection and device - /// flow). Injected by tests; production uses each client's default. - final http.Client? httpClient; - - /// Builds the Firebase backend for the post-connect sync. Injected so - /// tests can supply a fake. - final Future Function()? firebaseFactory; - - /// Revision cache. Injected so tests need no application-support directory. - final SyncStateStore? stateStore; - - @override - State createState() => _GitHubMirrorScreenState(); -} +part 'github_mirror_screen_widget.dart'; class _GitHubMirrorScreenState extends State { late final TextEditingController _owner = TextEditingController( @@ -111,7 +63,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.isNotEmpty) { setState(() { @@ -278,91 +230,3 @@ class _GitHubMirrorScreenState extends State { ); } } - -/// Dialog shown during the device flow: displays the user code, opens the -/// verification page, and polls until authorized — popping the token (or -/// null if cancelled / failed). -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/lib/ui/github_mirror_screen_widget.dart b/lib/ui/github_mirror_screen_widget.dart new file mode 100644 index 0000000..36e9e05 --- /dev/null +++ b/lib/ui/github_mirror_screen_widget.dart @@ -0,0 +1,54 @@ +/// The [GitHubMirrorScreen] widget's public surface. +/// +/// A `part` rather than a separate library because `createState` returns a +/// private class, so the widget and its State cannot be split apart. +part of 'github_mirror_screen.dart'; + +/// The GitHub mirror screen: cutover-only sync transport, not a peer of +/// Firebase. +/// +/// Kept app-local rather than folded into the shared `sync_settings_ui` +/// package because connecting here also triggers an actual note sync via +/// [runSync] -- unlike the shared package's Firebase/Backup sections, which +/// only save settings. See `lib/ui/settings_screen.dart` for the "Enable +/// advanced" toggle and the link to this screen and to the shared Sync +/// settings screen. +class GitHubMirrorScreen extends StatefulWidget { + /// Creates a [GitHubMirrorScreen] pre-filled with [initial] settings. + const GitHubMirrorScreen({ + required this.initial, + required this.repository, + required this.appSettings, + this.analytics, + this.httpClient, + this.firebaseFactory, + this.stateStore, + super.key, + }); + + /// The sync settings loaded when this screen was opened. + final SyncSettings initial; + + /// The store a post-connect sync reads from and writes to. + final NoteRepository repository; + + /// App-wide preferences, reconciled after a post-connect sync. + final ValueNotifier appSettings; + + /// Interaction-only usage analytics. Null in tests that don't exercise it. + final AnalyticsService? analytics; + + /// Optional HTTP client for the GitHub calls (test-connection and device + /// flow). Injected by tests; production uses each client's default. + final http.Client? httpClient; + + /// Builds the Firebase backend for the post-connect sync. Injected so + /// tests can supply a fake. + final Future Function()? firebaseFactory; + + /// Revision cache. Injected so tests need no application-support directory. + final SyncStateStore? stateStore; + + @override + State createState() => _GitHubMirrorScreenState(); +} diff --git a/pubspec.yaml b/pubspec.yaml index 29788be..86c9ece 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.13+1 +version: 1.0.14+1 environment: sdk: ^3.12.2