mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
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:
parent
d4c8162e0e
commit
381afd1d77
@ -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;
|
||||
|
||||
71
lib/desktop/browser_launcher.dart
Normal file
71
lib/desktop/browser_launcher.dart
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
124
lib/desktop/wrapper_sync_routes.dart
Normal file
124
lib/desktop/wrapper_sync_routes.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
110
lib/ui/device_code_dialog.dart
Normal file
110
lib/ui/device_code_dialog.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
54
lib/ui/github_mirror_screen_widget.dart
Normal file
54
lib/ui/github_mirror_screen_widget.dart
Normal 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();
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user