Compare commits

...

2 commits

Author SHA1 Message Date
randogoth
aa74e12ee7 feat: support smol:// QR sharing, deep links and in-dialog scanning 2026-10-01 21:06:14 +03:00
randogoth
7d2a147fec fix: scope server pins by port (fumi-core rev 1468f3c) 2026-09-30 17:14:47 +03:00
35 changed files with 700 additions and 180 deletions

View file

@ -13,6 +13,7 @@ kirakira began as [FlashMail](https://github.com/sarthakkimtani/flash-mail), a F
- Pin a server's key, register or recall an address, then fetch and send sealed mail. - Pin a server's key, register or recall an address, then fetch and send sealed mail.
- Trust-on-first-use for server and contact keys, with rotation support and on-screen warnings for anything unverified. - Trust-on-first-use for server and contact keys, with rotation support and on-screen warnings for anything unverified.
- Export/import inbox, sent mail, contacts and server pins as one shareable backup file — never the seed. - Export/import inbox, sent mail, contacts and server pins as one shareable backup file — never the seed.
- Share your address as a `smol://` QR code, and react to any `smol://` link a scanner or browser hands the device: its key lands in the confirm-first import dialog.
Mail, contacts and pinned server keys live in fumi-core's SQLite store, bundled into the native library; the seed, read marks and settings stay app-side in Hive. Mail, contacts and pinned server keys live in fumi-core's SQLite store, bundled into the native library; the seed, read marks and settings stay app-side in Hive.
@ -37,7 +38,7 @@ devbox run test
flutter run --flavor prod flutter run --flavor prod
``` ```
`test/e2e_test.dart` runs a live round-trip against the local bunshin on `127.0.0.1:1961` and skips itself when nothing's listening there. On-device flows are covered by the Patrol suite — `devbox run test-integration` builds the `integration` flavor (installed beside the real app, its data cleared between tests) and drives onboarding, registration, fetch cancellation, rotation and the unchanged-key refresh against the same server. `test/e2e_test.dart` runs a live round-trip against the local bunshin on `127.0.0.1:1961` and skips itself when nothing's listening there. On-device flows are covered by the Patrol suite — `devbox run test-integration` builds the `integration` flavor (installed beside the real app, its data cleared between tests) and drives onboarding, registration, fetch cancellation, rotation and the unchanged-key refresh against the same server. To exercise the smol:// deep link by hand, scan `test-qr.png` — it encodes the `kirakira@localhost` identity — with any device camera and confirm the import dialog.
## License ## License

View file

@ -19,6 +19,15 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<!-- smol:// (SPEC.md §3): the self-certifying form a QR scanner
or a link hands over; the app imports the key it carries
after the user confirms. -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="smol"/>
</intent-filter>
</activity> </activity>
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"

View file

@ -112,14 +112,14 @@ class FumiNative {
Future<int> rotations() => _io((ffi) => ffi.rotations(_s)); Future<int> rotations() => _io((ffi) => ffi.rotations(_s));
Future<void> pinServer(String host, String keyB32) => Future<void> pinServer(String host, String keyB32, int port) =>
_io((ffi) => ffi.pinServer(_s, host, keyB32)); _io((ffi) => ffi.pinServer(_s, host, keyB32, port));
Future<String?> serverPin(String host) => Future<String?> serverPin(String host, int port) =>
_io((ffi) => ffi.serverPin(_s, host)); _io((ffi) => ffi.serverPin(_s, host, port));
Future<void> unpinServer(String host) => Future<void> unpinServer(String host, int port) =>
_io((ffi) => ffi.unpinServer(_s, host)); _io((ffi) => ffi.unpinServer(_s, host, port));
Future<List<dynamic>> pins() => _io((ffi) => ffi.pins(_s)); Future<List<dynamic>> pins() => _io((ffi) => ffi.pins(_s));

View file

@ -109,15 +109,15 @@ class SmolFfi {
Void Function(Pointer<Void>), Void Function(Pointer<Void>),
void Function(Pointer<Void>)>("smol_flag_free"); void Function(Pointer<Void>)>("smol_flag_free");
late final int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>) late final int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, int)
_pinServer = _lib.lookupFunction< _pinServer = _lib.lookupFunction<
Int32 Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>), Int32 Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Uint16),
int Function( int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>,
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>)>("smol_pin_server"); int)>("smol_pin_server");
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>) late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>, int)
_serverPin = _lib.lookupFunction< _serverPin = _lib.lookupFunction<
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>), Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>, Uint16),
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>)>( Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>, int)>(
"smol_server_pin"); "smol_server_pin");
late final Pointer<Utf8> Function(Pointer<Void>) _storeAccount = _lib late final Pointer<Utf8> Function(Pointer<Void>) _storeAccount = _lib
.lookupFunction<Pointer<Utf8> Function(Pointer<Void>), .lookupFunction<Pointer<Utf8> Function(Pointer<Void>),
@ -226,9 +226,11 @@ class SmolFfi {
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Int32), Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Int32),
int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>,
int)>("smol_contact_save"); int)>("smol_contact_save");
late final int Function(Pointer<Void>, Pointer<Utf8>) _unpinServer = _lib late final int Function(Pointer<Void>, Pointer<Utf8>, int) _unpinServer =
.lookupFunction<Int32 Function(Pointer<Void>, Pointer<Utf8>), _lib.lookupFunction<
int Function(Pointer<Void>, Pointer<Utf8>)>("smol_unpin_server"); Int32 Function(Pointer<Void>, Pointer<Utf8>, Uint16),
int Function(
Pointer<Void>, Pointer<Utf8>, int)>("smol_unpin_server");
late final Pointer<Utf8> Function(Pointer<Void>) _pins = _lib.lookupFunction< late final Pointer<Utf8> Function(Pointer<Void>) _pins = _lib.lookupFunction<
Pointer<Utf8> Function(Pointer<Void>), Pointer<Utf8> Function(Pointer<Void>),
Pointer<Utf8> Function(Pointer<Void>)>("smol_pins"); Pointer<Utf8> Function(Pointer<Void>)>("smol_pins");
@ -334,12 +336,14 @@ class SmolFfi {
void freeFlag(int flag) => _flagFree(_h(flag)); void freeFlag(int flag) => _flagFree(_h(flag));
void pinServer(int store, String host, String keyB32) { void pinServer(int store, String host, String keyB32, int port) {
if (_pinServer(_h(store), _text(host), _text(keyB32)) != smolOk) _fail(); if (_pinServer(_h(store), _text(host), _text(keyB32), port) != smolOk) {
_fail();
}
} }
void unpinServer(int store, String host) { void unpinServer(int store, String host, int port) {
if (_unpinServer(_h(store), _text(host)) != smolOk) _fail(); if (_unpinServer(_h(store), _text(host), port) != smolOk) _fail();
} }
/// Every pin: [{host, key}] with keys as base32. /// Every pin: [{host, key}] with keys as base32.
@ -355,8 +359,8 @@ class SmolFfi {
_h(store), _text(address), _text(keyB32), verified ? 1 : 0); _h(store), _text(address), _text(keyB32), verified ? 1 : 0);
/// Null when nothing is pinned. /// Null when nothing is pinned.
String? serverPin(int store, String host) { String? serverPin(int store, String host, int port) {
final text = take(_serverPin(_h(store), _text(host))); final text = take(_serverPin(_h(store), _text(host), port));
return text.isEmpty ? null : text; return text.isEmpty ? null : text;
} }

View file

