25ed765a03
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
535 lines
17 KiB
Dart
535 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:matrix/encryption.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:pyramid/core/theme.dart';
|
|
|
|
Future<void> showBootstrapDialog(
|
|
BuildContext context,
|
|
Client client, {
|
|
bool force = false,
|
|
}) async {
|
|
if (client.encryption == null) return;
|
|
if (!force) {
|
|
final enc = client.encryption!;
|
|
final cached = await enc.keyManager.isCached().catchError((_) => false);
|
|
if (cached && enc.crossSigning.enabled) return;
|
|
}
|
|
if (!context.mounted) return;
|
|
await showDialog<bool>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => _BootstrapDialog(client: client),
|
|
);
|
|
}
|
|
|
|
class _BootstrapDialog extends StatefulWidget {
|
|
final Client client;
|
|
const _BootstrapDialog({required this.client});
|
|
|
|
@override
|
|
State<_BootstrapDialog> createState() => _BootstrapDialogState();
|
|
}
|
|
|
|
class _BootstrapDialogState extends State<_BootstrapDialog> {
|
|
Bootstrap? _bootstrap;
|
|
final _keyCtrl = TextEditingController();
|
|
bool _keyLoading = false;
|
|
String? _keyError;
|
|
bool _wipe = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_start();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_keyCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _start() async {
|
|
final client = widget.client;
|
|
await client.roomsLoading;
|
|
await client.accountDataLoading;
|
|
await client.userDeviceKeysLoading;
|
|
while (client.prevBatch == null) {
|
|
await client.onSyncStatus.stream.first;
|
|
}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_bootstrap = client.encryption!.bootstrap(
|
|
onUpdate: (_) {
|
|
if (mounted) setState(() {});
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
void _autoAdvance(Bootstrap bs) {
|
|
switch (bs.state) {
|
|
case BootstrapState.askWipeSsss:
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.wipeSsss(_wipe));
|
|
case BootstrapState.askBadSsss:
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.ignoreBadSecrets(true));
|
|
case BootstrapState.askUseExistingSsss:
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.useExistingSsss(!_wipe));
|
|
case BootstrapState.askUnlockSsss:
|
|
// Handled by UI if oldSsssKeys exist, otherwise skip
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.unlockedSsss());
|
|
case BootstrapState.askNewSsss:
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss());
|
|
case BootstrapState.askWipeCrossSigning:
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.wipeCrossSigning(_wipe));
|
|
case BootstrapState.askSetupCrossSigning:
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => bs.askSetupCrossSigning(
|
|
setupMasterKey: true,
|
|
setupSelfSigningKey: true,
|
|
setupUserSigningKey: true,
|
|
),
|
|
);
|
|
case BootstrapState.askWipeOnlineKeyBackup:
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(_wipe));
|
|
case BootstrapState.askSetupOnlineKeyBackup:
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true));
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _unlock() async {
|
|
final bs = _bootstrap;
|
|
if (bs == null || bs.newSsssKey == null) return;
|
|
final key = _keyCtrl.text.trim();
|
|
if (key.isEmpty) {
|
|
setState(() => _keyError = 'Bitte Wiederherstellungsschlüssel eingeben.');
|
|
return;
|
|
}
|
|
setState(() {
|
|
_keyLoading = true;
|
|
_keyError = null;
|
|
});
|
|
try {
|
|
await bs.newSsssKey!.unlock(keyOrPassphrase: key);
|
|
Logs().d('[Bootstrap] SSSS key unlocked');
|
|
await bs.openExistingSsss();
|
|
Logs().d('[Bootstrap] SSSS opened, secrets cached');
|
|
if (bs.encryption.crossSigning.enabled) {
|
|
await widget.client.encryption!.crossSigning.selfSign(recoveryKey: key);
|
|
Logs().d('[Bootstrap] Cross-signing self-signed');
|
|
}
|
|
} on InvalidPassphraseException catch (_) {
|
|
setState(
|
|
() => _keyError = 'Falscher Schlüssel. Bitte nochmal prüfen.',
|
|
);
|
|
} on FormatException catch (_) {
|
|
setState(() => _keyError = 'Ungültiges Format. Prüfe deinen Schlüssel.');
|
|
} catch (e) {
|
|
setState(() => _keyError = e.toString().split('\n').first);
|
|
} finally {
|
|
if (mounted) setState(() => _keyLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _close({required bool success}) async {
|
|
if (success) {
|
|
// Download all megolm session keys from backup — bypasses
|
|
// _requestedSessionIds deduplication so previously failed decryptions
|
|
// get a second chance.
|
|
widget.client.encryption?.keyManager
|
|
.loadAllKeys()
|
|
.catchError((e) => Logs().e('[Bootstrap] loadAllKeys failed', e));
|
|
}
|
|
if (mounted) Navigator.of(context).pop(success);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pt = PyramidTheme.of(context);
|
|
final bs = _bootstrap;
|
|
|
|
// Waiting for bootstrap to initialise
|
|
if (bs == null || bs.state == BootstrapState.loading) {
|
|
return _Shell(
|
|
pt: pt,
|
|
title: 'Verschlüsselung einrichten',
|
|
onClose: () => _close(success: false),
|
|
child: const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: CircularProgressIndicator.adaptive(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// User must enter recovery key
|
|
if (bs.state == BootstrapState.openExistingSsss) {
|
|
return _Shell(
|
|
pt: pt,
|
|
title: 'Nachrichten entschlüsseln',
|
|
onClose: () => _close(success: false),
|
|
child: _KeyInputBody(
|
|
pt: pt,
|
|
ctrl: _keyCtrl,
|
|
loading: _keyLoading,
|
|
error: _keyError,
|
|
onUnlock: _unlock,
|
|
onWipe: () {
|
|
setState(() {
|
|
_wipe = true;
|
|
_start();
|
|
});
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
// Success — all done
|
|
if (bs.state == BootstrapState.done) {
|
|
return _Shell(
|
|
pt: pt,
|
|
title: 'Einrichtung abgeschlossen',
|
|
onClose: null,
|
|
child: _DoneBody(
|
|
pt: pt,
|
|
onClose: () => _close(success: true),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Error
|
|
if (bs.state == BootstrapState.error) {
|
|
return _Shell(
|
|
pt: pt,
|
|
title: 'Fehler',
|
|
onClose: () => _close(success: false),
|
|
child: _ErrorBody(
|
|
pt: pt,
|
|
onClose: () => _close(success: false),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Auto-advance all other (non-interactive) states
|
|
_autoAdvance(bs);
|
|
return _Shell(
|
|
pt: pt,
|
|
title: 'Verschlüsselung einrichten…',
|
|
onClose: () => _close(success: false),
|
|
child: const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: CircularProgressIndicator.adaptive(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Sub-widgets
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class _Shell extends StatelessWidget {
|
|
final PyramidTheme pt;
|
|
final String title;
|
|
final VoidCallback? onClose;
|
|
final Widget child;
|
|
|
|
const _Shell({
|
|
required this.pt,
|
|
required this.title,
|
|
required this.onClose,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
backgroundColor: pt.bg1,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(pt.rBase + 4),
|
|
),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(
|
|
maxWidth: 460,
|
|
minWidth: 320,
|
|
minHeight: 120,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Header
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 12, 12),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.shield_outlined, size: 18, color: pt.accent),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: TextStyle(
|
|
color: pt.fg,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
if (onClose != null)
|
|
IconButton(
|
|
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
|
|
onPressed: onClose,
|
|
tooltip: 'Überspringen',
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Divider(color: pt.border, height: 1),
|
|
child,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _KeyInputBody extends StatelessWidget {
|
|
final PyramidTheme pt;
|
|
final TextEditingController ctrl;
|
|
final bool loading;
|
|
final String? error;
|
|
final VoidCallback onUnlock;
|
|
final VoidCallback onWipe;
|
|
|
|
const _KeyInputBody({
|
|
required this.pt,
|
|
required this.ctrl,
|
|
required this.loading,
|
|
required this.error,
|
|
required this.onUnlock,
|
|
required this.onWipe,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Gib deinen Wiederherstellungsschlüssel ein, um deine verschlüsselten Nachrichten lesbar zu machen.',
|
|
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: ctrl,
|
|
readOnly: loading,
|
|
autofocus: true,
|
|
autocorrect: false,
|
|
style: TextStyle(
|
|
color: pt.fg,
|
|
fontSize: 13,
|
|
fontFamily: 'monospace',
|
|
),
|
|
decoration: InputDecoration(
|
|
hintText: 'EsXX XXXX XXXX XXXX XXXX XXXX XXXX XXXX',
|
|
hintStyle: TextStyle(color: pt.fgDim, fontSize: 12),
|
|
labelText: 'Wiederherstellungsschlüssel',
|
|
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
|
errorText: error,
|
|
errorMaxLines: 2,
|
|
filled: true,
|
|
fillColor: pt.bg2,
|
|
prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(color: pt.border),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(color: pt.border),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(color: pt.accent),
|
|
),
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
cursorColor: pt.accent,
|
|
onSubmitted: (_) => onUnlock(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: pt.accent,
|
|
foregroundColor: pt.accentFg,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
onPressed: loading ? null : onUnlock,
|
|
icon: loading
|
|
? SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
color: pt.accentFg,
|
|
strokeWidth: 2,
|
|
),
|
|
)
|
|
: const Icon(Icons.lock_open_outlined, size: 18),
|
|
label: Text(
|
|
loading ? 'Entschlüssele…' : 'Nachrichten entschlüsseln',
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
// Divider + lost key option
|
|
Row(
|
|
children: [
|
|
Expanded(child: Divider(color: pt.border)),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: Text('oder', style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
|
),
|
|
Expanded(child: Divider(color: pt.border)),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: OutlinedButton.icon(
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: PyramidColors.danger,
|
|
side: BorderSide(color: PyramidColors.danger.withAlpha(100)),
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
onPressed: loading ? null : onWipe,
|
|
icon: const Icon(Icons.delete_outlined, size: 16),
|
|
label: const Text('Schlüssel verloren — neu einrichten', style: TextStyle(fontSize: 13)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DoneBody extends StatelessWidget {
|
|
final PyramidTheme pt;
|
|
final VoidCallback onClose;
|
|
|
|
const _DoneBody({required this.pt, required this.onClose});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.check_circle_outline_rounded,
|
|
color: PyramidColors.success,
|
|
size: 56,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Alle Schlüssel wurden erfolgreich importiert.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Deine verschlüsselten Nachrichten werden jetzt im Hintergrund entschlüsselt.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
|
),
|
|
const SizedBox(height: 20),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: pt.accent,
|
|
foregroundColor: pt.accentFg,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
elevation: 0,
|
|
),
|
|
onPressed: onClose,
|
|
child: const Text('Fertig'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ErrorBody extends StatelessWidget {
|
|
final PyramidTheme pt;
|
|
final VoidCallback onClose;
|
|
|
|
const _ErrorBody({required this.pt, required this.onClose});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Etwas ist schiefgelaufen. Versuche es erneut.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
|
),
|
|
const SizedBox(height: 20),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: OutlinedButton(
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: pt.fg,
|
|
side: BorderSide(color: pt.border),
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
),
|
|
onPressed: onClose,
|
|
child: const Text('Schließen'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|