wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
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
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user