@ -1,9 +1,15 @@
import "dart:async";
import "package:app_links/app_links.dart";
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/theme/theme.dart";
import "package:smol_mail/presentation/routes/app_router.dart"; import "package:smol_mail/presentation/routes/app_router.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/theme/theme.dart";
import "package:smol_mail/smol/link.dart";
/// SnackBars need no context through this key, so the client's trust /// SnackBars need no context through this key, so the client's trust
/// warnings can be shown from anywhere in the protocol stack. /// warnings can be shown from anywhere in the protocol stack.
@ -17,6 +23,10 @@ class AppWidget extends ConsumerStatefulWidget {
} }
class _AppWidgetState extends ConsumerState<AppWidget> { class _AppWidgetState extends ConsumerState<AppWidget> {
late final AppRouter _router = AppRouter(ref);
final AppLinks _appLinks = AppLinks();
StreamSubscription<Uri>? _linkSub;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -37,6 +47,33 @@ class _AppWidgetState extends ConsumerState<AppWidget> {
), ),
); );
}; };
_linkSub = _appLinks.uriLinkStream.listen(_handleLink);
}
// A smol:// link is the self-certifying form: the §8 strong path,
// confirmed in the import dialog route before its key is bound. The push
// waits a frame — the stream can deliver the cold-start link while the
// first frame is still being laid out.
void _handleLink(Uri uri) {
final link = parseSmolLink(uri.toString());
if (link == null) return;
if (ref.read(identityProvider) == null) {
rootScaffoldMessengerKey.currentState?.showSnackBar(
const SnackBar(
content: Text("finish onboarding before using smol:// links")),
);
return;
}
WidgetsBinding.instance.addPostFrameCallback(
(_) => _router.push(ImportContactDialogRoute(prefill: link.source)),
);
}
@override
void dispose() {
_linkSub?.cancel();
super.dispose();
} }
@override @override
@ -48,7 +85,16 @@ class _AppWidgetState extends ConsumerState<AppWidget> {
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
scaffoldMessengerKey: rootScaffoldMessengerKey, scaffoldMessengerKey: rootScaffoldMessengerKey,
routerConfig: AppRouter(ref).config(), // smol:// URIs are for app_links, not the route table. The startup link
// must still resolve to a real route — auto_route throws "Can not
// resolve initial route" on an unmatched link and leaves a blank
// screen — so it lands on the normal entry route, whose guard
// redirects to home exactly like a launcher start. Every later
// platform link is ignored by the router; the stream handler reacts.
routerConfig: _router.config(
deepLinkBuilder: (link) =>
link.initial ? DeepLink.single(const OnboardingRoute()) : DeepLink.none,
),
); );
} }
} }

View file

@ -1,10 +1,25 @@
import "package:auto_route/auto_route.dart"; import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/presentation/routes/home_guard.dart"; import "package:smol_mail/presentation/routes/home_guard.dart";
import "package:smol_mail/presentation/routes/identity_guard.dart"; import "package:smol_mail/presentation/routes/identity_guard.dart";
import "app_router.gr.dart"; import "app_router.gr.dart";
/// A DialogRoute is a PageRoute whose settings carry the AutoRoutePage, so
/// auto_route's setNewRoutePath — which pops pageless top routes before
/// resolving a platform link — leaves dialog pages standing.
Route<T> dialogRouteBuilder<T>(
BuildContext context,
Widget child,
AutoRoutePage<T> page,
) =>
DialogRoute<T>(
context: context,
settings: page,
builder: (ctx) => child,
);
@AutoRouterConfig(replaceInRouteName: "Screen,Route") @AutoRouterConfig(replaceInRouteName: "Screen,Route")
class AppRouter extends RootStackRouter { class AppRouter extends RootStackRouter {
final WidgetRef ref; final WidgetRef ref;
@ -44,5 +59,11 @@ class AppRouter extends RootStackRouter {
path: "/contact", path: "/contact",
guards: [IdentityGuard(ref)], guards: [IdentityGuard(ref)],
), ),
CustomRoute(
page: ImportContactDialogRoute.page,
path: "/import",
customRouteBuilder: dialogRouteBuilder,
guards: [IdentityGuard(ref)],
),
]; ];
} }

View file

