feat(useragent): added connection info setup screen

This commit is contained in:
hdbg
2026-03-15 14:51:39 +01:00
parent 16d5b9a233
commit ec0e8a980c
25 changed files with 800 additions and 2441 deletions

View File

@@ -0,0 +1,56 @@
import 'dart:convert';
class ArbiterUrl {
const ArbiterUrl({
required this.host,
required this.port,
required this.caCert,
this.bootstrapToken,
});
final String host;
final int port;
final List<int> caCert;
final String? bootstrapToken;
static const _scheme = 'arbiter';
static const _certQueryKey = 'cert';
static const _bootstrapTokenQueryKey = 'bootstrap_token';
static ArbiterUrl parse(String value) {
final uri = Uri.tryParse(value);
if (uri == null || uri.scheme != _scheme) {
throw const FormatException("Invalid URL scheme, expected 'arbiter://'");
}
if (uri.host.isEmpty) {
throw const FormatException('Missing host in URL');
}
if (!uri.hasPort) {
throw const FormatException('Missing port in URL');
}
final cert = uri.queryParameters[_certQueryKey];
if (cert == null || cert.isEmpty) {
throw const FormatException("Missing 'cert' query parameter in URL");
}
final decodedCert = _decodeCert(cert);
return ArbiterUrl(
host: uri.host,
port: uri.port,
caCert: decodedCert,
bootstrapToken: uri.queryParameters[_bootstrapTokenQueryKey],
);
}
static List<int> _decodeCert(String cert) {
try {
return base64Url.decode(base64Url.normalize(cert));
} on FormatException catch (error) {
throw FormatException("Invalid base64 in 'cert' query parameter: ${error.message}");
}
}
}

View File

@@ -0,0 +1,3 @@
class Connection {}

View File

@@ -1,6 +1,3 @@
import 'package:flutter/services.dart';
enum KeyAlgorithm {
rsa, ecdsa, ed25519
}
@@ -16,4 +13,4 @@ abstract class KeyHandle {
abstract class KeyManager {
Future<KeyHandle?> get();
Future<KeyHandle> create();
}
}

View File

@@ -0,0 +1,67 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class StoredServerInfo {
const StoredServerInfo({
required this.address,
required this.port,
required this.caCertFingerprint,
});
final String address;
final int port;
final String caCertFingerprint;
Map<String, dynamic> toJson() => {
'address': address,
'port': port,
'caCertFingerprint': caCertFingerprint,
};
factory StoredServerInfo.fromJson(Map<String, dynamic> json) {
return StoredServerInfo(
address: json['address'] as String,
port: json['port'] as int,
caCertFingerprint: json['caCertFingerprint'] as String,
);
}
}
abstract class ServerInfoStorage {
Future<StoredServerInfo?> load();
Future<void> save(StoredServerInfo serverInfo);
Future<void> clear();
}
class SecureServerInfoStorage implements ServerInfoStorage {
static const _storageKey = 'server_info';
const SecureServerInfoStorage();
static const _storage = FlutterSecureStorage();
@override
Future<StoredServerInfo?> load() async {
final rawValue = await _storage.read(key: _storageKey);
if (rawValue == null) {
return null;
}
final decoded = jsonDecode(rawValue) as Map<String, dynamic>;
return StoredServerInfo.fromJson(decoded);
}
@override
Future<void> save(StoredServerInfo serverInfo) {
return _storage.write(
key: _storageKey,
value: jsonEncode(serverInfo.toJson()),
);
}
@override
Future<void> clear() {
return _storage.delete(key: _storageKey);
}
}

View File

@@ -1,14 +1,35 @@
import 'package:arbiter/router.dart';
import 'package:flutter/material.dart' hide Router;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(
ProviderScope(
child: MaterialApp.router(
routerConfig: Router().config(),
),
),
);
runApp(const ProviderScope(child: App()));
}
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
late final Router _router;
@override
void initState() {
super.initState();
_router = Router();
}
@override
Widget build(BuildContext context) {
return Sizer(
builder: (context, orientation, deviceType) {
return MaterialApp.router(routerConfig: _router.config());
},
);
}
}

View File

