halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps

What every Halkyon app's shell needs, taken from Halkyon Learn's and set
on halkyon_base's client: the instances a device keeps an account on,
the first start's tiles and where an instance keeps people's data, the
account switcher; signing in anonymously, with GitHub or an email code,
switching person and the start screen; the gates that build an app's
dependencies per instance and per person; and the sync service with its
status, Settings card and banner. Texts of its own in English and
German take the app's name; what only the app can say, it passes in.
Fakes for apps' tests are in testing.dart.

An instance's basics now name the app it serves (`app`, set by the
host's configuration), and an app accepts only an instance of its own.
SerialQueue and CoalescingRunner move into halkyon_core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-19 15:18:49 +02:00
commit e23cc77f7c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,259 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' show AppLifecycleState;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
import 'package:halkyon_app/halkyon_app.dart';
void main() {
const gitHubPage = 'https://github.com/login/oauth/authorize?client_id=app';
late int port;
late String callback;
late Completer<Uri> pageOpened;
late int broughtToFront;
late LoopbackWebAuth webAuth;
/// The loopback part with a fake browser that only records the page it
/// opens; [opens] is whether opening it works.
LoopbackWebAuth loopback({
bool opens = true,
Future<void> Function()? bringToFront,
Duration timeout = const Duration(minutes: 1),
}) => LoopbackWebAuth(
appName: 'Halkyon Learn',
launch: (url) {
pageOpened.complete(url);
return Future.value(opens);
},
bringToFront:
bringToFront ??
() {
broughtToFront++;
return Future.value();
},
timeout: timeout,
);
setUp(() async {
// A port that is free right now, so the tests never meet a running app.
final probe = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
port = probe.port;
await probe.close();
callback = 'http://127.0.0.1:$port/github-callback';
pageOpened = Completer();
broughtToFront = 0;
webAuth = loopback();
});
tearDown(() => webAuth.cancel());
/// Starts a sign-in as flutter_web_auth_2 hands it over.
Future<String> signIn() => webAuth.authenticate(
url: gitHubPage,
callbackUrlScheme: callback,
options: const {},
);
/// The browser asking this machine's [address] for [pathAndQuery], as when
/// GitHub sends it back. Over a raw socket, so that the fake HTTP of
/// Flutter's test binding can't get in between.
Future<({int status, String body})> browserAsks(
String pathAndQuery, {
InternetAddress? address,
}) async {
final socket = await Socket.connect(
address ?? InternetAddress.loopbackIPv4,
port,
timeout: const Duration(seconds: 2),
);
socket.write(
'GET $pathAndQuery HTTP/1.1\r\n'
'Host: 127.0.0.1:$port\r\n'
'Connection: close\r\n\r\n',
);
final response = await utf8.decoder.bind(socket).join();
socket.destroy();
return (
status: int.parse(response.split(' ')[1]),
body: response.substring(response.indexOf('\r\n\r\n') + 4),
);
}
test(
"opens GitHub's page and returns where the browser comes back to",
() async {
final result = signIn();
expect(await pageOpened.future, Uri.parse(gitHubPage));
final response = await browserAsks('/github-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
expect(response.status, HttpStatus.ok);
expect(response.body, contains('return to Halkyon Learn'));
expect(broughtToFront, 1);
},
);
test(
"keeps waiting when the app's window regains focus",
() async {
// The way the bug went: flutter_web_auth_2's front ends every pending
// sign-in when the app is resumed, and on the desktop the window
// regaining focus resumes it.
TestWidgetsFlutterBinding.ensureInitialized();
FlutterWebAuth2Platform.instance = webAuth;
final result = FlutterWebAuth2.authenticate(
url: gitHubPage,
callbackUrlScheme: callback,
);
await pageOpened.future;
// The browser takes the focus; then the person looks back at the app.
await _embedderReports(AppLifecycleState.inactive);
await _embedderReports(AppLifecycleState.resumed);
final response = await browserAsks('/github-callback?code=c&state=s');
expect(response.status, HttpStatus.ok);
expect(await result, '$callback?code=c&state=s');
},
// Elsewhere, flutter_web_auth_2 insists on a URL scheme as the callback.
skip: !(Platform.isLinux || Platform.isWindows),
);
test('a cancel ends the wait as cancelled and stops listening', () async {
final result = signIn();
await pageOpened.future;
await webAuth.cancel();
await expectLater(result, throwsA(_cancelled));
await expectLater(
browserAsks('/github-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test('gives up after the timeout and stops listening', () async {
webAuth = loopback(timeout: const Duration(milliseconds: 50));
await expectLater(signIn(), throwsA(isA<TimeoutException>()));
await expectLater(
browserAsks('/github-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test('a second sign-in ends the first as cancelled', () async {
final first = signIn();
await pageOpened.future;
pageOpened = Completer();
final second = signIn();
await expectLater(first, throwsA(_cancelled));
await pageOpened.future;
await browserAsks('/github-callback?code=c&state=s');
expect(await second, '$callback?code=c&state=s');
});
test('answers other paths with "not found" and keeps waiting', () async {
final result = signIn();
await pageOpened.future;
final stray = await browserAsks('/favicon.ico');
final response = await browserAsks('/github-callback?code=c&state=s');
expect(stray.status, HttpStatus.notFound);
expect(response.status, HttpStatus.ok);
expect(await result, '$callback?code=c&state=s');
});
test('listens on the loopback address only', () async {
final otherAddresses = [
for (final interface in await NetworkInterface.list())
for (final address in interface.addresses)
if (!address.isLoopback) address,
];
final result = signIn();
await pageOpened.future;
for (final address in otherAddresses) {
await expectLater(
browserAsks('/github-callback?code=c&state=s', address: address),
throwsA(isA<SocketException>()),
reason: '$address',
);
}
await browserAsks('/github-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
});
test("stops listening when the browser can't be opened", () async {
webAuth = loopback(opens: false);
await expectLater(signIn(), throwsA(isA<PlatformException>()));
await expectLater(
browserAsks('/github-callback?code=c&state=s'),
throwsA(isA<SocketException>()),
);
});
test("signs in even when the window can't come to the front", () async {
webAuth = loopback(
bringToFront: () => Future.error(MissingPluginException()),
);
final result = signIn();
await pageOpened.future;
await browserAsks('/github-callback?code=c&state=s');
expect(await result, '$callback?code=c&state=s');
});
test(
'refuses a callback other than a path on 127.0.0.1 with a port',
() async {
for (final address in [
'http://0.0.0.0:$port/github-callback',
'http://localhost:$port/github-callback',
'http://example.com:$port/github-callback',
'https://127.0.0.1:$port/github-callback',
'http://127.0.0.1/github-callback',
'http://127.0.0.1:$port',
]) {
await expectLater(
webAuth.authenticate(
url: gitHubPage,
callbackUrlScheme: address,
options: const {},
),
throwsArgumentError,
reason: address,
);
}
expect(pageOpened.isCompleted, isFalse);
},
);
}
/// How flutter_web_auth_2 reports a cancelled sign-in, which is how the
/// gateway's callers learn of it.
final _cancelled = isA<PlatformException>().having(
(error) => error.code,
'code',
'CANCELED',
);
/// The embedder reporting the app's lifecycle, as it does when the window
/// gains or loses focus.
Future<void> _embedderReports(AppLifecycleState state) =>
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
SystemChannels.lifecycle.name,
SystemChannels.lifecycle.codec.encodeMessage(state.toString()),
(_) {},
);