@ -10,27 +10,29 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:auto_route/auto_route.dart' as _i9; import 'package:auto_route/auto_route.dart' as _i10;
import 'package:flutter/material.dart' as _i10; import 'package:flutter/material.dart' as _i11;
import 'package:smol_mail/presentation/screens/compose_screen.dart' as _i1; import 'package:smol_mail/presentation/screens/compose_screen.dart' as _i1;
import 'package:smol_mail/presentation/screens/contact_detail_screen.dart' import 'package:smol_mail/presentation/screens/contact_detail_screen.dart'
as _i2; as _i2;
import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3; import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3;
import 'package:smol_mail/presentation/screens/home_screen.dart' as _i4; import 'package:smol_mail/presentation/screens/home_screen.dart' as _i4;
import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i5; import 'package:smol_mail/presentation/screens/import_contact_dialog.dart'
as _i5;
import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i6;
import 'package:smol_mail/presentation/screens/message_detail_screen.dart' import 'package:smol_mail/presentation/screens/message_detail_screen.dart'
as _i6; as _i7;
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i7; import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i8;
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i8; import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i9;
/// generated route for /// generated route for
/// [_i1.ComposeScreen] /// [_i1.ComposeScreen]
class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> { class ComposeRoute extends _i10.PageRouteInfo<ComposeRouteArgs> {
ComposeRoute({ ComposeRoute({
_i10.Key? key, _i11.Key? key,
String? to, String? to,
String? subject, String? subject,
List<_i9.PageRouteInfo>? children, List<_i10.PageRouteInfo>? children,
}) : super( }) : super(
ComposeRoute.name, ComposeRoute.name,
args: ComposeRouteArgs(key: key, to: to, subject: subject), args: ComposeRouteArgs(key: key, to: to, subject: subject),
@ -39,7 +41,7 @@ class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
static const String name = 'ComposeRoute'; static const String name = 'ComposeRoute';
static _i9.PageInfo page = _i9.PageInfo( static _i10.PageInfo page = _i10.PageInfo(
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<ComposeRouteArgs>( final args = data.argsAs<ComposeRouteArgs>(
@ -57,7 +59,7 @@ class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
class ComposeRouteArgs { class ComposeRouteArgs {
const ComposeRouteArgs({this.key, this.to, this.subject}); const ComposeRouteArgs({this.key, this.to, this.subject});
final _i10.Key? key; final _i11.Key? key;
final String? to; final String? to;
@ -81,11 +83,11 @@ class ComposeRouteArgs {
/// generated route for /// generated route for
/// [_i2.ContactDetailScreen] /// [_i2.ContactDetailScreen]
class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> { class ContactDetailRoute extends _i10.PageRouteInfo<ContactDetailRouteArgs> {
ContactDetailRoute({ ContactDetailRoute({
_i10.Key? key, _i11.Key? key,
required String address, required String address,
List<_i9.PageRouteInfo>? children, List<_i10.PageRouteInfo>? children,
}) : super( }) : super(
ContactDetailRoute.name, ContactDetailRoute.name,
args: ContactDetailRouteArgs(key: key, address: address), args: ContactDetailRouteArgs(key: key, address: address),
@ -94,7 +96,7 @@ class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
static const String name = 'ContactDetailRoute'; static const String name = 'ContactDetailRoute';
static _i9.PageInfo page = _i9.PageInfo( static _i10.PageInfo page = _i10.PageInfo(
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<ContactDetailRouteArgs>(); final args = data.argsAs<ContactDetailRouteArgs>();
@ -106,7 +108,7 @@ class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
class ContactDetailRouteArgs { class ContactDetailRouteArgs {
const ContactDetailRouteArgs({this.key, required this.address}); const ContactDetailRouteArgs({this.key, required this.address});
final _i10.Key? key; final _i11.Key? key;
final String address; final String address;
@ -128,13 +130,13 @@ class ContactDetailRouteArgs {
/// generated route for /// generated route for
/// [_i3.ContactsScreen] /// [_i3.ContactsScreen]
class ContactsRoute extends _i9.PageRouteInfo<void> { class ContactsRoute extends _i10.PageRouteInfo<void> {
const ContactsRoute({List<_i9.PageRouteInfo>? children}) const ContactsRoute({List<_i10.PageRouteInfo>? children})
: super(ContactsRoute.name, initialChildren: children); : super(ContactsRoute.name, initialChildren: children);
static const String name = 'ContactsRoute'; static const String name = 'ContactsRoute';
static _i9.PageInfo page = _i9.PageInfo( static _i10.PageInfo page = _i10.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i3.ContactsScreen(); return const _i3.ContactsScreen();
@ -144,13 +146,13 @@ class ContactsRoute extends _i9.PageRouteInfo<void> {
/// generated route for /// generated route for
/// [_i4.HomeScreen] /// [_i4.HomeScreen]
class HomeRoute extends _i9.PageRouteInfo<void> { class HomeRoute extends _i10.PageRouteInfo<void> {
const HomeRoute({List<_i9.PageRouteInfo>? children}) const HomeRoute({List<_i10.PageRouteInfo>? children})
: super(HomeRoute.name, initialChildren: children); : super(HomeRoute.name, initialChildren: children);
static const String name = 'HomeRoute'; static const String name = 'HomeRoute';
static _i9.PageInfo page = _i9.PageInfo( static _i10.PageInfo page = _i10.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i4.HomeScreen(); return const _i4.HomeScreen();
@ -159,29 +161,82 @@ class HomeRoute extends _i9.PageRouteInfo<void> {
} }
/// generated route for /// generated route for
/// [_i5.InboxScreen] /// [_i5.ImportContactDialogScreen]
class InboxRoute extends _i9.PageRouteInfo<void> { class ImportContactDialogRoute
const InboxRoute({List<_i9.PageRouteInfo>? children}) extends _i10.PageRouteInfo<ImportContactDialogRouteArgs> {
ImportContactDialogRoute({
_i11.Key? key,
String? prefill,
List<_i10.PageRouteInfo>? children,
}) : super(
ImportContactDialogRoute.name,
args: ImportContactDialogRouteArgs(key: key, prefill: prefill),
initialChildren: children,
);
static const String name = 'ImportContactDialogRoute';
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
final args = data.argsAs<ImportContactDialogRouteArgs>(
orElse: () => const ImportContactDialogRouteArgs(),
);
return _i5.ImportContactDialogScreen(
key: args.key,
prefill: args.prefill,
);
},
);
}
class ImportContactDialogRouteArgs {
const ImportContactDialogRouteArgs({this.key, this.prefill});
final _i11.Key? key;
final String? prefill;
@override
String toString() {
return 'ImportContactDialogRouteArgs{key: $key, prefill: $prefill}';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! ImportContactDialogRouteArgs) return false;
return key == other.key && prefill == other.prefill;
}
@override
int get hashCode => key.hashCode ^ prefill.hashCode;
}
/// generated route for
/// [_i6.InboxScreen]
class InboxRoute extends _i10.PageRouteInfo<void> {
const InboxRoute({List<_i10.PageRouteInfo>? children})
: super(InboxRoute.name, initialChildren: children); : super(InboxRoute.name, initialChildren: children);
static const String name = 'InboxRoute'; static const String name = 'InboxRoute';
static _i9.PageInfo page = _i9.PageInfo( static _i10.PageInfo page = _i10.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i5.InboxScreen(); return const _i6.InboxScreen();
}, },
); );
} }
/// generated route for /// generated route for
/// [_i6.MessageDetailScreen] /// [_i7.MessageDetailScreen]
class MessageDetailRoute extends _i9.PageRouteInfo<MessageDetailRouteArgs> { class MessageDetailRoute extends _i10.PageRouteInfo<MessageDetailRouteArgs> {
MessageDetailRoute({ MessageDetailRoute({
_i10.Key? key, _i11.Key? key,
required String folder, required String folder,
required String id, required String id,
List<_i9.PageRouteInfo>? children, List<_i10.PageRouteInfo>? children,
}) : super( }) : super(
MessageDetailRoute.name, MessageDetailRoute.name,
args: MessageDetailRouteArgs(key: key, folder: folder, id: id), args: MessageDetailRouteArgs(key: key, folder: folder, id: id),
@ -190,11 +245,11 @@ class MessageDetailRoute extends _i9.PageRouteInfo<MessageDetailRouteArgs> {
static const String name = 'MessageDetailRoute'; static const String name = 'MessageDetailRoute';
static _i9.PageInfo page = _i9.PageInfo( static _i10.PageInfo page = _i10.PageInfo(
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<MessageDetailRouteArgs>(); final args = data.argsAs<MessageDetailRouteArgs>();
return _i6.MessageDetailScreen( return _i7.MessageDetailScreen(
key: args.key, key: args.key,
folder: args.folder, folder: args.folder,
id: args.id, id: args.id,
@ -210,7 +265,7 @@ class MessageDetailRouteArgs {
required this.id, required this.id,
}); });
final _i10.Key? key; final _i11.Key? key;
final String folder; final String folder;
@ -233,33 +288,33 @@ class MessageDetailRouteArgs {
} }
/// generated route for /// generated route for
/// [_i7.OnboardingScreen] /// [_i8.OnboardingScreen]
class OnboardingRoute extends _i9.PageRouteInfo<void> { class OnboardingRoute extends _i10.PageRouteInfo<void> {
const OnboardingRoute({List<_i9.PageRouteInfo>? children}) const OnboardingRoute({List<_i10.PageRouteInfo>? children})
: super(OnboardingRoute.name, initialChildren: children); : super(OnboardingRoute.name, initialChildren: children);
static const String name = 'OnboardingRoute'; static const String name = 'OnboardingRoute';
static _i9.PageInfo page = _i9.PageInfo( static _i10.PageInfo page = _i10.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i7.OnboardingScreen(); return const _i8.OnboardingScreen();
}, },
); );
} }
/// generated route for /// generated route for
/// [_i8.SettingsScreen] /// [_i9.SettingsScreen]
class SettingsRoute extends _i9.PageRouteInfo<void> { class SettingsRoute extends _i10.PageRouteInfo<void> {
const SettingsRoute({List<_i9.PageRouteInfo>? children}) const SettingsRoute({List<_i10.PageRouteInfo>? children})
: super(SettingsRoute.name, initialChildren: children); : super(SettingsRoute.name, initialChildren: children);
static const String name = 'SettingsRoute'; static const String name = 'SettingsRoute';
static _i9.PageInfo page = _i9.PageInfo( static _i10.PageInfo page = _i10.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i8.SettingsScreen(); return const _i9.SettingsScreen();
}, },
); );
} }

View file

@ -6,6 +6,7 @@ import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/shared/utils/format.dart"; import "package:smol_mail/shared/utils/format.dart";
import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/address.dart";
import "package:smol_mail/presentation/widgets/qr_code_sheet.dart";
import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/ui.dart"; import "package:smol_mail/smol/ui.dart";
@ -88,6 +89,10 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
final contact = store.contact(widget.address); final contact = store.contact(widget.address);
final accepted = store.accepted(widget.address); final accepted = store.accepted(widget.address);
final isAccepted = accepted != null && accepted.active; final isAccepted = accepted != null && accepted.active;
// Computed once per build; the branch below only renders it when a
// contact is bound to the address, which is when the parse succeeds.
final uri =
contact == null ? null : parseAddress(widget.address).uri(contact.key);
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.address)), appBar: AppBar(title: Text(widget.address)),
@ -130,17 +135,16 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: _field( child: _field(context, "address", uri!),
context,
"address",
parseAddress(widget.address)
.uri(contact.key)),
), ),
IconButton( IconButton(
icon: const Icon(Icons.copy, size: 18), icon: const Icon(Icons.copy, size: 18),
onPressed: () => copyText( onPressed: () => copyText(context, uri),
context, ),
parseAddress(widget.address).uri(contact.key)), IconButton(
tooltip: "Show QR code",
icon: const Icon(Icons.qr_code, size: 18),
onPressed: () => showQrCodeSheet(context, uri),
), ),
], ],
), ),

View file

@ -5,8 +5,6 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/theme/app_colors.dart"; import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/ui.dart"; import "package:smol_mail/smol/ui.dart";
/// Contacts: the addresses bound to keys, with the trust badge and the number /// Contacts: the addresses bound to keys, with the trust badge and the number
@ -15,45 +13,8 @@ import "package:smol_mail/smol/ui.dart";
class ContactsScreen extends ConsumerWidget { class ContactsScreen extends ConsumerWidget {
const ContactsScreen({super.key}); const ContactsScreen({super.key});
Future<void> _import(BuildContext context, WidgetRef ref) async { void _import(BuildContext context) =>
final client = ref.read(clientProvider); AutoRouter.of(context).push(ImportContactDialogRoute());
final controller = TextEditingController();
final text = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
title: const Text("Import contact"),
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: "smol://user@host/key",
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(controller.text),
child: const Text("Import"),
),
],
),
);
if (text == null || text.trim().isEmpty) return;
try {
client.importContact(text);
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("contact imported (verified key)")),
);
}
} on SmolError catch (err) {
if (context.mounted) {
showErrorSnackBar(context, err.message);
}
}
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -65,7 +26,7 @@ class ContactsScreen extends ConsumerWidget {
actions: [ actions: [
IconButton( IconButton(
tooltip: "Import smol:// address", tooltip: "Import smol:// address",
onPressed: () => _import(context, ref), onPressed: () => _import(context),
icon: const Icon(Icons.person_add), icon: const Icon(Icons.person_add),
), ),
], ],

View file

@ -0,0 +1,131 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:mobile_scanner/mobile_scanner.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/app_widget.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/link.dart";
/// The §8 strong path: a self-certifying smol:// address is pasted, scanned
/// or deep-linked in, and the user confirms before its key is bound. A
/// routed dialog page rather than showDialog, so auto_route's platform-link
/// handling — which pops pageless top routes — leaves it standing. The QR
/// icon swaps the text field for a camera view; a scanned URI only fills
/// the field, the Import tap is still what binds the key.
@RoutePage()
class ImportContactDialogScreen extends ConsumerStatefulWidget {
final String? prefill;
const ImportContactDialogScreen({super.key, this.prefill});
@override
ConsumerState<ImportContactDialogScreen> createState() =>
ImportContactDialogScreenState();
}
class ImportContactDialogScreenState
extends ConsumerState<ImportContactDialogScreen> {
late final controller = TextEditingController(text: widget.prefill ?? "");
bool scanning = false;
MobileScannerController? _scanner;
@override
void dispose() {
_scanner?.dispose();
controller.dispose();
super.dispose();
}
void _toggleScan() {
setState(() {
_scanner?.dispose();
_scanner =
scanning ? null : MobileScannerController(formats: [BarcodeFormat.qrCode]);
scanning = !scanning;
});
}
// Only a URI fumi accepts fills the field; anything else the camera sees
// is not for this dialog, so scanning simply continues.
void _onDetect(BarcodeCapture capture) {
if (!scanning) return;
for (final barcode in capture.barcodes) {
final raw = barcode.rawValue?.trim();
if (raw != null && parseSmolLink(raw) != null) {
controller.text = raw;
_toggleScan();
return;
}
}
}
void _import() {
final text = controller.text;
if (text.trim().isEmpty) return;
try {
ref.read(clientProvider).importContact(text);
ref.read(revisionProvider.notifier).bump();
rootScaffoldMessengerKey.currentState?.showSnackBar(
const SnackBar(content: Text("contact imported (verified key)")),
);
AutoRouter.of(context).pop();
} on SmolError catch (err) {
showErrorSnackBar(context, err.message);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
title: const Text("Import contact"),
content: scanning
? SizedBox(
height: 220,
width: double.maxFinite,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: MobileScanner(
controller: _scanner!,
onDetect: _onDetect,
errorBuilder: (context, error) => Center(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
error.errorCode.name,
style: Theme.of(context).textTheme.labelSmall,
textAlign: TextAlign.center,
),
),
),
),
),
)
: TextField(
controller: controller,
autofocus: true,
onSubmitted: (_) => _import(),
decoration: InputDecoration(
hintText: "smol://user@host/key",
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
),
actions: [
IconButton(
tooltip: scanning ? "Type the address" : "Scan a QR code",
onPressed: _toggleScan,
icon: Icon(scanning ? Icons.keyboard : Icons.qr_code_scanner),
),
TextButton(
onPressed: _import,
child: const Text("Import"),
),
],
);
}
}

View file

@ -6,6 +6,7 @@ import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/widgets/image_banner.dart"; import "package:smol_mail/presentation/widgets/image_banner.dart";
import "package:smol_mail/presentation/widgets/message/message_list.dart"; import "package:smol_mail/presentation/widgets/message/message_list.dart";
import "package:smol_mail/presentation/widgets/qr_code_sheet.dart";
import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/errors.dart";
@ -90,11 +91,24 @@ class _InboxScreenState extends ConsumerState<InboxScreen>
final unread = ref.watch(unreadProvider); final unread = ref.watch(unreadProvider);
final requestsUnread = ref.watch(requestsUnreadProvider); final requestsUnread = ref.watch(requestsUnreadProvider);
final canFetch = _folders[_tabController.index] != "sent"; final canFetch = _folders[_tabController.index] != "sent";
// The same self-certifying URI the settings share row shows, one tap
// closer: what a second device scans to import this identity.
final me = ref.watch(identityProvider);
final account = ref.watch(accountProvider);
final share = (me == null || account == null)
? null
: account.uri(me.publicKey);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("kirakira"), title: const Text("kirakira"),
actions: [ actions: [
if (share != null)
IconButton(
tooltip: "Show QR code",
onPressed: () => showQrCodeSheet(context, share),
icon: const Icon(Icons.qr_code),
),
if (canFetch) if (canFetch)
IconButton( IconButton(
tooltip: "Fetch", tooltip: "Fetch",

View file

@ -161,7 +161,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
// "already pinned, e.g. by a previous attempt or a preset server". // "already pinned, e.g. by a previous attempt or a preset server".
final serverKey = serverKeyController.text.trim(); final serverKey = serverKeyController.text.trim();
if (serverKey.isNotEmpty) { if (serverKey.isNotEmpty) {
client.pinServer(address.host, serverKey); client.pinServer(address.host, serverKey, address.port);
} }
if (recall && restoreIntent) { if (recall && restoreIntent) {
await client.restoreAndRecall(seedController.text, address.short); await client.restoreAndRecall(seedController.text, address.short);
@ -210,7 +210,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
image: AssetImage("assets/images/bug_logo.png"), image: AssetImage("assets/images/bug_logo.png"),
fit: BoxFit.contain), fit: BoxFit.contain),
), ),
Text("kirakira", Text("キラキラ",
style: Theme.of(context).textTheme.titleLarge), style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 40), const SizedBox(height: 40),
Text(title, style: Theme.of(context).textTheme.titleMedium), Text(title, style: Theme.of(context).textTheme.titleMedium),

View file

@ -11,7 +11,9 @@ import "package:share_plus/share_plus.dart";
import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/theme/app_colors.dart"; import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/presentation/widgets/qr_code_sheet.dart";
import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/address.dart";
import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/ui.dart"; import "package:smol_mail/smol/ui.dart";
@ -235,12 +237,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
if (share != null) if (share != null) ...[
IconButton( IconButton(
icon: const Icon(Icons.copy, size: 18), icon: const Icon(Icons.copy, size: 18),
onPressed: () => onPressed: () =>
Clipboard.setData(ClipboardData(text: share)), Clipboard.setData(ClipboardData(text: share)),
), ),
IconButton(
tooltip: "Show QR code",
icon: const Icon(Icons.qr_code, size: 18),
onPressed: () => showQrCodeSheet(context, share),
),
],
], ],
), ),
], ],
@ -320,7 +328,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 18), icon: const Icon(Icons.delete_outline, size: 18),
onPressed: () { onPressed: () {
store.unpinServer(host); store.unpinServer(host, defaultPort);
ref.read(revisionProvider.notifier).bump(); ref.read(revisionProvider.notifier).bump();
setState(() {}); setState(() {});
}, },
@ -350,9 +358,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
TextButton( TextButton(
onPressed: () { onPressed: () {
try { try {
ref ref.read(clientProvider).pinServer(
.read(clientProvider) hostController.text, keyController.text, defaultPort);
.pinServer(hostController.text, keyController.text);
keyController.clear(); keyController.clear();
setState(() {}); setState(() {});
} on SmolError catch (err) { } on SmolError catch (err) {

View file

@ -0,0 +1,52 @@
import "package:flutter/material.dart";
import "package:qr_flutter/qr_flutter.dart";
import "package:smol_mail/shared/utils/format.dart";
/// Shows [payload] as a scannable QR code in a bottom sheet. Fixed dark
/// modules on white regardless of theme: contrast is what a scanner needs,
/// not the palette. Scrolled, because the sheet is height-capped on small
/// screens.
Future<void> showQrCodeSheet(BuildContext context, String payload) {
return showModalBottomSheet<void>(
context: context,
builder: (ctx) => SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(12),
color: Colors.white,
child: QrImageView(
data: payload,
version: QrVersions.auto,
size: 260,
backgroundColor: Colors.white,
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: Color.fromRGBO(1, 17, 29, 1),
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Color.fromRGBO(1, 17, 29, 1),
),
),
),
const SizedBox(height: 15),
SelectableText(
payload,
style: Theme.of(ctx).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
TextButton.icon(
onPressed: () => copyText(ctx, payload),
icon: const Icon(Icons.copy, size: 18),
label: const Text("Copy"),
),
],
),
),
);
}

View file

@ -144,7 +144,7 @@ class SmolClient {
// registration and fetching demand it anyway (sec 4), so restoring is // registration and fetching demand it anyway (sec 4), so restoring is
// stopped at the pin step rather than letting the account bind to a // stopped at the pin step rather than letting the account bind to a
// server whose key nobody verified. // server whose key nobody verified.
if (store.serverPin(addr.host) == null) { if (store.serverPin(addr.host, addr.port) == null) {
throw SmolError("no pinned key for ${addr.host}. Obtain it from the " throw SmolError("no pinned key for ${addr.host}. Obtain it from the "
"operator through a trusted channel, then pin it in settings."); "operator through a trusted channel, then pin it in settings.");
} }
@ -157,7 +157,8 @@ class SmolClient {
} }
} }
void pinServer(String host, String keyB32) => store.pinServer(host, keyB32); void pinServer(String host, String keyB32, int port) =>
store.pinServer(host, keyB32, port);
Future<void> registerAccount(String addressText, {String token = ""}) async { Future<void> registerAccount(String addressText, {String token = ""}) async {
final addr = parseAddress(addressText); final addr = parseAddress(addressText);

View file

@ -9,6 +9,7 @@
// removable in settings like any other, and never overwrites a host the user // removable in settings like any other, and never overwrites a host the user
// (or a previous install) already pinned. // (or a previous install) already pinned.
import "package:smol_mail/smol/address.dart";
import "package:smol_mail/smol/store.dart"; import "package:smol_mail/smol/store.dart";
const _presetHost = String.fromEnvironment("SMOL_PRESET_SERVER"); const _presetHost = String.fromEnvironment("SMOL_PRESET_SERVER");
@ -16,7 +17,7 @@ const _presetKey = String.fromEnvironment("SMOL_PRESET_SERVER_KEY");
void applyPresetServer(SmolStore store) { void applyPresetServer(SmolStore store) {
if (_presetHost.isEmpty || _presetKey.isEmpty) return; if (_presetHost.isEmpty || _presetKey.isEmpty) return;
if (store.serverPin(_presetHost) != null) return; if (store.serverPin(_presetHost, defaultPort) != null) return;
// A malformed preset leaves the host unpinned rather than blocking boot. // A malformed preset leaves the host unpinned rather than blocking boot.
store.pinServer(_presetHost, _presetKey); store.pinServer(_presetHost, _presetKey, defaultPort);
} }

27
lib/smol/link.dart Normal file
View file

@ -0,0 +1,27 @@
// Deep links (SPEC.md §3): the OS hands the app a URI a QR scanner or a
// browser produced. Only the smol scheme is ours to interpret, and the
// native parser decides validity — fumi rejects a smol:// URI without its
// key, so every link that parses is the self-certifying form.
import "package:smol_mail/smol/address.dart";
import "package:smol_mail/smol/errors.dart";
class SmolLink {
final SmolAddress address;
final String source;
const SmolLink._(this.address, this.source);
}
/// Parses a smol:// URI. Returns null for other schemes and for anything
/// the native parser rejects — those are not silent fixes, they are not
/// for this app to act on.
SmolLink? parseSmolLink(String uri) {
final text = uri.trim();
if (!text.startsWith("smol://")) return null;
try {
return SmolLink._(parseAddress(text), text);
} on SmolError {
return null;
}
}

View file

@ -291,10 +291,11 @@ class SmolStore {
// --- pins ------------------------------------------------------------------- // --- pins -------------------------------------------------------------------
/// Pins a server's static key (§4): sync, because it is one local write. /// Pins a server's static key (§4): sync, because it is one local write.
void pinServer(String host, String keyB32) => void pinServer(String host, String keyB32, int port) =>
_ffi.pinServer(_native.store!, host, keyB32); _ffi.pinServer(_native.store!, host, keyB32, port);
String? serverPin(String host) => _ffi.serverPin(_native.store!, host); String? serverPin(String host, int port) =>
_ffi.serverPin(_native.store!, host, port);
List<(String, String)> allPins() { List<(String, String)> allPins() {
final rows = _ffi.pins(_native.store!); final rows = _ffi.pins(_native.store!);
@ -304,7 +305,8 @@ class SmolStore {
]; ];
} }
Future<void> unpinServer(String host) async => _native.unpinServer(host); Future<void> unpinServer(String host, int port) async =>
_native.unpinServer(host, port);
// --- backups ---------------------------------------------------------------- // --- backups ----------------------------------------------------------------

View file

@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <gtk/gtk_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View file

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
gtk
url_launcher_linux url_launcher_linux
) )

2
native/Cargo.lock generated
View file

@ -271,7 +271,7 @@ checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]] [[package]]
name = "fumi-core" name = "fumi-core"
version = "0.1.0" version = "0.1.0"
source = "git+ssh://git@code.randogoth.com:2222/randogoth/fumi.git?rev=2d65d66#2d65d66012fa40121e4dc3d8d15db9ffc486569a" source = "git+ssh://git@code.randogoth.com:2222/randogoth/fumi.git?rev=1468f3c#1468f3cbc0a89a0cb073f46a985911c0327885d1"
dependencies = [ dependencies = [
"chacha20poly1305", "chacha20poly1305",
"data-encoding", "data-encoding",

View file

@ -7,7 +7,7 @@ edition = "2021"
crate-type = ["lib", "cdylib"] crate-type = ["lib", "cdylib"]
[dependencies] [dependencies]
fumi-core = { git = "ssh://git@code.randogoth.com:2222/randogoth/fumi.git", rev = "2d65d66" } fumi-core = { git = "ssh://git@code.randogoth.com:2222/randogoth/fumi.git", rev = "1468f3c" }
rand_core = { version = "0.6", features = ["getrandom"] } rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"

View file

@ -126,22 +126,29 @@ struct ErrorJson {
/// The raw byte behind an UNKNOWN_STATUS (code 11). /// The raw byte behind an UNKNOWN_STATUS (code 11).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
status: Option<u8>, status: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
port: Option<u16>,
} }
impl ErrorJson { impl ErrorJson {
fn of(error: &Error) -> ErrorJson { fn of(error: &Error) -> ErrorJson {
let message = error.to_string(); let message = error.to_string();
let (host, pinned, presented, address, known, offered, status) = match error { let (host, pinned, presented, address, known, offered, status, port) = match error {
Error::UnknownStatus(status, _) => { Error::UnknownStatus(status, _) => {
(None, None, None, None, None, None, Some(*status)) (None, None, None, None, None, None, Some(*status), None)
}
Error::NotPinned { host, port } => {
(Some(host.clone()), None, None, None, None, None, None, Some(*port))
}
Error::Unreachable { host, .. } => {
(Some(host.clone()), None, None, None, None, None, None, None)
} }
Error::NotPinned { host } => (Some(host.clone()), None, None, None, None, None, None),
Error::Unreachable { host, .. } => (Some(host.clone()), None, None, None, None, None, None),
Error::HandshakeRefused { host, .. } => { Error::HandshakeRefused { host, .. } => {
(Some(host.clone()), None, None, None, None, None, None) (Some(host.clone()), None, None, None, None, None, None, None)
} }
Error::PinMismatch { Error::PinMismatch {
host, host,
port,
pinned, pinned,
presented, presented,
} => ( } => (
@ -152,6 +159,7 @@ impl ErrorJson {
None, None,
None, None,
None, None,
Some(*port),
), ),
Error::KeyChanged { Error::KeyChanged {
address, address,
@ -165,8 +173,9 @@ impl ErrorJson {
Some(b32(known)), Some(b32(known)),
Some(b32(offered)), Some(b32(offered)),
None, None,
None,
), ),
_ => (None, None, None, None, None, None, None), _ => (None, None, None, None, None, None, None, None),
}; };
ErrorJson { ErrorJson {
code: code_of(error), code: code_of(error),
@ -178,6 +187,7 @@ impl ErrorJson {
known, known,
offered, offered,
status, status,
port,
} }
} }
} }
@ -383,23 +393,24 @@ pub extern "C" fn smol_pin_server(
store: *mut Store, store: *mut Store,
host: *const c_char, host: *const c_char,
key_b32: *const c_char, key_b32: *const c_char,
port: u16,
) -> i32 { ) -> i32 {
status(|| { status(|| {
let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? };
let key: [u8; KEY_LEN] = unb32(text(key_b32)?)? let key: [u8; KEY_LEN] = unb32(text(key_b32)?)?
.try_into() .try_into()
.map_err(|_| Error::Other("server key must decode to 32 bytes".into()))?; .map_err(|_| Error::Other("server key must decode to 32 bytes".into()))?;
store.pin_server(text(host)?, &key) store.pin_server(text(host)?, port, &key)
}) })
} }
/// The pinned key for a host, base32. Empty string: none pinned. Null: error. /// The pinned key for a host, base32. Empty string: none pinned. Null: error.
#[no_mangle] #[no_mangle]
pub extern "C" fn smol_server_pin(store: *mut Store, host: *const c_char) -> *mut c_char { pub extern "C" fn smol_server_pin(store: *mut Store, host: *const c_char, port: u16) -> *mut c_char {
match guarded(|| { match guarded(|| {
let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? };
Ok(store Ok(store
.server_pin(text(host)?)? .server_pin(text(host)?, port)?
.map(|key| b32(&key)) .map(|key| b32(&key))
.unwrap_or_default()) .unwrap_or_default())
}) { }) {
@ -426,12 +437,13 @@ pub extern "C" fn smol_contact_save(
}) })
} }
/// Removes a pin: the next session against that host is trust on first use. /// Removes a pin: the next session against that host and port is trust on
/// first use.
#[no_mangle] #[no_mangle]
pub extern "C" fn smol_unpin_server(store: *mut Store, host: *const c_char) -> i32 { pub extern "C" fn smol_unpin_server(store: *mut Store, host: *const c_char, port: u16) -> i32 {
status(|| { status(|| {
let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? };
store.unpin_server(text(host)?) store.unpin_server(text(host)?, port)
}) })
} }
@ -1178,6 +1190,7 @@ mod tests {
let json: serde_json::Value = serde_json::from_str(&slot).unwrap(); let json: serde_json::Value = serde_json::from_str(&slot).unwrap();
assert_eq!(json["code"], NOT_PINNED); assert_eq!(json["code"], NOT_PINNED);
assert_eq!(json["host"], "127.0.0.1"); assert_eq!(json["host"], "127.0.0.1");
assert_eq!(json["port"], 19619);
// Strings returned on success must be freed without double-free. // Strings returned on success must be freed without double-free.
assert_eq!(take(smol_store_account(store)), ""); assert_eq!(take(smol_store_account(store)), "");
@ -1193,7 +1206,8 @@ mod tests {
fn mail_and_contacts_render_as_json() { fn mail_and_contacts_render_as_json() {
let store = smol_store_memory(); let store = smol_store_memory();
let s = unsafe { &*store }; let s = unsafe { &*store };
s.pin_server("example.org", &[1u8; KEY_LEN]).unwrap(); s.pin_server("example.org", fumi_core::address::DEFAULT_PORT, &[1u8; KEY_LEN])
.unwrap();
s.save_contact("alice@example.org", &[2u8; KEY_LEN], true).unwrap(); s.save_contact("alice@example.org", &[2u8; KEY_LEN], true).unwrap();
s.save_history("alice@example.org", &[9u8; KEY_LEN], 500).unwrap(); s.save_history("alice@example.org", &[9u8; KEY_LEN], 500).unwrap();
s.store_inbox(&[3u8; ID_LEN], b"sealed", 7, false, true).unwrap(); s.store_inbox(&[3u8; ID_LEN], b"sealed", 7, false, true).unwrap();

View file

@ -86,8 +86,8 @@ mod tests {
let alice_addr = let alice_addr =
Address::parse(&format!("a{run}@127.0.0.1:19619")).unwrap(); Address::parse(&format!("a{run}@127.0.0.1:19619")).unwrap();
let bob_addr = Address::parse(&format!("b{run}@127.0.0.1:19619")).unwrap(); let bob_addr = Address::parse(&format!("b{run}@127.0.0.1:19619")).unwrap();
alice_store.pin_server("127.0.0.1", &server).unwrap(); alice_store.pin_server("127.0.0.1", 19619, &server).unwrap();
bob_store.pin_server("127.0.0.1", &server).unwrap(); bob_store.pin_server("127.0.0.1", 19619, &server).unwrap();
register(&alice_store, &alice_addr, &alice, None, 5).unwrap(); register(&alice_store, &alice_addr, &alice, None, 5).unwrap();
register(&bob_store, &bob_addr, &bob, None, 5).unwrap(); register(&bob_store, &bob_addr, &bob, None, 5).unwrap();
@ -168,8 +168,8 @@ mod tests {
let run = fumi_core::crypto::b32(&suffix); let run = fumi_core::crypto::b32(&suffix);
let alice_addr = Address::parse(&format!("a{run}@127.0.0.1:19619")).unwrap(); let alice_addr = Address::parse(&format!("a{run}@127.0.0.1:19619")).unwrap();
let bob_addr = Address::parse(&format!("b{run}@127.0.0.1:19619")).unwrap(); let bob_addr = Address::parse(&format!("b{run}@127.0.0.1:19619")).unwrap();
alice_store.pin_server("127.0.0.1", &server).unwrap(); alice_store.pin_server("127.0.0.1", 19619, &server).unwrap();
bob_store.pin_server("127.0.0.1", &server).unwrap(); bob_store.pin_server("127.0.0.1", 19619, &server).unwrap();
register(&alice_store, &alice_addr, &alice, None, 5).unwrap(); register(&alice_store, &alice_addr, &alice, None, 5).unwrap();
register(&bob_store, &bob_addr, &bob, None, 5).unwrap(); register(&bob_store, &bob_addr, &bob, None, 5).unwrap();
@ -272,7 +272,7 @@ mod tests {
fumi_core::address::Address::parse(&format!("u{run}@{host}:19619")) fumi_core::address::Address::parse(&format!("u{run}@{host}:19619"))
.unwrap() .unwrap()
.with_dial("127.0.0.1"); .with_dial("127.0.0.1");
store.pin_server(&host, &server).unwrap(); store.pin_server(&host, 19619, &server).unwrap();
register(&store, &addr, &account, None, 5).unwrap(); register(&store, &addr, &account, None, 5).unwrap();
assert_eq!(store.account().unwrap().unwrap().short(), assert_eq!(store.account().unwrap().unwrap().short(),
format!("u{run}@{host}:19619")); format!("u{run}@{host}:19619"));
@ -296,7 +296,10 @@ mod tests {
let unpinned = Store::open_in_memory().unwrap(); let unpinned = Store::open_in_memory().unwrap();
match connect(&unpinned, &addr, true, 5) { match connect(&unpinned, &addr, true, 5) {
Err(Error::NotPinned { host }) => assert_eq!(host, "127.0.0.1"), Err(Error::NotPinned { host, port }) => {
assert_eq!(host, "127.0.0.1");
assert_eq!(port, 19619);
}
Err(err) => panic!("expected NotPinned, got {err}"), Err(err) => panic!("expected NotPinned, got {err}"),
Ok(_) => panic!("expected NotPinned, connected"), Ok(_) => panic!("expected NotPinned, connected"),
} }
@ -304,7 +307,7 @@ mod tests {
let mut wrong = [0u8; KEY_LEN]; let mut wrong = [0u8; KEY_LEN];
OsRng.fill_bytes(&mut wrong); OsRng.fill_bytes(&mut wrong);
let mismatched = Store::open_in_memory().unwrap(); let mismatched = Store::open_in_memory().unwrap();
mismatched.pin_server("127.0.0.1", &wrong).unwrap(); mismatched.pin_server("127.0.0.1", 19619, &wrong).unwrap();
match connect(&mismatched, &addr, true, 5) { match connect(&mismatched, &addr, true, 5) {
Err(Error::PinMismatch { .. }) => {} Err(Error::PinMismatch { .. }) => {}
Err(err) => panic!("expected PinMismatch, got {err}"), Err(err) => panic!("expected PinMismatch, got {err}"),
@ -348,8 +351,8 @@ mod tests {
let run = fumi_core::crypto::b32(&suffix); let run = fumi_core::crypto::b32(&suffix);
let carol_addr = Address::parse(&format!("c{run}@127.0.0.1:19619")).unwrap(); let carol_addr = Address::parse(&format!("c{run}@127.0.0.1:19619")).unwrap();
let alice_addr = Address::parse(&format!("d{run}@127.0.0.1:19619")).unwrap(); let alice_addr = Address::parse(&format!("d{run}@127.0.0.1:19619")).unwrap();
carol_store.pin_server("127.0.0.1", &server).unwrap(); carol_store.pin_server("127.0.0.1", 19619, &server).unwrap();
alice_store.pin_server("127.0.0.1", &server).unwrap(); alice_store.pin_server("127.0.0.1", 19619, &server).unwrap();
register(&carol_store, &carol_addr, &carol, None, 5).unwrap(); register(&carol_store, &carol_addr, &carol, None, 5).unwrap();
register(&alice_store, &alice_addr, &alice, None, 5).unwrap(); register(&alice_store, &alice_addr, &alice, None, 5).unwrap();

View file

@ -33,6 +33,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" version: "2.0.3"
app_links:
dependency: "direct main"
description:
name: app_links
sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
url: "https://pub.dev"
source: hosted
version: "1.0.3"
app_links_platform_interface:
dependency: "direct dev"
description:
name: app_links_platform_interface
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@ -426,6 +458,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
gtk:
dependency: transitive
description:
name: gtk
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
hive: hive:
dependency: "direct main" dependency: "direct main"
description: description:
@ -631,6 +671,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: "5a51ea79f0cf8293f616a5d08baae185b59b5e6bb24df01f2a63037ade0bade0"
url: "https://pub.dev"
source: hosted
version: "7.4.2"
node_preamble: node_preamble:
dependency: transitive dependency: transitive
description: description:
@ -799,6 +847,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" version: "1.6.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
record_use: record_use:
dependency: transitive dependency: transitive
description: description:

View file

@ -21,6 +21,9 @@ dependencies:
share_plus: ^13.3.0 share_plus: ^13.3.0
path_provider: ^2.1.6 path_provider: ^2.1.6
file_picker: ^13.1.0 file_picker: ^13.1.0
qr_flutter: ^4.1.0
app_links: ^7.0.0
mobile_scanner: ^7.4.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -33,6 +36,7 @@ dev_dependencies:
auto_route_generator: ^10.5.0 auto_route_generator: ^10.5.0
flutter_native_splash: ^2.3.4 flutter_native_splash: ^2.3.4
flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: ^0.14.4
app_links_platform_interface: ^2.0.2
# patrol test reads this block; android.package_name is the flavored # patrol test reads this block; android.package_name is the flavored
# applicationId (the integration flavor adds the .integration suffix), and # applicationId (the integration flavor adds the .integration suffix), and
@ -59,8 +63,13 @@ flutter_native_splash:
web: false web: false
ios: false ios: false
color: "#01111d" color: "#01111d"
image: assets/images/sparkle.png image: assets/images/bug_logo.png
branding: assets/images/sparkle.png branding: assets/images/bug_logo.png
android_12:
color: "#01111d"
icon_background_color: "#01111d"
image: assets/images/bug_logo.png
branding: assets/images/bug_logo.png
flutter: flutter:
uses-material-design: true uses-material-design: true
@ -68,7 +77,6 @@ flutter:
- assets/images/bug_logo.png - assets/images/bug_logo.png
- assets/images/empty.png - assets/images/empty.png
- assets/images/error.png - assets/images/error.png
- assets/images/avatar.png
fonts: fonts:
- family: FlashMailIcons - family: FlashMailIcons
fonts: fonts:

BIN
test-qr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

View file

@ -60,7 +60,7 @@ void main() {
final me = client.identity!; final me = client.identity!;
final user = "e2e${hex(randomBytes(4))}"; final user = "e2e${hex(randomBytes(4))}";
final address = parseAddress("$user@$host"); final address = parseAddress("$user@$host");
store.pinServer(host, serverKey); store.pinServer(host, serverKey, port);
await client.registerAccount(address.short); await client.registerAccount(address.short);
expect(client.accountAddress()!.short, address.short); expect(client.accountAddress()!.short, address.short);
@ -129,7 +129,7 @@ void main() {
secondStore.restoreMaster(store.master()!, 0); secondStore.restoreMaster(store.master()!, 0);
await expectLater( await expectLater(
secondDevice.recallAccount(address.short), throwsA(isA<SmolError>())); secondDevice.recallAccount(address.short), throwsA(isA<SmolError>()));
secondStore.pinServer(host, serverKey); secondStore.pinServer(host, serverKey, port);
await expectLater( await expectLater(
secondDevice.registerAccount(address.short), throwsA(isA<SmolError>())); secondDevice.registerAccount(address.short), throwsA(isA<SmolError>()));
final bound = await secondDevice.recallAccount(address.short); final bound = await secondDevice.recallAccount(address.short);
@ -157,7 +157,7 @@ void main() {
await client.createIdentity(); await client.createIdentity();
final user = "e2ekeep${hex(randomBytes(4))}"; final user = "e2ekeep${hex(randomBytes(4))}";
final address = parseAddress("$user@$host"); final address = parseAddress("$user@$host");
store.pinServer(host, serverKey); store.pinServer(host, serverKey, port);
await client.registerAccount(address.short); await client.registerAccount(address.short);
await store.setLeaveOnServer(true); await store.setLeaveOnServer(true);

View file

@ -13,6 +13,7 @@ import "dart:typed_data";
import "package:flutter_test/flutter_test.dart"; import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart"; import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/smol/address.dart";
import "package:smol_mail/smol/store.dart"; import "package:smol_mail/smol/store.dart";
const fumiBinary = "../fumi/target/release/fumi"; const fumiBinary = "../fumi/target/release/fumi";
@ -38,7 +39,7 @@ void main() {
stateBox: "interop-state", stateBox: "interop-state",
readBox: "interop-read"); readBox: "interop-read");
mine.restoreMaster(master, 0); mine.restoreMaster(master, 0);
mine.pinServer("example.org", pinKey); mine.pinServer("example.org", pinKey, defaultPort);
// One contact, bound to the current key; rotation history is written by // One contact, bound to the current key; rotation history is written by
// the trust engine on a validated rotation, not by a plain re-save. // the trust engine on a validated rotation, not by a plain re-save.
await mine.saveContact("alice@example.org", pinKey, verified: true); await mine.saveContact("alice@example.org", pinKey, verified: true);
@ -72,6 +73,6 @@ void main() {
// The binding survived both directions of the round trip. // The binding survived both directions of the round trip.
final contact = restored.contact("alice@example.org")!; final contact = restored.contact("alice@example.org")!;
expect(contact.key, pinKey); expect(contact.key, pinKey);
expect(restored.serverPin("example.org"), pinKey); expect(restored.serverPin("example.org", defaultPort), pinKey);
}); });
} }

45
test/link_test.dart Normal file
View file

@ -0,0 +1,45 @@
// Deep links: only smol:// URIs react, and only the native parser's verdict
// decides that — the format rules stay in fumi, not a Dart copy of them.
import "package:flutter_test/flutter_test.dart";
import "package:smol_mail/smol/link.dart";
const key = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq";
void main() {
test("self-certifying URI carries its key", () {
final link = parseSmolLink("smol://alice@example.org/$key");
expect(link, isNotNull);
expect(link!.address.identity, key);
expect(link.address.user, "alice");
expect(link.address.host, "example.org");
expect(link.address.port, 1961);
expect(link.source, "smol://alice@example.org/$key");
});
test("a non-default port is kept", () {
final link = parseSmolLink("smol://alice@example.org:1962/$key");
expect(link!.address.port, 1962);
});
test("a smol:// URI without its key is not a link", () {
// fumi rejects this form, so a truncated scan acts on nothing rather
// than importing a key the sender never signed into.
expect(parseSmolLink("smol://alice@example.org"), isNull);
});
test("other schemes are not ours", () {
expect(parseSmolLink("https://example.org/a"), isNull);
expect(parseSmolLink("mailto:alice@example.org"), isNull);
});
test("scheme-less addresses are not deep links", () {
expect(parseSmolLink("alice@example.org"), isNull);
});
test("a smol:// URI the parser rejects is ignored", () {
expect(parseSmolLink("smol://not a uri"), isNull);
expect(parseSmolLink("smol://alice@example.org/tooshort"), isNull);
});
}

View file

@ -11,6 +11,7 @@ import "package:flutter_test/flutter_test.dart";
import "package:smol_mail/native/client.dart"; import "package:smol_mail/native/client.dart";
import "package:smol_mail/native/ffi.dart"; import "package:smol_mail/native/ffi.dart";
import "package:smol_mail/smol/address.dart";
const spikeServerKey = "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga"; const spikeServerKey = "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga";
const spikeServer = "127.0.0.1"; const spikeServer = "127.0.0.1";
@ -39,7 +40,9 @@ void main() async {
expect(await client.accountAddress(), isNull); expect(await client.accountAddress(), isNull);
// A pinned but dead host fails by code, never by prose: Unreachable (7). // A pinned but dead host fails by code, never by prose: Unreachable (7).
await client.pinServer("127.0.0.1", spikeServerKey); // The pin is scoped to the dead port itself — a default-port pin would
// not cover it, and the register would fail NotPinned instead.
await client.pinServer("127.0.0.1", spikeServerKey, 19629);
client.setMaster(Uint8List.fromList(List.filled(32, 9))); client.setMaster(Uint8List.fromList(List.filled(32, 9)));
try { try {
await client.register("nobody@127.0.0.1:19629"); await client.register("nobody@127.0.0.1:19629");
@ -63,7 +66,7 @@ void main() async {
final mine = FumiNative("${dir.path}/mine.db"); final mine = FumiNative("${dir.path}/mine.db");
await mine.open(); await mine.open();
mine.setMaster(master); mine.setMaster(master);
await mine.pinServer("example.org", spikeServerKey); await mine.pinServer("example.org", spikeServerKey, defaultPort);
await mine.importContact( await mine.importContact(
"smol://alice@example.org/ayb6y3mwr3cfkcmcaqbn3cgfi6xsrsoqkalykw5s7oqmwqcpnmsq"); "smol://alice@example.org/ayb6y3mwr3cfkcmcaqbn3cgfi6xsrsoqkalykw5s7oqmwqcpnmsq");
@ -76,7 +79,7 @@ void main() async {
as Map<String, dynamic>; as Map<String, dynamic>;
expect(summary["contactsAdded"], 1); expect(summary["contactsAdded"], 1);
expect(summary["pinsAdded"], 1); expect(summary["pinsAdded"], 1);
expect(await restored.serverPin("example.org"), spikeServerKey); expect(await restored.serverPin("example.org", defaultPort), spikeServerKey);
await mine.close(); await mine.close();
await restored.close(); await restored.close();
@ -98,14 +101,14 @@ void main() async {
final alice = FumiNative("${dir.path}/alice.db"); final alice = FumiNative("${dir.path}/alice.db");
await alice.open(); await alice.open();
alice.setMaster(aliceMaster); alice.setMaster(aliceMaster);
await alice.pinServer(spikeServer, spikeServerKey); await alice.pinServer(spikeServer, spikeServerKey, spikePort);
await alice.register("a$run@$spikeServer:$spikePort"); await alice.register("a$run@$spikeServer:$spikePort");
expect(await alice.accountAddress(), "a$run@$spikeServer:$spikePort"); expect(await alice.accountAddress(), "a$run@$spikeServer:$spikePort");
final bob = FumiNative("${dir.path}/bob.db"); final bob = FumiNative("${dir.path}/bob.db");
await bob.open(); await bob.open();
bob.setMaster(bobMaster); bob.setMaster(bobMaster);
await bob.pinServer(spikeServer, spikeServerKey); await bob.pinServer(spikeServer, spikeServerKey, spikePort);
await bob.register("b$run@$spikeServer:$spikePort"); await bob.register("b$run@$spikeServer:$spikePort");
final sent = await alice.send("b$run@$spikeServer:$spikePort", final sent = await alice.send("b$run@$spikeServer:$spikePort",
@ -147,7 +150,7 @@ void main() async {
final alice = FumiNative("${dir.path}/alice.db"); final alice = FumiNative("${dir.path}/alice.db");
await alice.open(); await alice.open();
alice.setMaster(aliceMaster); alice.setMaster(aliceMaster);
await alice.pinServer(spikeServer, spikeServerKey); await alice.pinServer(spikeServer, spikeServerKey, spikePort);
await alice.register("a$run@$spikeServer:$spikePort"); await alice.register("a$run@$spikeServer:$spikePort");
final bob = FumiNative("${dir.path}/bob.db"); final bob = FumiNative("${dir.path}/bob.db");
@ -156,7 +159,7 @@ void main() async {
// The onboarding screen zeroes its own master reference on dispose; // The onboarding screen zeroes its own master reference on dispose;
// whatever holds the master after that must not share that buffer. // whatever holds the master after that must not share that buffer.
bobMaster.fillRange(0, 32, 0); bobMaster.fillRange(0, 32, 0);
await bob.pinServer(spikeServer, spikeServerKey); await bob.pinServer(spikeServer, spikeServerKey, spikePort);
await bob.register("b$run@$spikeServer:$spikePort"); await bob.register("b$run@$spikeServer:$spikePort");
await alice.send("b$run@$spikeServer:$spikePort", await alice.send("b$run@$spikeServer:$spikePort",

28
test/qr_sheet_test.dart Normal file
View file

@ -0,0 +1,28 @@
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
import "package:qr_flutter/qr_flutter.dart";
import "package:smol_mail/presentation/theme/theme.dart";
import "package:smol_mail/presentation/widgets/qr_code_sheet.dart";
void main() {
testWidgets("the sheet renders the payload as a scannable QR code",
(tester) async {
const payload = "smol://alice@example.org/lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq";
await tester.pumpWidget(
MaterialApp(
theme: appThemeLight,
home: Builder(
builder: (context) => TextButton(
onPressed: () => showQrCodeSheet(context, payload),
child: const Text("show"),
),
),
),
);
await tester.tap(find.text("show"));
await tester.pumpAndSettle();
expect(find.byType(QrImageView), findsOneWidget);
expect(find.text(payload), findsOneWidget);
});
}

View file

@ -36,7 +36,7 @@ class StubClient extends SmolClient {
@override @override
Future<SmolAddress> restoreAndRecall(String masterHex, String addressText) async { Future<SmolAddress> restoreAndRecall(String masterHex, String addressText) async {
final addr = parseAddress(addressText); final addr = parseAddress(addressText);
if (store.serverPin(addr.host) == null) { if (store.serverPin(addr.host, addr.port) == null) {
throw SmolError("no pinned key for ${addr.host}"); throw SmolError("no pinned key for ${addr.host}");
} }
store.restoreMaster(unhex(masterHex.trim()), 0); store.restoreMaster(unhex(masterHex.trim()), 0);

View file

@ -8,6 +8,7 @@ import "dart:typed_data";
import "package:flutter_test/flutter_test.dart"; import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart"; import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/smol/address.dart";
import "package:smol_mail/smol/store.dart"; import "package:smol_mail/smol/store.dart";
Uint8List randomBytes(int n) => Uint8List randomBytes(int n) =>
@ -66,11 +67,11 @@ void main() {
expect(store.allPins(), isEmpty); expect(store.allPins(), isEmpty);
// A syntactically valid key: the system server's, a real 52-char form. // A syntactically valid key: the system server's, a real 52-char form.
const key = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; const key = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq";
store.pinServer("example.org", key); store.pinServer("example.org", key, defaultPort);
expect(store.serverPin("example.org"), key); expect(store.serverPin("example.org", defaultPort), key);
expect(store.allPins().length, 1); expect(store.allPins().length, 1);
await store.unpinServer("example.org"); await store.unpinServer("example.org", defaultPort);
expect(store.serverPin("example.org"), isNull); expect(store.serverPin("example.org", defaultPort), isNull);
}); });
test("wipe clears every secret and mark, overwriting the master", () async { test("wipe clears every secret and mark, overwriting the master", () async {
@ -80,7 +81,7 @@ void main() {
// The store's own copy, read back before the wipe. // The store's own copy, read back before the wipe.
final stored = store.master()!; final stored = store.master()!;
store.pinServer("example.org", store.pinServer("example.org",
"lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"); "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq", defaultPort);
store.markRead("cd" * 32); store.markRead("cd" * 32);
await store.wipe(); await store.wipe();
@ -93,7 +94,7 @@ void main() {
expect(master.every((b) => b == 0), isFalse); expect(master.every((b) => b == 0), isFalse);
expect(store.master(), isNull); expect(store.master(), isNull);
expect(store.identity(), isNull); expect(store.identity(), isNull);
expect(store.serverPin("example.org"), isNull); expect(store.serverPin("example.org", defaultPort), isNull);
expect(store.isRead("cd" * 32), isFalse); expect(store.isRead("cd" * 32), isFalse);
}); });
} }

View file

@ -1,5 +1,6 @@
import "dart:io"; import "dart:io";
import "package:app_links_platform_interface/app_links_platform_interface.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_test/flutter_test.dart"; import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart"; import "package:hive_flutter/hive_flutter.dart";
@ -10,11 +11,22 @@ import "package:smol_mail/smol/store.dart";
late final SmolStore store; late final SmolStore store;
// AppWidget subscribes to app_links on boot; in the test shell there is no
// platform plugin to talk to, so the subscription points at this no-op.
class _NoopAppLinks extends AppLinksPlatform {
@override
Future<Uri?> getInitialLink() async => null;
@override
Stream<Uri> get uriLinkStream => const Stream.empty();
}
void main() { void main() {
// Hive's box opening is real file IO, which never completes inside a // Hive's box opening is real file IO, which never completes inside a
// testWidgets fake-async zone — so it happens here, outside one. // testWidgets fake-async zone — so it happens here, outside one.
setUpAll(() async { setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
AppLinksPlatform.instance = _NoopAppLinks();
final dir = await Directory.systemTemp.createTemp("smol-widget-test"); final dir = await Directory.systemTemp.createTemp("smol-widget-test");
Hive.init(dir.path); Hive.init(dir.path);
store = await SmolStore.open(dbPath: "${dir.path}/widget.db"); store = await SmolStore.open(dbPath: "${dir.path}/widget.db");