@@ -0,0 +1,51 @@
import 'package:arbiter/features/server_info_storage.dart';
import 'package:cryptography/cryptography.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'server_info.g.dart';
@riverpod
ServerInfoStorage serverInfoStorage(Ref ref) {
return const SecureServerInfoStorage();
}
@Riverpod(keepAlive: true)
class ServerInfo extends _$ServerInfo {
@override
Future<StoredServerInfo?> build() {
final storage = ref.watch(serverInfoStorageProvider);
return storage.load();
}
Future<void> save({
required String address,
required int port,
required List<int> caCert,
}) async {
final storage = ref.read(serverInfoStorageProvider);
final fingerprint = await _fingerprint(caCert);
final serverInfo = StoredServerInfo(
address: address,
port: port,
caCertFingerprint: fingerprint,
);
state = await AsyncValue.guard(() async {
await storage.save(serverInfo);
return serverInfo;
});
}
Future<void> clear() async {
final storage = ref.read(serverInfoStorageProvider);
state = await AsyncValue.guard(() async {
await storage.clear();
return null;
});
}
Future<String> _fingerprint(List<int> caCert) async {
final digest = await Sha256().hash(caCert);
return digest.bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
}

View File

@@ -0,0 +1,102 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'server_info.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(serverInfoStorage)
final serverInfoStorageProvider = ServerInfoStorageProvider._();
final class ServerInfoStorageProvider
extends
$FunctionalProvider<
ServerInfoStorage,
ServerInfoStorage,
ServerInfoStorage
>
with $Provider<ServerInfoStorage> {
ServerInfoStorageProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'serverInfoStorageProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$serverInfoStorageHash();
@$internal
@override
$ProviderElement<ServerInfoStorage> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
ServerInfoStorage create(Ref ref) {
return serverInfoStorage(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ServerInfoStorage value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ServerInfoStorage>(value),
);
}
}
String _$serverInfoStorageHash() => r'fc06865e7314b1a2493c5de1a9347923a3d21c5c';
@ProviderFor(ServerInfo)
final serverInfoProvider = ServerInfoProvider._();
final class ServerInfoProvider
extends $AsyncNotifierProvider<ServerInfo, StoredServerInfo?> {
ServerInfoProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'serverInfoProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$serverInfoHash();
@$internal
@override
ServerInfo create() => ServerInfo();
}
String _$serverInfoHash() => r'6e94f52de03259695a2166b766004eec60ff45fa';
abstract class _$ServerInfo extends $AsyncNotifier<StoredServerInfo?> {
FutureOr<StoredServerInfo?> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<AsyncValue<StoredServerInfo?>, StoredServerInfo?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<StoredServerInfo?>, StoredServerInfo?>,
AsyncValue<StoredServerInfo?>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -1,8 +1,4 @@
import 'package:arbiter/screens/dashboard/about.dart';
import 'package:arbiter/screens/dashboard/calc.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
import 'router.gr.dart';
@@ -11,6 +7,7 @@ class Router extends RootStackRouter {
@override
List<AutoRoute> get routes => [
AutoRoute(page: Bootstrap.page, path: '/bootstrap', initial: true),
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
AutoRoute(
page: DashboardRouter.page,

View File

@@ -13,33 +13,34 @@ import 'package:arbiter/screens/bootstrap.dart' as _i2;
import 'package:arbiter/screens/dashboard.dart' as _i4;
import 'package:arbiter/screens/dashboard/about.dart' as _i1;
import 'package:arbiter/screens/dashboard/calc.dart' as _i3;
import 'package:auto_route/auto_route.dart' as _i5;
import 'package:arbiter/screens/server_info_setup.dart' as _i5;
import 'package:auto_route/auto_route.dart' as _i6;
/// generated route for
/// [_i1.AboutScreen]
class AboutRoute extends _i5.PageRouteInfo<void> {
const AboutRoute({List<_i5.PageRouteInfo>? children})
class AboutRoute extends _i6.PageRouteInfo<void> {
const AboutRoute({List<_i6.PageRouteInfo>? children})
: super(AboutRoute.name, initialChildren: children);
static const String name = 'AboutRoute';
static _i5.PageInfo page = _i5.PageInfo(
static _i6.PageInfo page = _i6.PageInfo(
name,
builder: (data) {
return _i1.AboutScreen();
return const _i1.AboutScreen();
},
);
}
/// generated route for
/// [_i2.Bootstrap]
class Bootstrap extends _i5.PageRouteInfo<void> {
const Bootstrap({List<_i5.PageRouteInfo>? children})
class Bootstrap extends _i6.PageRouteInfo<void> {
const Bootstrap({List<_i6.PageRouteInfo>? children})
: super(Bootstrap.name, initialChildren: children);
static const String name = 'Bootstrap';
static _i5.PageInfo page = _i5.PageInfo(
static _i6.PageInfo page = _i6.PageInfo(
name,
builder: (data) {
return const _i2.Bootstrap();
@@ -49,13 +50,13 @@ class Bootstrap extends _i5.PageRouteInfo<void> {
/// generated route for
/// [_i3.CalcScreen]
class CalcRoute extends _i5.PageRouteInfo<void> {
const CalcRoute({List<_i5.PageRouteInfo>? children})
class CalcRoute extends _i6.PageRouteInfo<void> {
const CalcRoute({List<_i6.PageRouteInfo>? children})
: super(CalcRoute.name, initialChildren: children);
static const String name = 'CalcRoute';
static _i5.PageInfo page = _i5.PageInfo(
static _i6.PageInfo page = _i6.PageInfo(
name,
builder: (data) {
return const _i3.CalcScreen();
@@ -65,16 +66,32 @@ class CalcRoute extends _i5.PageRouteInfo<void> {
/// generated route for
/// [_i4.DashboardRouter]
class DashboardRouter extends _i5.PageRouteInfo<void> {
const DashboardRouter({List<_i5.PageRouteInfo>? children})
class DashboardRouter extends _i6.PageRouteInfo<void> {
const DashboardRouter({List<_i6.PageRouteInfo>? children})
: super(DashboardRouter.name, initialChildren: children);
static const String name = 'DashboardRouter';
static _i5.PageInfo page = _i5.PageInfo(
static _i6.PageInfo page = _i6.PageInfo(
name,
builder: (data) {
return const _i4.DashboardRouter();
},
);
}
/// generated route for
/// [_i5.ServerInfoSetupScreen]
class ServerInfoSetupRoute extends _i6.PageRouteInfo<void> {
const ServerInfoSetupRoute({List<_i6.PageRouteInfo>? children})
: super(ServerInfoSetupRoute.name, initialChildren: children);
static const String name = 'ServerInfoSetupRoute';
static _i6.PageInfo page = _i6.PageInfo(
name,
builder: (data) {
return const _i5.ServerInfoSetupScreen();
},
);
}

View File

@@ -14,13 +14,13 @@ class Bootstrap extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final container = ProviderScope.containerOf( context);
final container = ProviderScope.containerOf(context);
final completer = useMemoized(() {
final completer = Completer<void>();
completer.future.then((_) async {
if (context.mounted) {
final router = AutoRouter.of(context);
router.replace(const DashboardRouter());
router.replace(const ServerInfoSetupRoute());
}
});

View File

@@ -2,9 +2,10 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:mtcore/markettakers.dart' as mt;
@RoutePage()
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key});
@override
Widget build(BuildContext context) {
return mt.AboutScreen(decription: "Arbiter is bla bla bla");

View File

@@ -0,0 +1,284 @@
import 'package:arbiter/features/arbiter_url.dart';
import 'package:arbiter/features/server_info_storage.dart';
import 'package:arbiter/providers/server_info.dart';
import 'package:arbiter/router.gr.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
@RoutePage()
class ServerInfoSetupScreen extends HookConsumerWidget {
const ServerInfoSetupScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final storedServerInfo = ref.watch(serverInfoProvider);
final resolvedServerInfo = storedServerInfo.asData?.value;
final controller = useTextEditingController();
final errorText = useState<String?>(null);
final isSaving = useState(false);
useEffect(() {
final serverInfo = resolvedServerInfo;
if (serverInfo != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.router.replace(const DashboardRouter());
}
});
}
return null;
}, [context, resolvedServerInfo]);
Future<void> saveRemoteServerInfo() async {
errorText.value = null;
isSaving.value = true;
try {
final arbiterUrl = ArbiterUrl.parse(controller.text.trim());
await ref
.read(serverInfoProvider.notifier)
.save(
address: arbiterUrl.host,
port: arbiterUrl.port,
caCert: arbiterUrl.caCert,
);
if (context.mounted) {
context.router.replace(const DashboardRouter());
}
} on FormatException catch (error) {
errorText.value = error.message;
} catch (_) {
errorText.value = 'Failed to store connection settings.';
} finally {
isSaving.value = false;
}
}
if (storedServerInfo.isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (storedServerInfo.hasError) {
return Scaffold(
appBar: AppBar(title: const Text('Server Info Setup')),
body: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 6.w),
child: Text(
'Failed to load stored server info.',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
),
);
}
final serverInfo = resolvedServerInfo;
if (serverInfo != null) {
return _RedirectingView(serverInfo: serverInfo);
}
return Scaffold(
appBar: AppBar(title: const Text('Server Info Setup')),
body: LayoutBuilder(
builder: (context, constraints) {
final useRowLayout = constraints.maxWidth > constraints.maxHeight;
final gap = 2.h;
final horizontalPadding = 6.w;
final verticalPadding = 3.h;
final options = [
const _OptionCard(
title: 'Local',
subtitle: 'Will start and connect to a local service in a future update.',
enabled: false,
child: SizedBox.shrink(),
),
_OptionCard(
title: 'Remote',
subtitle: 'Paste an Arbiter URL to store the server address, port, and CA fingerprint.',
child: _RemoteConnectionForm(
controller: controller,
errorText: errorText.value,
isSaving: isSaving.value,
onSave: saveRemoteServerInfo,
),
),
];
return ListView(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: verticalPadding,
),
children: [
_SetupHeader(gap: gap),
SizedBox(height: gap),
useRowLayout
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: options[0]),
SizedBox(width: 3.w),
Expanded(child: options[1]),
],
)
: Column(
children: [
options[0],
SizedBox(height: gap),
options[1],
],
),
],
);
},
),
);
}
}
class _SetupHeader extends StatelessWidget {
const _SetupHeader({required this.gap});
final double gap;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Choose how this user agent should reach Arbiter.',
style: Theme.of(context).textTheme.headlineSmall,
),
SizedBox(height: gap * 0.5),
Text(
'Remote accepts the shareable Arbiter URL emitted by the server.',
style: Theme.of(context).textTheme.bodyMedium,
),
],
);
}
}
class _RedirectingView extends StatelessWidget {
const _RedirectingView({required this.serverInfo});
final StoredServerInfo serverInfo;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
SizedBox(height: 2.h),
Text('Using saved server ${serverInfo.address}:${serverInfo.port}'),
],
),
),
);
}
}
class _RemoteConnectionForm extends StatelessWidget {
const _RemoteConnectionForm({
required this.controller,
required this.errorText,
required this.isSaving,
required this.onSave,
});
final TextEditingController controller;
final String? errorText;
final bool isSaving;
final VoidCallback onSave;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Arbiter URL',
hintText: 'arbiter://host:port?cert=...',
),
minLines: 2,
maxLines: 4,
),
if (errorText != null) ...[
SizedBox(height: 1.5.h),
Text(
errorText!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
SizedBox(height: 2.h),
Align(
alignment: Alignment.centerLeft,
child: FilledButton(
onPressed: isSaving ? null : onSave,
child: Text(isSaving ? 'Saving...' : 'Save connection'),
),
),
],
);
}
}
class _OptionCard extends StatelessWidget {
const _OptionCard({
required this.title,
required this.subtitle,
required this.child,
this.enabled = true,
});
final String title;
final String subtitle;
final Widget child;
final bool enabled;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: EdgeInsets.all(2.h),
child: Opacity(
opacity: enabled ? 1 : 0.55,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(title, style: theme.textTheme.titleLarge),
if (!enabled) ...[
SizedBox(width: 2.w),
const Chip(label: Text('Coming soon')),
],
],
),
SizedBox(height: 1.h),
Text(subtitle, style: theme.textTheme.bodyMedium),
if (enabled) ...[
SizedBox(height: 2.h),
child,
],
],
),
),
),
);
}
}