Improve RPC error recovery.

This commit is contained in:
Dain Nilsson 2021-12-06 10:26:38 +01:00
parent 034e2794f8
commit 81c33a0e36
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
4 changed files with 168 additions and 168 deletions

View File

@ -46,13 +46,23 @@ class LogLevelNotifier extends StateNotifier<Level> {
}
}
typedef ErrorHandler = Future<void> Function(RpcError e);
class RpcNodeSession {
final RpcSession _rpc;
final List<String> devicePath;
final List<String> subPath;
final Function _reset;
final Map<String, ErrorHandler> _errorHandlers = {};
RpcNodeSession(this._rpc, this.devicePath, this.subPath, this._reset);
RpcNodeSession(this._rpc, this.devicePath, this.subPath);
void setErrorHandler(String status, ErrorHandler handler) {
_errorHandlers[status] = handler;
}
void unserErrorHandler(String status) {
_errorHandlers.remove(status);
}
Future<Map<String, dynamic>> command(
String action, {
@ -68,8 +78,11 @@ class RpcNodeSession {
signal: signal,
);
} on RpcError catch (e) {
if (e.status == 'state-reset') {
_reset();
final handler = _errorHandlers[e.status];
if (handler != null) {
log.info('Attempting recovery on "${e.status}"');
await handler(e);
return command(action, target: target, params: params, signal: signal);
}
rethrow;
}

View File

@ -14,19 +14,9 @@ import 'models.dart';
final log = Logger('oath.state');
final _sessionProvider =
Provider.autoDispose.family<RpcNodeSession, List<String>>(
(ref, devicePath) {
return RpcNodeSession(
ref.watch(rpcProvider),
devicePath,
['ccid', 'oath'],
() {
ref.refresh(_sessionProvider(devicePath));
},
);
},
);
final _sessionProvider = Provider.autoDispose
.family<RpcNodeSession, List<String>>((ref, devicePath) =>
RpcNodeSession(ref.watch(rpcProvider), devicePath, ['ccid', 'oath']));
// This remembers the key for all devices for the duration of the process.
final _lockKeyProvider =
@ -43,10 +33,23 @@ class _LockKeyNotifier extends StateNotifier<String?> {
final oathStateProvider = StateNotifierProvider.autoDispose
.family<OathStateNotifier, OathState?, List<String>>(
(ref, devicePath) => OathStateNotifier(
ref.watch(_sessionProvider(devicePath)),
ref.read,
)..refresh(),
(ref, devicePath) {
final session = ref.watch(_sessionProvider(devicePath));
final notifier = OathStateNotifier(session, ref.read);
session
..setErrorHandler('state-reset', (_) async {
ref.refresh(_sessionProvider(devicePath));
})
..setErrorHandler('auth-required', (_) async {
await notifier.refresh();
});
ref.onDispose(() {
session
..unserErrorHandler('state-reset')
..unserErrorHandler('auth-required');
});
return notifier..refresh();
},
);
class OathStateNotifier extends StateNotifier<OathState?> {
@ -141,7 +144,8 @@ class CredentialListNotifier extends StateNotifier<List<OathPair>?> {
super.state = value != null ? List.unmodifiable(value) : null;
}
calculate(OathCredential credential) async {
Future<OathCode> calculate(OathCredential credential,
{bool update = true}) async {
OathCode code;
if (credential.isSteam) {
final timeStep = DateTime.now().millisecondsSinceEpoch ~/ 30000;
@ -159,14 +163,16 @@ class CredentialListNotifier extends StateNotifier<List<OathPair>?> {
code = OathCode.fromJson(result);
}
log.config('Calculate', jsonEncode(code));
if (mounted) {
if (update && mounted) {
final creds = state!.toList();
final i = creds.indexWhere((e) => e.credential.id == credential.id);
state = creds..[i] = creds[i].copyWith(code: code);
}
return code;
}
addAccount(Uri otpauth, {bool requireTouch = false}) async {
Future<OathCredential> addAccount(Uri otpauth,
{bool requireTouch = false, bool update = true}) async {
var result = await _session.command('put', target: [
'accounts'
], params: {
@ -174,12 +180,13 @@ class CredentialListNotifier extends StateNotifier<List<OathPair>?> {
'require_touch': requireTouch,
});
final credential = OathCredential.fromJson(result);
if (mounted) {
if (update && mounted) {
state = state!.toList()..add(OathPair(credential, null));
if (!requireTouch && credential.oathType == OathType.totp) {
calculate(credential);
}
}
return credential;
}
refresh() async {
@ -188,25 +195,24 @@ class CredentialListNotifier extends StateNotifier<List<OathPair>?> {
var result = await _session.command('calculate_all', target: ['accounts']);
log.config('Entries', jsonEncode(result));
var current = state?.toList() ?? [];
for (var e in result['entries']) {
final credential = OathCredential.fromJson(e['credential']);
final code = e['code'] == null
? null
: credential.isSteam // Steam codes require a re-calculate
? await calculate(credential, update: false)
: OathCode.fromJson(e['code']);
var i = current
.indexWhere((element) => element.credential.id == credential.id);
if (i < 0) {
current.add(OathPair(credential, code));
} else if (code != null) {
current[i] = current[i].copyWith(code: code);
}
}
if (mounted) {
var current = state?.toList() ?? [];
for (var e in result['entries']) {
final credential = OathCredential.fromJson(e['credential']);
final code = e['code'] == null ? null : OathCode.fromJson(e['code']);
var i = current
.indexWhere((element) => element.credential.id == credential.id);
if (i < 0) {
current.add(OathPair(credential, code));
} else if (code != null) {
current[i] = current[i].copyWith(code: code);
}
}
state = current;
for (var pair in current.where((element) =>
(element.credential.isSteam && !element.credential.touchRequired))) {
await calculate(pair.credential);
}
_scheduleRefresh();
}
}
@ -224,7 +230,6 @@ class CredentialListNotifier extends StateNotifier<List<OathPair>?> {
.map((e) => e.code)
.whereType<OathCode>()
.map((e) => e.validTo);
//.where((time) => time > now);
if (expirations.isEmpty) {
_timer = null;
} else {

View File

@ -25,7 +25,6 @@ class AccountList extends StatelessWidget {
credentials.where((entry) => !favorites.contains(entry.credential.id));
return ListView(
shrinkWrap: true,
children: [
if (favCreds.isNotEmpty)
ListTile(

View File

@ -9,65 +9,40 @@ import '../../app/models.dart';
import '../models.dart';
import '../state.dart';
class AccountView extends ConsumerStatefulWidget {
final _expireProvider =
StateNotifierProvider.autoDispose.family<_ExpireNotifier, bool, int>(
(ref, expiry) =>
_ExpireNotifier(DateTime.now().millisecondsSinceEpoch, expiry * 1000),
);
class _ExpireNotifier extends StateNotifier<bool> {
Timer? _timer;
_ExpireNotifier(int now, int expiry) : super(expiry <= now) {
if (expiry > now) {
_timer = Timer(Duration(milliseconds: expiry - now), () {
if (mounted) {
state = true;
}
});
}
}
@override
dispose() {
_timer?.cancel();
super.dispose();
}
}
class AccountView extends ConsumerWidget {
final DeviceNode device;
final OathCredential credential;
final OathCode? code;
const AccountView(this.device, this.credential, this.code, {Key? key})
: super(key: key);
@override
ConsumerState<ConsumerStatefulWidget> createState() => _AccountViewState();
}
class _AccountViewState extends ConsumerState<AccountView> {
Timer? _expirationTimer;
late bool _expired;
void _scheduleExpiration() {
final expires = (widget.code?.validTo ?? 0) * 1000;
final now = DateTime.now().millisecondsSinceEpoch;
if (expires > now) {
_expired = false;
_expirationTimer?.cancel();
_expirationTimer = Timer(Duration(milliseconds: expires - now), () {
setState(() {
_expired = true;
});
});
} else {
_expired = true;
}
}
@override
void didUpdateWidget(AccountView oldWidget) {
super.didUpdateWidget(oldWidget);
_scheduleExpiration();
}
@override
void initState() {
super.initState();
_scheduleExpiration();
}
@override
void dispose() {
_expirationTimer?.cancel();
super.dispose();
}
String get _avatarLetter {
var name = widget.credential.issuer ?? widget.credential.name;
return name.substring(0, 1).toUpperCase();
}
String get _label =>
'${widget.credential.issuer} (${widget.credential.name})';
String get _code {
var value = widget.code?.value;
String formatCode() {
var value = code?.value;
if (value == null) {
return '••• •••';
} else if (value.length < 6) {
@ -78,18 +53,15 @@ class _AccountViewState extends ConsumerState<AccountView> {
}
}
Color get _color =>
Colors.primaries.elementAt(_label.hashCode % Colors.primaries.length);
List<PopupMenuEntry> _buildPopupMenu(BuildContext context, WidgetRef ref) => [
PopupMenuItem(
child: const ListTile(
leading: Icon(Icons.copy),
title: Text('Copy to clipboard'),
),
enabled: widget.code != null,
enabled: code != null,
onTap: () {
Clipboard.setData(ClipboardData(text: widget.code!.value));
Clipboard.setData(ClipboardData(text: code!.value));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Code copied'),
@ -104,13 +76,10 @@ class _AccountViewState extends ConsumerState<AccountView> {
title: Text('Toggle favorite'),
),
onTap: () {
ref
.read(favoritesProvider.notifier)
.toggleFavorite(widget.credential.id);
ref.read(favoritesProvider.notifier).toggleFavorite(credential.id);
},
),
if (widget.device.info.version.major >= 5 &&
widget.device.info.version.minor >= 3)
if (device.info.version.major >= 5 && device.info.version.minor >= 3)
PopupMenuItem(
child: const ListTile(
leading: Icon(Icons.edit),
@ -130,69 +99,83 @@ class _AccountViewState extends ConsumerState<AccountView> {
];
@override
Widget build(BuildContext context) {
final code = widget.code;
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(children: [
CircleAvatar(
backgroundColor: _color,
child: Text(_avatarLetter, style: const TextStyle(fontSize: 18)),
Widget build(BuildContext context, WidgetRef ref) {
final code = this.code;
final expired = ref.watch(_expireProvider(code?.validTo ?? 0));
final label = credential.issuer != null
? '${credential.issuer} (${credential.name})'
: credential.name;
return ListTile(
onTap: () {},
leading: CircleAvatar(
backgroundColor: Colors.primaries
.elementAt(label.hashCode % Colors.primaries.length),
child: Text(
(credential.issuer ?? credential.name).characters.first.toUpperCase(),
style: const TextStyle(fontSize: 18),
),
const SizedBox(width: 8.0),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_code,
style: _expired
? Theme.of(context)
.textTheme
.headline6
?.copyWith(color: Colors.grey)
: Theme.of(context).textTheme.headline6),
Text(_label, style: Theme.of(context).textTheme.caption),
],
),
const Spacer(),
Row(
children: [
if (code == null ||
_expired &&
(widget.credential.touchRequired ||
widget.credential.oathType == OathType.hotp))
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
if (widget.credential.touchRequired) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Touch your YubiKey'),
duration: Duration(seconds: 2),
),
);
}
ref
.read(credentialListProvider(widget.device.path).notifier)
.calculate(widget.credential);
},
),
title: Text(
formatCode(),
style: expired
? Theme.of(context)
.textTheme
.headline5
?.copyWith(color: Colors.grey)
: Theme.of(context).textTheme.headline5,
),
subtitle: Text(label, style: Theme.of(context).textTheme.caption),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (code == null ||
expired &&
(credential.touchRequired ||
credential.oathType == OathType.hotp))
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () async {
VoidCallback? close;
if (credential.touchRequired) {
final sbc = ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Touch your YubiKey'),
duration: Duration(seconds: 30),
),
)..closed.then((_) {
close = null;
});
close = sbc.close;
}
await ref
.read(credentialListProvider(device.path).notifier)
.calculate(credential);
close?.call();
},
),
Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
if (code != null && code.validTo - code.validFrom < 600)
Align(
alignment: AlignmentDirectional.topCenter,
child: SizedBox.square(
dimension: 16,
child:
CircleTimer(code.validFrom * 1000, code.validTo * 1000),
),
),
Transform.scale(
scale: 0.8,
child: PopupMenuButton(
itemBuilder: (context) => _buildPopupMenu(context, ref),
),
)
],
),
Column(
children: [
SizedBox.square(
dimension: 16,
child: code != null && code.validTo - code.validFrom < 600
? CircleTimer(code.validFrom * 1000, code.validTo * 1000)
: null,
),
PopupMenuButton(
iconSize: 20.0,
itemBuilder: (context) => _buildPopupMenu(context, ref),
),
],
),
]),
],
),
],
),
);
}
}