feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention
This commit is contained in:
parent
eaaa3f2ede
commit
c693e6fcb9
27 changed files with 1190 additions and 592 deletions
|
|
@ -29,12 +29,17 @@ enum _Step { welcome, backup, restore, register }
|
|||
|
||||
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||
_Step step = _Step.welcome;
|
||||
Uint8List? createdSeed;
|
||||
Uint8List? createdMaster;
|
||||
bool busy = false;
|
||||
// The register step doubles as "pin a key to finish recalling" when a
|
||||
// restore's recall can't proceed without one yet — same fields, different
|
||||
// framing and default action, not the generic "register a new address" copy.
|
||||
bool recallIntent = false;
|
||||
// Set alongside recallIntent when we got here from Restore rather than from
|
||||
// "Already registered? Recall": there is no well-defined "register a new
|
||||
// address instead" fallback for a restored master until its rotation index
|
||||
// is known, so that escape hatch is hidden in this case (§2).
|
||||
bool restoreIntent = false;
|
||||
|
||||
final seedController = TextEditingController();
|
||||
final restoreAddressController = TextEditingController();
|
||||
|
|
@ -62,11 +67,11 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
}
|
||||
|
||||
// Reaching onboarding at all means HomeGuard already found no complete
|
||||
// identity+account, so a seed still sitting in the store here can only be
|
||||
// an abandoned attempt from earlier in this same flow (wrong seed, failed
|
||||
// identity+account, so a master still sitting in the store here can only be
|
||||
// an abandoned attempt from earlier in this same flow (wrong master, failed
|
||||
// recall, "Back") — safe to replace rather than reject.
|
||||
// wipe() is fire-and-forget here, like every other store write in this
|
||||
// screen (setIdentity, pinServer, ...) — Hive updates its in-memory state
|
||||
// screen (setMaster, pinServer, ...) — Hive updates its in-memory state
|
||||
// synchronously and persists to disk in the background, so the identity
|
||||
// check right after is already consistent without awaiting the write.
|
||||
void _clearAbandonedIdentity() {
|
||||
|
|
@ -80,9 +85,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
_clearAbandonedIdentity();
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
final fresh = client.createIdentity();
|
||||
final master = client.createIdentity();
|
||||
setState(() {
|
||||
createdSeed = fresh.seed;
|
||||
createdMaster = master;
|
||||
step = _Step.backup;
|
||||
});
|
||||
} on Exception catch (err) {
|
||||
|
|
@ -94,74 +99,49 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
setState(() => step = _Step.restore);
|
||||
}
|
||||
|
||||
// Restoring must succeed on its own even if recall fails (server not
|
||||
// pinned yet, offline, typo) — recall can always be retried from the
|
||||
// register step or settings afterward.
|
||||
void _submitRestore() {
|
||||
_clearAbandonedIdentity();
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
client.restoreIdentity(seedController.text);
|
||||
} on Exception catch (err) {
|
||||
_showError(err);
|
||||
return;
|
||||
}
|
||||
// §2: a master alone does not say which rotation index a server bound, so
|
||||
// restoring resolves the address and walks indices to find it — restore
|
||||
// always ends in a recall, never a bare local step.
|
||||
Future<void> _submitRestore() async {
|
||||
final addressText = restoreAddressController.text.trim();
|
||||
if (addressText.isEmpty) {
|
||||
setState(() => step = _Step.register);
|
||||
_showError(const SmolError("enter the address this master was registered under"));
|
||||
return;
|
||||
}
|
||||
_clearAbandonedIdentity();
|
||||
// The register step has its own address field (it also needs a server
|
||||
// key, which restore doesn't collect) — carry over what was already
|
||||
// typed rather than making the user re-enter it.
|
||||
addressController.text = addressText;
|
||||
final SmolAddress addr;
|
||||
final client = ref.read(clientProvider);
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
addr = parseAddress(addressText);
|
||||
await client.restoreAndRecall(seedController.text, addressText);
|
||||
if (!mounted) return;
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
AutoRouter.of(context).replace(HomeRoute());
|
||||
} on Exception catch (err) {
|
||||
// A genuine typo, distinct from the merely-unpinned case below — still
|
||||
// worth an error, but land on the same recall-oriented step to fix it.
|
||||
if (!mounted) return;
|
||||
// Most often the host just isn't pinned yet (SPEC.md §4) — the expected
|
||||
// state right after a restore, not a dead end — so land on the
|
||||
// recall-framed register step to collect a key and retry.
|
||||
_showError(err);
|
||||
setState(() {
|
||||
recallIntent = true;
|
||||
restoreIntent = true;
|
||||
step = _Step.register;
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
// Recall needs the host pinned first (SPEC.md §4). That's the expected,
|
||||
// common state right after a restore — not an error — so check for it
|
||||
// up front instead of letting recallAccount fail and surfacing that as
|
||||
// one: this address just needs a key before its first recall can proceed.
|
||||
if (ref.read(storeProvider).serverPin(addr.host) == null) {
|
||||
setState(() {
|
||||
recallIntent = true;
|
||||
step = _Step.register;
|
||||
});
|
||||
return;
|
||||
}
|
||||
() async {
|
||||
try {
|
||||
await client.recallAccount(addressText);
|
||||
if (!mounted) return;
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
AutoRouter.of(context).replace(InboxRoute());
|
||||
} on Exception catch (err) {
|
||||
if (!mounted) return;
|
||||
_showError(err);
|
||||
setState(() {
|
||||
recallIntent = true;
|
||||
step = _Step.register;
|
||||
});
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
Future<void> _submitRegistration() async {
|
||||
await _submit(recall: false);
|
||||
}
|
||||
|
||||
// Restoring a seed on a new device knows the identity but not the address it
|
||||
// was registered under; recall binds it without re-REGISTER.
|
||||
// Recall binds a restored or already-created identity to its registered
|
||||
// address without re-REGISTER.
|
||||
Future<void> _submitRecall() async {
|
||||
await _submit(recall: true);
|
||||
}
|
||||
|
|
@ -178,7 +158,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
if (serverKey.isNotEmpty) {
|
||||
client.pinServer(address.host, serverKey);
|
||||
}
|
||||
if (recall) {
|
||||
if (recall && restoreIntent) {
|
||||
await client.restoreAndRecall(seedController.text, address.short);
|
||||
} else if (recall) {
|
||||
await client.recallAccount(address.short);
|
||||
} else {
|
||||
await client.registerAccount(address.short,
|
||||
|
|
@ -186,7 +168,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
}
|
||||
if (mounted) {
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
AutoRouter.of(context).replace(InboxRoute());
|
||||
AutoRouter.of(context).replace(HomeRoute());
|
||||
}
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
|
|
@ -248,20 +230,21 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
}
|
||||
|
||||
Widget _backup(BuildContext context) {
|
||||
final seed = createdSeed!;
|
||||
final seedHex = hex(seed);
|
||||
final master = createdMaster!;
|
||||
final masterHex = hex(master);
|
||||
final publicKey = ref.read(clientProvider).identity!.publicKey;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context, "Back up this seed; it is the only secret."),
|
||||
_header(context, "Back up this master secret; it is the only secret."),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
"fingerprint:\n${fingerprint(ed25519PublicKey(seed))}",
|
||||
"fingerprint:\n${fingerprint(publicKey)}",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SelectableText(
|
||||
seedHex,
|
||||
masterHex,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!
|
||||
|
|
@ -269,14 +252,14 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: seedHex));
|
||||
await Clipboard.setData(ClipboardData(text: masterHex));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
const SnackBar(content: Text("Seed copied to clipboard")),
|
||||
const SnackBar(content: Text("Master secret copied to clipboard")),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text("Copy seed"),
|
||||
child: const Text("Copy master secret"),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
PrimaryButton(
|
||||
|
|
@ -292,7 +275,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context, "Enter the 32-byte seed as 64 hex characters."),
|
||||
_header(context, "Enter the 32-byte master secret as 64 hex characters."),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: seedController,
|
||||
|
|
@ -306,7 +289,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
TextField(
|
||||
controller: restoreAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Address (if already registered)",
|
||||
labelText: "Address this master was registered under",
|
||||
hintText: "alice@example.org",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
|
|
@ -314,13 +297,13 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
),
|
||||
const SizedBox(height: 30),
|
||||
PrimaryButton(
|
||||
onPressed: _submitRestore,
|
||||
child: const Text("Restore"),
|
||||
onPressed: busy ? () {} : _submitRestore,
|
||||
child: busy ? const SmallLoadingSpinner() : const Text("Restore"),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
SecondaryButton(
|
||||
text: "Back",
|
||||
onPressed: () => setState(() => step = _Step.welcome),
|
||||
onPressed: busy ? () {} : () => setState(() => step = _Step.welcome),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
|
|
@ -377,14 +360,18 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||
? const SmallLoadingSpinner()
|
||||
: Text(recallIntent ? "Pin and Recall" : "Pin and Register"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: busy
|
||||
? () {}
|
||||
: (recallIntent ? _submitRegistration : _submitRecall),
|
||||
child: Text(recallIntent
|
||||
? "Register a new address instead"
|
||||
: "Already registered? Recall"),
|
||||
),
|
||||
// Restoring a master has no well-defined "register instead" fallback
|
||||
// until its rotation index is resolved (§2), so that escape hatch is
|
||||
// only offered from the fresh-identity path.
|
||||
if (!restoreIntent)
|
||||
TextButton(
|
||||
onPressed: busy
|
||||
? () {}
|
||||
: (recallIntent ? _submitRegistration : _submitRecall),
|
||||
child: Text(recallIntent
|
||||
? "Register a new address instead"
|
||||
: "Already registered? Recall"),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue