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 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-14 16:29:35 +02:00
parent d4c8162e0e
commit 381afd1d77
8 changed files with 374 additions and 318 deletions

View File

@ -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<void> main(List<String> 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<bool> _recoverPort(
String home,
List<String> 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<bool> _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<bool> _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<bool> _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<bool> _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<bool> _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<bool> _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<String> args, String name) {
final index = args.indexOf(name);
if (index == -1 || index + 1 >= args.length) return null;

View File

@ -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<bool> 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);
}

View File

@ -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<void> _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<String, dynamic>)['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<void> _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<String, dynamic> credentials;
try {
credentials =
jsonDecode(await credentialsFile.readAsString())
as Map<String, dynamic>;
} 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<String, dynamic>;
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<void> _file(HttpRequest request, String filePath) async {
final file = File(filePath);

View File

@ -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<void> _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<String, dynamic>)['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<void> _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<String, dynamic> credentials;
try {
credentials =
jsonDecode(await credentialsFile.readAsString())
as Map<String, dynamic>;
} 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<String, dynamic>;
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;
}
}

View File

@ -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<DeviceCodeDialog> createState() => _DeviceCodeDialogState();
}
class _DeviceCodeDialogState extends State<DeviceCodeDialog> {
String? _error;
@override
void initState() {
super.initState();
unawaited(_poll());
}
Future<void> _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<void> _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'),
),
],
);
}
}

View File

@ -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> 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<FirebaseRestClient?> Function()? firebaseFactory;
/// Revision cache. Injected so tests need no application-support directory.
final SyncStateStore? stateStore;
@override
State<GitHubMirrorScreen> createState() => _GitHubMirrorScreenState();
}
part 'github_mirror_screen_widget.dart';
class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
late final TextEditingController _owner = TextEditingController(
@ -111,7 +63,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
final token = await showDialog<String>(
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<GitHubMirrorScreen> {
);
}
}
/// 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<void> _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<void> _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'),
),
],
);
}
}

View File

@ -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> 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<FirebaseRestClient?> Function()? firebaseFactory;
/// Revision cache. Injected so tests need no application-support directory.
final SyncStateStore? stateStore;
@override
State<GitHubMirrorScreen> createState() => _GitHubMirrorScreenState();
}

View File

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