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:
Bernd Steckmeister
2026-07-03 05:47:18 +02:00
parent d706ace35f
commit 25ed765a03
195 changed files with 47784 additions and 1236 deletions
+534
View File
@@ -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'),
),
),
],
),
);
}
}