diff --git a/lib/about_page.dart b/lib/about_page.dart index 0af299c0..d472c3b4 100755 --- a/lib/about_page.dart +++ b/lib/about_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'core/state.dart'; +import 'desktop/state.dart'; final log = Logger('about'); @@ -13,7 +14,6 @@ class AboutPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final rpcState = ref.watch(rpcStateProvider); return Scaffold( appBar: AppBar( title: const Text('About Yubico Authenticator'), @@ -25,54 +25,42 @@ class AboutPage extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('ykman version: ${rpcState.version}'), + if (isDesktop) + Text('ykman version: ${ref.watch(rpcStateProvider).version}'), Text('Dart version: ${Platform.version}'), const SizedBox(height: 8.0), Text('Log level: ${ref.watch(logLevelProvider)}'), Row( mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () { - ref.read(logLevelProvider.notifier).setLevel(Level.INFO); - log.info('Log level changed to INFO'); - }, - child: const Text('Info'), - ), - TextButton( - onPressed: () { - ref - .read(logLevelProvider.notifier) - .setLevel(Level.CONFIG); - log.config('Log level changed to CONFIG'); - }, - child: const Text('Config'), - ), - TextButton( - onPressed: () { - ref.read(logLevelProvider.notifier).setLevel(Level.FINE); - log.fine('Log level changed to FINE'); - }, - child: const Text('Fine'), - ), - ], + children: [Level.INFO, Level.CONFIG, Level.FINE] + .map((level) => TextButton( + onPressed: () { + ref.read(logLevelProvider.notifier).state = level; + log.info( + 'Log level changed to ${level.name.toUpperCase()}'); + }, + child: Text(level.name.toUpperCase()), + )) + .toList(), ), const Divider(), - TextButton( - onPressed: () async { - log.info('Running diagnostics...'); - final response = - await ref.read(rpcProvider).command('diagnose', []); - log.info('Response', response['diagnostics']); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Diagnostics done. See log for results...'), - duration: Duration(seconds: 2), - ), - ); - }, - child: const Text('Run diagnostics...'), - ), + if (isDesktop) + TextButton( + onPressed: () async { + log.info('Running diagnostics...'); + final response = + await ref.read(rpcProvider).command('diagnose', []); + log.info('Response', response['diagnostics']); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Diagnostics done. See log for results...'), + duration: Duration(seconds: 2), + ), + ); + }, + child: const Text('Run diagnostics...'), + ), ], ), ), diff --git a/lib/app/state.dart b/lib/app/state.dart index 54cc8e57..9610f7f1 100755 --- a/lib/app/state.dart +++ b/lib/app/state.dart @@ -1,93 +1,18 @@ -import 'dart:async'; -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:window_manager/window_manager.dart'; -import 'package:yubico_authenticator/management/models.dart'; -import '../core/models.dart'; import '../core/state.dart'; -import '../core/rpc.dart'; import '../oath/menu_actions.dart'; import 'models.dart'; -const _usbPollDelay = Duration(milliseconds: 500); - -const _nfcPollDelay = Duration(milliseconds: 2500); -const _nfcAttachPollDelay = Duration(seconds: 1); -const _nfcDetachPollDelay = Duration(seconds: 5); - final log = Logger('app.state'); -final windowStateProvider = - StateNotifierProvider( - (ref) => WindowStateNotifier()); - -class WindowStateNotifier extends StateNotifier - with WindowListener { - Timer? _idleTimer; - WindowStateNotifier() - : super(WindowState(focused: true, visible: true, active: true)) { - _init(); - } - - void _init() async { - windowManager.addListener(this); - if (!await windowManager.isVisible() && mounted) { - state = WindowState(focused: false, visible: false, active: true); - _idleTimer = Timer(const Duration(seconds: 5), () { - if (mounted) { - state = state.copyWith(active: false); - } - }); - } - } - - @override - void dispose() { - windowManager.removeListener(this); - super.dispose(); - } - - @override - set state(WindowState value) { - log.config('Window state changed: $value'); - super.state = value; - } - - @override - void onWindowEvent(String eventName) { - if (mounted) { - switch (eventName) { - case 'blur': - state = state.copyWith(focused: false); - _idleTimer?.cancel(); - _idleTimer = Timer(const Duration(seconds: 5), () { - if (mounted) { - state = state.copyWith(active: false); - } - }); - break; - case 'focus': - state = state.copyWith(focused: true, active: true); - _idleTimer?.cancel(); - break; - case 'minimize': - state = state.copyWith(visible: false, active: false); - _idleTimer?.cancel(); - break; - case 'restore': - state = state.copyWith(visible: true, active: true); - break; - default: - log.fine('Window event ignored: $eventName'); - } - } - } -} +// Default implementation is always focused, override with platform specific version. +final windowStateProvider = Provider( + (ref) => WindowState(focused: true, visible: true, active: true), +); final themeModeProvider = StateNotifierProvider( (ref) => ThemeModeNotifier(ref.watch(prefProvider))); @@ -125,140 +50,15 @@ class SearchNotifier extends StateNotifier { } } -final _usbDevicesProvider = - StateNotifierProvider>((ref) { - final notifier = UsbDeviceNotifier(ref.watch(rpcProvider)); - ref.listen(windowStateProvider, (_, windowState) { - notifier._notifyWindowState(windowState); - }, fireImmediately: true); - return notifier; -}); +// Override with platform implementation +final attachedDevicesProvider = Provider>( + (ref) => [], +); -class UsbDeviceNotifier extends StateNotifier> { - final RpcSession _rpc; - Timer? _pollTimer; - int _usbState = -1; - UsbDeviceNotifier(this._rpc) : super([]); - - void _notifyWindowState(WindowState windowState) { - if (windowState.active) { - _pollDevices(); - } else { - _pollTimer?.cancel(); - // Release any held device - _rpc.command('get', ['usb']); - } - } - - @override - void dispose() { - _pollTimer?.cancel(); - super.dispose(); - } - - void _pollDevices() async { - _pollTimer?.cancel(); - - try { - var scan = await _rpc.command('scan', ['usb']); - - if (_usbState != scan['state'] || state.length != scan['pids'].length) { - var usbResult = await _rpc.command('get', ['usb']); - log.info('USB state change', jsonEncode(usbResult)); - _usbState = usbResult['data']['state']; - List usbDevices = []; - - for (String id in (usbResult['children'] as Map).keys) { - var path = ['usb', id]; - var deviceResult = await _rpc.command('get', path); - var deviceData = deviceResult['data']; - usbDevices.add(DeviceNode.usbYubiKey( - path, - deviceData['name'], - deviceData['pid'], - DeviceInfo.fromJson(deviceData['info']), - ) as UsbYubiKeyNode); - } - - log.info('USB state updated'); - if (mounted) { - state = usbDevices; - } - } - } on RpcError catch (e) { - log.severe('Error polling USB', jsonEncode(e)); - } - - if (mounted) { - _pollTimer = Timer(_usbPollDelay, _pollDevices); - } - } -} - -final _nfcDevicesProvider = - StateNotifierProvider>((ref) { - final notifier = NfcDeviceNotifier(ref.watch(rpcProvider)); - ref.listen(windowStateProvider, (_, windowState) { - notifier._notifyWindowState(windowState); - }, fireImmediately: true); - return notifier; -}); - -class NfcDeviceNotifier extends StateNotifier> { - final RpcSession _rpc; - Timer? _pollTimer; - String _nfcState = ''; - NfcDeviceNotifier(this._rpc) : super([]); - - void _notifyWindowState(WindowState windowState) { - if (windowState.active) { - _pollReaders(); - } else { - _pollTimer?.cancel(); - // Release any held device - _rpc.command('get', ['nfc']); - } - } - - @override - void dispose() { - _pollTimer?.cancel(); - super.dispose(); - } - - void _pollReaders() async { - _pollTimer?.cancel(); - - try { - var children = await _rpc.command('scan', ['nfc']); - var newState = children.keys.join(':'); - - if (mounted && newState != _nfcState) { - log.info('NFC state change', jsonEncode(children)); - _nfcState = newState; - state = children.entries - .map((e) => - DeviceNode.nfcReader(['nfc', e.key], e.value['name'] as String) - as NfcReaderNode) - .toList(); - } - } on RpcError catch (e) { - log.severe('Error polling NFC', jsonEncode(e)); - } - - if (mounted) { - _pollTimer = Timer(_nfcPollDelay, _pollReaders); - } - } -} - -final attachedDevicesProvider = Provider>((ref) { - final usbDevices = ref.watch(_usbDevicesProvider).toList(); - final nfcDevices = ref.watch(_nfcDevicesProvider).toList(); - usbDevices.sort((a, b) => a.name.compareTo(b.name)); - nfcDevices.sort((a, b) => a.name.compareTo(b.name)); - return [...usbDevices, ...nfcDevices]; -}); +// Override with platform implementation +final currentDeviceDataProvider = Provider( + (ref) => null, +); final currentDeviceProvider = StateNotifierProvider((ref) { @@ -305,75 +105,6 @@ class CurrentDeviceNotifier extends StateNotifier { } } -final currentDeviceDataProvider = - StateNotifierProvider((ref) { - final notifier = CurrentDeviceDataNotifier( - ref.watch(rpcProvider), - ref.watch(currentDeviceProvider), - ); - if (notifier._deviceNode is NfcReaderNode) { - // If this is an NFC reader, listen on WindowState. - ref.listen(windowStateProvider, (_, windowState) { - notifier._notifyWindowState(windowState); - }, fireImmediately: true); - } - return notifier; -}); - -class CurrentDeviceDataNotifier extends StateNotifier { - final RpcSession _rpc; - final DeviceNode? _deviceNode; - Timer? _pollTimer; - - CurrentDeviceDataNotifier(this._rpc, this._deviceNode) : super(null) { - final dev = _deviceNode; - if (dev is UsbYubiKeyNode) { - state = YubiKeyData(dev, dev.name, dev.info); - } - } - - void _notifyWindowState(WindowState windowState) { - if (windowState.active) { - _pollReader(); - } else { - _pollTimer?.cancel(); - // TODO: Should we clear the key here? - /*if (mounted) { - state = null; - }*/ - } - } - - @override - void dispose() { - _pollTimer?.cancel(); - super.dispose(); - } - - void _pollReader() async { - _pollTimer?.cancel(); - final node = _deviceNode!; - try { - var result = await _rpc.command('get', node.path); - if (mounted) { - if (result['data']['present']) { - state = YubiKeyData(node, result['data']['name'], - DeviceInfo.fromJson(result['data']['info'])); - } else { - state = null; - } - } - } on RpcError catch (e) { - log.severe('Error polling NFC', jsonEncode(e)); - } - if (mounted) { - _pollTimer = Timer( - state == null ? _nfcAttachPollDelay : _nfcDetachPollDelay, - _pollReader); - } - } -} - final subPageProvider = StateNotifierProvider( (ref) => SubPageNotifier(SubPage.authenticator)); diff --git a/lib/core/models.dart b/lib/core/models.dart index 5d168472..0848d5f0 100644 --- a/lib/core/models.dart +++ b/lib/core/models.dart @@ -1,7 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'models.freezed.dart'; -part 'models.g.dart'; @freezed class Version with _$Version { @@ -19,19 +18,3 @@ class Version with _$Version { return '$major.$minor.$patch'; } } - -@Freezed(unionKey: 'kind') -class RpcResponse with _$RpcResponse { - factory RpcResponse.success(Map body) = Success; - factory RpcResponse.signal(String status, Map body) = Signal; - factory RpcResponse.error( - String status, String message, Map body) = RpcError; - - factory RpcResponse.fromJson(Map json) => - _$RpcResponseFromJson(json); -} - -@freezed -class RpcState with _$RpcState { - const factory RpcState(String version) = _RpcState; -} diff --git a/lib/core/models.freezed.dart b/lib/core/models.freezed.dart index 1258e4f5..35563eb5 100755 --- a/lib/core/models.freezed.dart +++ b/lib/core/models.freezed.dart @@ -168,764 +168,3 @@ abstract class _Version extends Version { _$VersionCopyWith<_Version> get copyWith => throw _privateConstructorUsedError; } - -RpcResponse _$RpcResponseFromJson(Map json) { - switch (json['kind']) { - case 'success': - return Success.fromJson(json); - case 'signal': - return Signal.fromJson(json); - case 'error': - return RpcError.fromJson(json); - - default: - throw CheckedFromJsonException( - json, 'kind', 'RpcResponse', 'Invalid union type "${json['kind']}"!'); - } -} - -/// @nodoc -class _$RpcResponseTearOff { - const _$RpcResponseTearOff(); - - Success success(Map body) { - return Success( - body, - ); - } - - Signal signal(String status, Map body) { - return Signal( - status, - body, - ); - } - - RpcError error(String status, String message, Map body) { - return RpcError( - status, - message, - body, - ); - } - - RpcResponse fromJson(Map json) { - return RpcResponse.fromJson(json); - } -} - -/// @nodoc -const $RpcResponse = _$RpcResponseTearOff(); - -/// @nodoc -mixin _$RpcResponse { - Map get body => throw _privateConstructorUsedError; - - @optionalTypeArgs - TResult when({ - required TResult Function(Map body) success, - required TResult Function(String status, Map body) signal, - required TResult Function( - String status, String message, Map body) - error, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? whenOrNull({ - TResult Function(Map body)? success, - TResult Function(String status, Map body)? signal, - TResult Function(String status, String message, Map body)? - error, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(Map body)? success, - TResult Function(String status, Map body)? signal, - TResult Function(String status, String message, Map body)? - error, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult map({ - required TResult Function(Success value) success, - required TResult Function(Signal value) signal, - required TResult Function(RpcError value) error, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? mapOrNull({ - TResult Function(Success value)? success, - TResult Function(Signal value)? signal, - TResult Function(RpcError value)? error, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeMap({ - TResult Function(Success value)? success, - TResult Function(Signal value)? signal, - TResult Function(RpcError value)? error, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $RpcResponseCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $RpcResponseCopyWith<$Res> { - factory $RpcResponseCopyWith( - RpcResponse value, $Res Function(RpcResponse) then) = - _$RpcResponseCopyWithImpl<$Res>; - $Res call({Map body}); -} - -/// @nodoc -class _$RpcResponseCopyWithImpl<$Res> implements $RpcResponseCopyWith<$Res> { - _$RpcResponseCopyWithImpl(this._value, this._then); - - final RpcResponse _value; - // ignore: unused_field - final $Res Function(RpcResponse) _then; - - @override - $Res call({ - Object? body = freezed, - }) { - return _then(_value.copyWith( - body: body == freezed - ? _value.body - : body // ignore: cast_nullable_to_non_nullable - as Map, - )); - } -} - -/// @nodoc -abstract class $SuccessCopyWith<$Res> implements $RpcResponseCopyWith<$Res> { - factory $SuccessCopyWith(Success value, $Res Function(Success) then) = - _$SuccessCopyWithImpl<$Res>; - @override - $Res call({Map body}); -} - -/// @nodoc -class _$SuccessCopyWithImpl<$Res> extends _$RpcResponseCopyWithImpl<$Res> - implements $SuccessCopyWith<$Res> { - _$SuccessCopyWithImpl(Success _value, $Res Function(Success) _then) - : super(_value, (v) => _then(v as Success)); - - @override - Success get _value => super._value as Success; - - @override - $Res call({ - Object? body = freezed, - }) { - return _then(Success( - body == freezed - ? _value.body - : body // ignore: cast_nullable_to_non_nullable - as Map, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$Success implements Success { - _$Success(this.body, {String? $type}) : $type = $type ?? 'success'; - - factory _$Success.fromJson(Map json) => - _$$SuccessFromJson(json); - - @override - final Map body; - - @JsonKey(name: 'kind') - final String $type; - - @override - String toString() { - return 'RpcResponse.success(body: $body)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is Success && - const DeepCollectionEquality().equals(other.body, body)); - } - - @override - int get hashCode => - Object.hash(runtimeType, const DeepCollectionEquality().hash(body)); - - @JsonKey(ignore: true) - @override - $SuccessCopyWith get copyWith => - _$SuccessCopyWithImpl(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function(Map body) success, - required TResult Function(String status, Map body) signal, - required TResult Function( - String status, String message, Map body) - error, - }) { - return success(body); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult Function(Map body)? success, - TResult Function(String status, Map body)? signal, - TResult Function(String status, String message, Map body)? - error, - }) { - return success?.call(body); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(Map body)? success, - TResult Function(String status, Map body)? signal, - TResult Function(String status, String message, Map body)? - error, - required TResult orElse(), - }) { - if (success != null) { - return success(body); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(Success value) success, - required TResult Function(Signal value) signal, - required TResult Function(RpcError value) error, - }) { - return success(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult Function(Success value)? success, - TResult Function(Signal value)? signal, - TResult Function(RpcError value)? error, - }) { - return success?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(Success value)? success, - TResult Function(Signal value)? signal, - TResult Function(RpcError value)? error, - required TResult orElse(), - }) { - if (success != null) { - return success(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$SuccessToJson(this); - } -} - -abstract class Success implements RpcResponse { - factory Success(Map body) = _$Success; - - factory Success.fromJson(Map json) = _$Success.fromJson; - - @override - Map get body; - @override - @JsonKey(ignore: true) - $SuccessCopyWith get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SignalCopyWith<$Res> implements $RpcResponseCopyWith<$Res> { - factory $SignalCopyWith(Signal value, $Res Function(Signal) then) = - _$SignalCopyWithImpl<$Res>; - @override - $Res call({String status, Map body}); -} - -/// @nodoc -class _$SignalCopyWithImpl<$Res> extends _$RpcResponseCopyWithImpl<$Res> - implements $SignalCopyWith<$Res> { - _$SignalCopyWithImpl(Signal _value, $Res Function(Signal) _then) - : super(_value, (v) => _then(v as Signal)); - - @override - Signal get _value => super._value as Signal; - - @override - $Res call({ - Object? status = freezed, - Object? body = freezed, - }) { - return _then(Signal( - status == freezed - ? _value.status - : status // ignore: cast_nullable_to_non_nullable - as String, - body == freezed - ? _value.body - : body // ignore: cast_nullable_to_non_nullable - as Map, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$Signal implements Signal { - _$Signal(this.status, this.body, {String? $type}) : $type = $type ?? 'signal'; - - factory _$Signal.fromJson(Map json) => - _$$SignalFromJson(json); - - @override - final String status; - @override - final Map body; - - @JsonKey(name: 'kind') - final String $type; - - @override - String toString() { - return 'RpcResponse.signal(status: $status, body: $body)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is Signal && - const DeepCollectionEquality().equals(other.status, status) && - const DeepCollectionEquality().equals(other.body, body)); - } - - @override - int get hashCode => Object.hash( - runtimeType, - const DeepCollectionEquality().hash(status), - const DeepCollectionEquality().hash(body)); - - @JsonKey(ignore: true) - @override - $SignalCopyWith get copyWith => - _$SignalCopyWithImpl(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function(Map body) success, - required TResult Function(String status, Map body) signal, - required TResult Function( - String status, String message, Map body) - error, - }) { - return signal(status, body); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult Function(Map body)? success, - TResult Function(String status, Map body)? signal, - TResult Function(String status, String message, Map body)? - error, - }) { - return signal?.call(status, body); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(Map body)? success, - TResult Function(String status, Map body)? signal, - TResult Function(String status, String message, Map body)? - error, - required TResult orElse(), - }) { - if (signal != null) { - return signal(status, body); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(Success value) success, - required TResult Function(Signal value) signal, - required TResult Function(RpcError value) error, - }) { - return signal(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult Function(Success value)? success, - TResult Function(Signal value)? signal, - TResult Function(RpcError value)? error, - }) { - return signal?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(Success value)? success, - TResult Function(Signal value)? signal, - TResult Function(RpcError value)? error, - required TResult orElse(), - }) { - if (signal != null) { - return signal(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$SignalToJson(this); - } -} - -abstract class Signal implements RpcResponse { - factory Signal(String status, Map body) = _$Signal; - - factory Signal.fromJson(Map json) = _$Signal.fromJson; - - String get status; - @override - Map get body; - @override - @JsonKey(ignore: true) - $SignalCopyWith get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $RpcErrorCopyWith<$Res> implements $RpcResponseCopyWith<$Res> { - factory $RpcErrorCopyWith(RpcError value, $Res Function(RpcError) then) = - _$RpcErrorCopyWithImpl<$Res>; - @override - $Res call({String status, String message, Map body}); -} - -/// @nodoc -class _$RpcErrorCopyWithImpl<$Res> extends _$RpcResponseCopyWithImpl<$Res> - implements $RpcErrorCopyWith<$Res> { - _$RpcErrorCopyWithImpl(RpcError _value, $Res Function(RpcError) _then) - : super(_value, (v) => _then(v as RpcError)); - - @override - RpcError get _value => super._value as RpcError; - - @override - $Res call({ - Object? status = freezed, - Object? message = freezed, - Object? body = freezed, - }) { - return _then(RpcError( - status == freezed - ? _value.status - : status // ignore: cast_nullable_to_non_nullable - as String, - message == freezed - ? _value.message - : message // ignore: cast_nullable_to_non_nullable - as String, - body == freezed - ? _value.body - : body // ignore: cast_nullable_to_non_nullable - as Map, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$RpcError implements RpcError { - _$RpcError(this.status, this.message, this.body, {String? $type}) - : $type = $type ?? 'error'; - - factory _$RpcError.fromJson(Map json) => - _$$RpcErrorFromJson(json); - - @override - final String status; - @override - final String message; - @override - final Map body; - - @JsonKey(name: 'kind') - final String $type; - - @override - String toString() { - return 'RpcResponse.error(status: $status, message: $message, body: $body)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is RpcError && - const DeepCollectionEquality().equals(other.status, status) && - const DeepCollectionEquality().equals(other.message, message) && - const DeepCollectionEquality().equals(other.body, body)); - } - - @override - int get hashCode => Object.hash( - runtimeType, - const DeepCollectionEquality().hash(status), - const DeepCollectionEquality().hash(message), - const DeepCollectionEquality().hash(body)); - - @JsonKey(ignore: true) - @override - $RpcErrorCopyWith get copyWith => - _$RpcErrorCopyWithImpl(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function(Map body) success, - required TResult Function(String status, Map body) signal, - required TResult Function( - String status, String message, Map body) - error, - }) { - return error(status, message, body); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult Function(Map body)? success, - TResult Function(String status, Map body)? signal, - TResult Function(String status, String message, Map body)? - error, - }) { - return error?.call(status, message, body); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(Map body)? success, - TResult Function(String status, Map body)? signal, - TResult Function(String status, String message, Map body)? - error, - required TResult orElse(), - }) { - if (error != null) { - return error(status, message, body); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(Success value) success, - required TResult Function(Signal value) signal, - required TResult Function(RpcError value) error, - }) { - return error(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult Function(Success value)? success, - TResult Function(Signal value)? signal, - TResult Function(RpcError value)? error, - }) { - return error?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(Success value)? success, - TResult Function(Signal value)? signal, - TResult Function(RpcError value)? error, - required TResult orElse(), - }) { - if (error != null) { - return error(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$RpcErrorToJson(this); - } -} - -abstract class RpcError implements RpcResponse { - factory RpcError(String status, String message, Map body) = - _$RpcError; - - factory RpcError.fromJson(Map json) = _$RpcError.fromJson; - - String get status; - String get message; - @override - Map get body; - @override - @JsonKey(ignore: true) - $RpcErrorCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -class _$RpcStateTearOff { - const _$RpcStateTearOff(); - - _RpcState call(String version) { - return _RpcState( - version, - ); - } -} - -/// @nodoc -const $RpcState = _$RpcStateTearOff(); - -/// @nodoc -mixin _$RpcState { - String get version => throw _privateConstructorUsedError; - - @JsonKey(ignore: true) - $RpcStateCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $RpcStateCopyWith<$Res> { - factory $RpcStateCopyWith(RpcState value, $Res Function(RpcState) then) = - _$RpcStateCopyWithImpl<$Res>; - $Res call({String version}); -} - -/// @nodoc -class _$RpcStateCopyWithImpl<$Res> implements $RpcStateCopyWith<$Res> { - _$RpcStateCopyWithImpl(this._value, this._then); - - final RpcState _value; - // ignore: unused_field - final $Res Function(RpcState) _then; - - @override - $Res call({ - Object? version = freezed, - }) { - return _then(_value.copyWith( - version: version == freezed - ? _value.version - : version // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc -abstract class _$RpcStateCopyWith<$Res> implements $RpcStateCopyWith<$Res> { - factory _$RpcStateCopyWith(_RpcState value, $Res Function(_RpcState) then) = - __$RpcStateCopyWithImpl<$Res>; - @override - $Res call({String version}); -} - -/// @nodoc -class __$RpcStateCopyWithImpl<$Res> extends _$RpcStateCopyWithImpl<$Res> - implements _$RpcStateCopyWith<$Res> { - __$RpcStateCopyWithImpl(_RpcState _value, $Res Function(_RpcState) _then) - : super(_value, (v) => _then(v as _RpcState)); - - @override - _RpcState get _value => super._value as _RpcState; - - @override - $Res call({ - Object? version = freezed, - }) { - return _then(_RpcState( - version == freezed - ? _value.version - : version // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc - -class _$_RpcState implements _RpcState { - const _$_RpcState(this.version); - - @override - final String version; - - @override - String toString() { - return 'RpcState(version: $version)'; - } - - @override - bool operator ==(dynamic other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _RpcState && - const DeepCollectionEquality().equals(other.version, version)); - } - - @override - int get hashCode => - Object.hash(runtimeType, const DeepCollectionEquality().hash(version)); - - @JsonKey(ignore: true) - @override - _$RpcStateCopyWith<_RpcState> get copyWith => - __$RpcStateCopyWithImpl<_RpcState>(this, _$identity); -} - -abstract class _RpcState implements RpcState { - const factory _RpcState(String version) = _$_RpcState; - - @override - String get version; - @override - @JsonKey(ignore: true) - _$RpcStateCopyWith<_RpcState> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/core/state.dart b/lib/core/state.dart index d5a39b1b..1263b0fd 100644 --- a/lib/core/state.dart +++ b/lib/core/state.dart @@ -1,90 +1,14 @@ +import 'dart:io'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'models.dart'; -import 'rpc.dart'; - // This must be initialized before use, in main.dart. final prefProvider = Provider((ref) { throw UnimplementedError(); }); -// This must be initialized before use, in main.dart. -final rpcProvider = Provider((ref) { - throw UnimplementedError(); -}); +final logLevelProvider = StateProvider((ref) => Logger.root.level); -final rpcStateProvider = StateNotifierProvider( - (ref) => RpcStateNotifier(ref.watch(rpcProvider))); - -class RpcStateNotifier extends StateNotifier { - final RpcSession rpc; - RpcStateNotifier(this.rpc) : super(const RpcState('unknown')) { - _init(); - } - - _init() async { - final response = await rpc.command('get', []); - if (mounted) { - state = state.copyWith(version: response['data']['version']); - } - } -} - -final logLevelProvider = StateNotifierProvider( - (ref) => LogLevelNotifier(ref.watch(rpcProvider), Logger.root.level)); - -class LogLevelNotifier extends StateNotifier { - final RpcSession rpc; - LogLevelNotifier(this.rpc, Level state) : super(state); - - setLevel(Level level) { - Logger.root.level = level; - rpc.setLogLevel(level); - state = level; - } -} - -typedef ErrorHandler = Future Function(RpcError e); - -class RpcNodeSession { - final RpcSession _rpc; - final List devicePath; - final List subPath; - final Map _errorHandlers = {}; - - RpcNodeSession(this._rpc, this.devicePath, this.subPath); - - void setErrorHandler(String status, ErrorHandler handler) { - _errorHandlers[status] = handler; - } - - void unserErrorHandler(String status) { - _errorHandlers.remove(status); - } - - Future> command( - String action, { - List target = const [], - Map? params, - Signaler? signal, - }) async { - try { - return await _rpc.command( - action, - devicePath + subPath + target, - params: params, - signal: signal, - ); - } on RpcError catch (e) { - 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; - } - } -} +final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; diff --git a/lib/desktop/devices.dart b/lib/desktop/devices.dart new file mode 100755 index 00000000..96a7ef3a --- /dev/null +++ b/lib/desktop/devices.dart @@ -0,0 +1,228 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; + +import '../app/models.dart'; +import '../app/state.dart'; +import '../management/models.dart'; +import 'models.dart'; +import 'rpc.dart'; +import 'state.dart'; + +const _usbPollDelay = Duration(milliseconds: 500); + +const _nfcPollDelay = Duration(milliseconds: 2500); +const _nfcAttachPollDelay = Duration(seconds: 1); +const _nfcDetachPollDelay = Duration(seconds: 5); + +final log = Logger('desktop.devices'); + +final _usbDevicesProvider = + StateNotifierProvider>((ref) { + final notifier = UsbDeviceNotifier(ref.watch(rpcProvider)); + ref.listen(windowStateProvider, (_, windowState) { + notifier._notifyWindowState(windowState); + }, fireImmediately: true); + return notifier; +}); + +class UsbDeviceNotifier extends StateNotifier> { + final RpcSession _rpc; + Timer? _pollTimer; + int _usbState = -1; + UsbDeviceNotifier(this._rpc) : super([]); + + void _notifyWindowState(WindowState windowState) { + if (windowState.active) { + _pollDevices(); + } else { + _pollTimer?.cancel(); + // Release any held device + _rpc.command('get', ['usb']); + } + } + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } + + void _pollDevices() async { + _pollTimer?.cancel(); + + try { + var scan = await _rpc.command('scan', ['usb']); + + if (_usbState != scan['state'] || state.length != scan['pids'].length) { + var usbResult = await _rpc.command('get', ['usb']); + log.info('USB state change', jsonEncode(usbResult)); + _usbState = usbResult['data']['state']; + List usbDevices = []; + + for (String id in (usbResult['children'] as Map).keys) { + var path = ['usb', id]; + var deviceResult = await _rpc.command('get', path); + var deviceData = deviceResult['data']; + usbDevices.add(DeviceNode.usbYubiKey( + path, + deviceData['name'], + deviceData['pid'], + DeviceInfo.fromJson(deviceData['info']), + ) as UsbYubiKeyNode); + } + + log.info('USB state updated'); + if (mounted) { + state = usbDevices; + } + } + } on RpcError catch (e) { + log.severe('Error polling USB', jsonEncode(e)); + } + + if (mounted) { + _pollTimer = Timer(_usbPollDelay, _pollDevices); + } + } +} + +final _nfcDevicesProvider = + StateNotifierProvider>((ref) { + final notifier = NfcDeviceNotifier(ref.watch(rpcProvider)); + ref.listen(windowStateProvider, (_, windowState) { + notifier._notifyWindowState(windowState); + }, fireImmediately: true); + return notifier; +}); + +class NfcDeviceNotifier extends StateNotifier> { + final RpcSession _rpc; + Timer? _pollTimer; + String _nfcState = ''; + NfcDeviceNotifier(this._rpc) : super([]); + + void _notifyWindowState(WindowState windowState) { + if (windowState.active) { + _pollReaders(); + } else { + _pollTimer?.cancel(); + // Release any held device + _rpc.command('get', ['nfc']); + } + } + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } + + void _pollReaders() async { + _pollTimer?.cancel(); + + try { + var children = await _rpc.command('scan', ['nfc']); + var newState = children.keys.join(':'); + + if (mounted && newState != _nfcState) { + log.info('NFC state change', jsonEncode(children)); + _nfcState = newState; + state = children.entries + .map((e) => + DeviceNode.nfcReader(['nfc', e.key], e.value['name'] as String) + as NfcReaderNode) + .toList(); + } + } on RpcError catch (e) { + log.severe('Error polling NFC', jsonEncode(e)); + } + + if (mounted) { + _pollTimer = Timer(_nfcPollDelay, _pollReaders); + } + } +} + +final desktopDevicesProvider = Provider>((ref) { + final usbDevices = ref.watch(_usbDevicesProvider).toList(); + final nfcDevices = ref.watch(_nfcDevicesProvider).toList(); + usbDevices.sort((a, b) => a.name.compareTo(b.name)); + nfcDevices.sort((a, b) => a.name.compareTo(b.name)); + return [...usbDevices, ...nfcDevices]; +}); + +final _desktopDeviceDataProvider = + StateNotifierProvider((ref) { + final notifier = CurrentDeviceDataNotifier( + ref.watch(rpcProvider), + ref.watch(currentDeviceProvider), + ); + if (notifier._deviceNode is NfcReaderNode) { + // If this is an NFC reader, listen on WindowState. + ref.listen(windowStateProvider, (_, windowState) { + notifier._notifyWindowState(windowState); + }, fireImmediately: true); + } + return notifier; +}); + +final desktopDeviceDataProvider = Provider( + (ref) => ref.watch(_desktopDeviceDataProvider), +); + +class CurrentDeviceDataNotifier extends StateNotifier { + final RpcSession _rpc; + final DeviceNode? _deviceNode; + Timer? _pollTimer; + + CurrentDeviceDataNotifier(this._rpc, this._deviceNode) : super(null) { + final dev = _deviceNode; + if (dev is UsbYubiKeyNode) { + state = YubiKeyData(dev, dev.name, dev.info); + } + } + + void _notifyWindowState(WindowState windowState) { + if (windowState.active) { + _pollReader(); + } else { + _pollTimer?.cancel(); + // TODO: Should we clear the key here? + /*if (mounted) { + state = null; + }*/ + } + } + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } + + void _pollReader() async { + _pollTimer?.cancel(); + final node = _deviceNode!; + try { + var result = await _rpc.command('get', node.path); + if (mounted) { + if (result['data']['present']) { + state = YubiKeyData(node, result['data']['name'], + DeviceInfo.fromJson(result['data']['info'])); + } else { + state = null; + } + } + } on RpcError catch (e) { + log.severe('Error polling NFC', jsonEncode(e)); + } + if (mounted) { + _pollTimer = Timer( + state == null ? _nfcAttachPollDelay : _nfcDetachPollDelay, + _pollReader); + } + } +} diff --git a/lib/desktop/init.dart b/lib/desktop/init.dart new file mode 100755 index 00000000..7dc5c9fb --- /dev/null +++ b/lib/desktop/init.dart @@ -0,0 +1,56 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:yubico_authenticator/desktop/devices.dart'; +import 'package:yubico_authenticator/desktop/oath/state.dart'; +import 'package:yubico_authenticator/desktop/state.dart'; +import 'package:yubico_authenticator/oath/state.dart'; + +import '../app/state.dart'; +import 'rpc.dart'; + +final log = Logger('desktop.init'); + +Future> initializeAndGetOverrides() async { + await windowManager.ensureInitialized(); + + // Linux doesn't currently support hiding the window at start currently. + // For now, this size should match linux/flutter/my_application.cc to avoid window flicker at startup. + unawaited(windowManager.waitUntilReadyToShow().then((_) async { + await windowManager.setSize(const Size(400, 720)); + await windowManager.show(); + })); + + // Either use the _YKMAN_EXE environment variable, or look relative to executable. + var exe = Platform.environment['_YKMAN_PATH']; + if (exe?.isEmpty ?? true) { + var relativePath = 'ykman/ykman'; + if (Platform.isMacOS) { + relativePath = '../Resources/' + relativePath; + } else if (Platform.isWindows) { + relativePath += '.exe'; + } + exe = Uri.file(Platform.resolvedExecutable) + .resolve(relativePath) + .toFilePath(); + } + + log.info('Starting subprocess: $exe'); + var rpc = await RpcSession.launch(exe!); + log.info('ykman process started', exe); + rpc.setLogLevel(Logger.root.level); + + return [ + rpcProvider.overrideWithValue(rpc), + windowStateProvider.overrideWithProvider(desktopWindowStateProvider), + attachedDevicesProvider.overrideWithProvider(desktopDevicesProvider), + currentDeviceDataProvider.overrideWithProvider(desktopDeviceDataProvider), + oathStateProvider.overrideWithProvider(desktopOathState), + credentialListProvider + .overrideWithProvider(desktopOathCredentialListProvider), + ]; +} diff --git a/lib/desktop/models.dart b/lib/desktop/models.dart new file mode 100755 index 00000000..d96326a3 --- /dev/null +++ b/lib/desktop/models.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'models.freezed.dart'; +part 'models.g.dart'; + +@Freezed(unionKey: 'kind') +class RpcResponse with _$RpcResponse { + factory RpcResponse.success(Map body) = Success; + factory RpcResponse.signal(String status, Map body) = Signal; + factory RpcResponse.error( + String status, String message, Map body) = RpcError; + + factory RpcResponse.fromJson(Map json) => + _$RpcResponseFromJson(json); +} + +@freezed +class RpcState with _$RpcState { + const factory RpcState(String version) = _RpcState; +} diff --git a/lib/desktop/models.freezed.dart b/lib/desktop/models.freezed.dart new file mode 100755 index 00000000..cc4a1baf --- /dev/null +++ b/lib/desktop/models.freezed.dart @@ -0,0 +1,776 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target + +part of 'models.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more informations: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +RpcResponse _$RpcResponseFromJson(Map json) { + switch (json['kind']) { + case 'success': + return Success.fromJson(json); + case 'signal': + return Signal.fromJson(json); + case 'error': + return RpcError.fromJson(json); + + default: + throw CheckedFromJsonException( + json, 'kind', 'RpcResponse', 'Invalid union type "${json['kind']}"!'); + } +} + +/// @nodoc +class _$RpcResponseTearOff { + const _$RpcResponseTearOff(); + + Success success(Map body) { + return Success( + body, + ); + } + + Signal signal(String status, Map body) { + return Signal( + status, + body, + ); + } + + RpcError error(String status, String message, Map body) { + return RpcError( + status, + message, + body, + ); + } + + RpcResponse fromJson(Map json) { + return RpcResponse.fromJson(json); + } +} + +/// @nodoc +const $RpcResponse = _$RpcResponseTearOff(); + +/// @nodoc +mixin _$RpcResponse { + Map get body => throw _privateConstructorUsedError; + + @optionalTypeArgs + TResult when({ + required TResult Function(Map body) success, + required TResult Function(String status, Map body) signal, + required TResult Function( + String status, String message, Map body) + error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult Function(Map body)? success, + TResult Function(String status, Map body)? signal, + TResult Function(String status, String message, Map body)? + error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(Map body)? success, + TResult Function(String status, Map body)? signal, + TResult Function(String status, String message, Map body)? + error, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(Success value) success, + required TResult Function(Signal value) signal, + required TResult Function(RpcError value) error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult Function(Success value)? success, + TResult Function(Signal value)? signal, + TResult Function(RpcError value)? error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(Success value)? success, + TResult Function(Signal value)? signal, + TResult Function(RpcError value)? error, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $RpcResponseCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RpcResponseCopyWith<$Res> { + factory $RpcResponseCopyWith( + RpcResponse value, $Res Function(RpcResponse) then) = + _$RpcResponseCopyWithImpl<$Res>; + $Res call({Map body}); +} + +/// @nodoc +class _$RpcResponseCopyWithImpl<$Res> implements $RpcResponseCopyWith<$Res> { + _$RpcResponseCopyWithImpl(this._value, this._then); + + final RpcResponse _value; + // ignore: unused_field + final $Res Function(RpcResponse) _then; + + @override + $Res call({ + Object? body = freezed, + }) { + return _then(_value.copyWith( + body: body == freezed + ? _value.body + : body // ignore: cast_nullable_to_non_nullable + as Map, + )); + } +} + +/// @nodoc +abstract class $SuccessCopyWith<$Res> implements $RpcResponseCopyWith<$Res> { + factory $SuccessCopyWith(Success value, $Res Function(Success) then) = + _$SuccessCopyWithImpl<$Res>; + @override + $Res call({Map body}); +} + +/// @nodoc +class _$SuccessCopyWithImpl<$Res> extends _$RpcResponseCopyWithImpl<$Res> + implements $SuccessCopyWith<$Res> { + _$SuccessCopyWithImpl(Success _value, $Res Function(Success) _then) + : super(_value, (v) => _then(v as Success)); + + @override + Success get _value => super._value as Success; + + @override + $Res call({ + Object? body = freezed, + }) { + return _then(Success( + body == freezed + ? _value.body + : body // ignore: cast_nullable_to_non_nullable + as Map, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$Success implements Success { + _$Success(this.body, {String? $type}) : $type = $type ?? 'success'; + + factory _$Success.fromJson(Map json) => + _$$SuccessFromJson(json); + + @override + final Map body; + + @JsonKey(name: 'kind') + final String $type; + + @override + String toString() { + return 'RpcResponse.success(body: $body)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Success && + const DeepCollectionEquality().equals(other.body, body)); + } + + @override + int get hashCode => + Object.hash(runtimeType, const DeepCollectionEquality().hash(body)); + + @JsonKey(ignore: true) + @override + $SuccessCopyWith get copyWith => + _$SuccessCopyWithImpl(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(Map body) success, + required TResult Function(String status, Map body) signal, + required TResult Function( + String status, String message, Map body) + error, + }) { + return success(body); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult Function(Map body)? success, + TResult Function(String status, Map body)? signal, + TResult Function(String status, String message, Map body)? + error, + }) { + return success?.call(body); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(Map body)? success, + TResult Function(String status, Map body)? signal, + TResult Function(String status, String message, Map body)? + error, + required TResult orElse(), + }) { + if (success != null) { + return success(body); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(Success value) success, + required TResult Function(Signal value) signal, + required TResult Function(RpcError value) error, + }) { + return success(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult Function(Success value)? success, + TResult Function(Signal value)? signal, + TResult Function(RpcError value)? error, + }) { + return success?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(Success value)? success, + TResult Function(Signal value)? signal, + TResult Function(RpcError value)? error, + required TResult orElse(), + }) { + if (success != null) { + return success(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$SuccessToJson(this); + } +} + +abstract class Success implements RpcResponse { + factory Success(Map body) = _$Success; + + factory Success.fromJson(Map json) = _$Success.fromJson; + + @override + Map get body; + @override + @JsonKey(ignore: true) + $SuccessCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SignalCopyWith<$Res> implements $RpcResponseCopyWith<$Res> { + factory $SignalCopyWith(Signal value, $Res Function(Signal) then) = + _$SignalCopyWithImpl<$Res>; + @override + $Res call({String status, Map body}); +} + +/// @nodoc +class _$SignalCopyWithImpl<$Res> extends _$RpcResponseCopyWithImpl<$Res> + implements $SignalCopyWith<$Res> { + _$SignalCopyWithImpl(Signal _value, $Res Function(Signal) _then) + : super(_value, (v) => _then(v as Signal)); + + @override + Signal get _value => super._value as Signal; + + @override + $Res call({ + Object? status = freezed, + Object? body = freezed, + }) { + return _then(Signal( + status == freezed + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as String, + body == freezed + ? _value.body + : body // ignore: cast_nullable_to_non_nullable + as Map, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$Signal implements Signal { + _$Signal(this.status, this.body, {String? $type}) : $type = $type ?? 'signal'; + + factory _$Signal.fromJson(Map json) => + _$$SignalFromJson(json); + + @override + final String status; + @override + final Map body; + + @JsonKey(name: 'kind') + final String $type; + + @override + String toString() { + return 'RpcResponse.signal(status: $status, body: $body)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Signal && + const DeepCollectionEquality().equals(other.status, status) && + const DeepCollectionEquality().equals(other.body, body)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(status), + const DeepCollectionEquality().hash(body)); + + @JsonKey(ignore: true) + @override + $SignalCopyWith get copyWith => + _$SignalCopyWithImpl(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(Map body) success, + required TResult Function(String status, Map body) signal, + required TResult Function( + String status, String message, Map body) + error, + }) { + return signal(status, body); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult Function(Map body)? success, + TResult Function(String status, Map body)? signal, + TResult Function(String status, String message, Map body)? + error, + }) { + return signal?.call(status, body); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(Map body)? success, + TResult Function(String status, Map body)? signal, + TResult Function(String status, String message, Map body)? + error, + required TResult orElse(), + }) { + if (signal != null) { + return signal(status, body); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(Success value) success, + required TResult Function(Signal value) signal, + required TResult Function(RpcError value) error, + }) { + return signal(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult Function(Success value)? success, + TResult Function(Signal value)? signal, + TResult Function(RpcError value)? error, + }) { + return signal?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(Success value)? success, + TResult Function(Signal value)? signal, + TResult Function(RpcError value)? error, + required TResult orElse(), + }) { + if (signal != null) { + return signal(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$SignalToJson(this); + } +} + +abstract class Signal implements RpcResponse { + factory Signal(String status, Map body) = _$Signal; + + factory Signal.fromJson(Map json) = _$Signal.fromJson; + + String get status; + @override + Map get body; + @override + @JsonKey(ignore: true) + $SignalCopyWith get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RpcErrorCopyWith<$Res> implements $RpcResponseCopyWith<$Res> { + factory $RpcErrorCopyWith(RpcError value, $Res Function(RpcError) then) = + _$RpcErrorCopyWithImpl<$Res>; + @override + $Res call({String status, String message, Map body}); +} + +/// @nodoc +class _$RpcErrorCopyWithImpl<$Res> extends _$RpcResponseCopyWithImpl<$Res> + implements $RpcErrorCopyWith<$Res> { + _$RpcErrorCopyWithImpl(RpcError _value, $Res Function(RpcError) _then) + : super(_value, (v) => _then(v as RpcError)); + + @override + RpcError get _value => super._value as RpcError; + + @override + $Res call({ + Object? status = freezed, + Object? message = freezed, + Object? body = freezed, + }) { + return _then(RpcError( + status == freezed + ? _value.status + : status // ignore: cast_nullable_to_non_nullable + as String, + message == freezed + ? _value.message + : message // ignore: cast_nullable_to_non_nullable + as String, + body == freezed + ? _value.body + : body // ignore: cast_nullable_to_non_nullable + as Map, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$RpcError implements RpcError { + _$RpcError(this.status, this.message, this.body, {String? $type}) + : $type = $type ?? 'error'; + + factory _$RpcError.fromJson(Map json) => + _$$RpcErrorFromJson(json); + + @override + final String status; + @override + final String message; + @override + final Map body; + + @JsonKey(name: 'kind') + final String $type; + + @override + String toString() { + return 'RpcResponse.error(status: $status, message: $message, body: $body)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is RpcError && + const DeepCollectionEquality().equals(other.status, status) && + const DeepCollectionEquality().equals(other.message, message) && + const DeepCollectionEquality().equals(other.body, body)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(status), + const DeepCollectionEquality().hash(message), + const DeepCollectionEquality().hash(body)); + + @JsonKey(ignore: true) + @override + $RpcErrorCopyWith get copyWith => + _$RpcErrorCopyWithImpl(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(Map body) success, + required TResult Function(String status, Map body) signal, + required TResult Function( + String status, String message, Map body) + error, + }) { + return error(status, message, body); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult Function(Map body)? success, + TResult Function(String status, Map body)? signal, + TResult Function(String status, String message, Map body)? + error, + }) { + return error?.call(status, message, body); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(Map body)? success, + TResult Function(String status, Map body)? signal, + TResult Function(String status, String message, Map body)? + error, + required TResult orElse(), + }) { + if (error != null) { + return error(status, message, body); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(Success value) success, + required TResult Function(Signal value) signal, + required TResult Function(RpcError value) error, + }) { + return error(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult Function(Success value)? success, + TResult Function(Signal value)? signal, + TResult Function(RpcError value)? error, + }) { + return error?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(Success value)? success, + TResult Function(Signal value)? signal, + TResult Function(RpcError value)? error, + required TResult orElse(), + }) { + if (error != null) { + return error(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$RpcErrorToJson(this); + } +} + +abstract class RpcError implements RpcResponse { + factory RpcError(String status, String message, Map body) = + _$RpcError; + + factory RpcError.fromJson(Map json) = _$RpcError.fromJson; + + String get status; + String get message; + @override + Map get body; + @override + @JsonKey(ignore: true) + $RpcErrorCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +class _$RpcStateTearOff { + const _$RpcStateTearOff(); + + _RpcState call(String version) { + return _RpcState( + version, + ); + } +} + +/// @nodoc +const $RpcState = _$RpcStateTearOff(); + +/// @nodoc +mixin _$RpcState { + String get version => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $RpcStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RpcStateCopyWith<$Res> { + factory $RpcStateCopyWith(RpcState value, $Res Function(RpcState) then) = + _$RpcStateCopyWithImpl<$Res>; + $Res call({String version}); +} + +/// @nodoc +class _$RpcStateCopyWithImpl<$Res> implements $RpcStateCopyWith<$Res> { + _$RpcStateCopyWithImpl(this._value, this._then); + + final RpcState _value; + // ignore: unused_field + final $Res Function(RpcState) _then; + + @override + $Res call({ + Object? version = freezed, + }) { + return _then(_value.copyWith( + version: version == freezed + ? _value.version + : version // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +abstract class _$RpcStateCopyWith<$Res> implements $RpcStateCopyWith<$Res> { + factory _$RpcStateCopyWith(_RpcState value, $Res Function(_RpcState) then) = + __$RpcStateCopyWithImpl<$Res>; + @override + $Res call({String version}); +} + +/// @nodoc +class __$RpcStateCopyWithImpl<$Res> extends _$RpcStateCopyWithImpl<$Res> + implements _$RpcStateCopyWith<$Res> { + __$RpcStateCopyWithImpl(_RpcState _value, $Res Function(_RpcState) _then) + : super(_value, (v) => _then(v as _RpcState)); + + @override + _RpcState get _value => super._value as _RpcState; + + @override + $Res call({ + Object? version = freezed, + }) { + return _then(_RpcState( + version == freezed + ? _value.version + : version // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$_RpcState implements _RpcState { + const _$_RpcState(this.version); + + @override + final String version; + + @override + String toString() { + return 'RpcState(version: $version)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _RpcState && + const DeepCollectionEquality().equals(other.version, version)); + } + + @override + int get hashCode => + Object.hash(runtimeType, const DeepCollectionEquality().hash(version)); + + @JsonKey(ignore: true) + @override + _$RpcStateCopyWith<_RpcState> get copyWith => + __$RpcStateCopyWithImpl<_RpcState>(this, _$identity); +} + +abstract class _RpcState implements RpcState { + const factory _RpcState(String version) = _$_RpcState; + + @override + String get version; + @override + @JsonKey(ignore: true) + _$RpcStateCopyWith<_RpcState> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/core/models.g.dart b/lib/desktop/models.g.dart similarity index 100% rename from lib/core/models.g.dart rename to lib/desktop/models.g.dart diff --git a/lib/desktop/oath/state.dart b/lib/desktop/oath/state.dart new file mode 100755 index 00000000..36606560 --- /dev/null +++ b/lib/desktop/oath/state.dart @@ -0,0 +1,330 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; + +import '../../app/models.dart'; +import '../../app/state.dart'; +import '../../oath/models.dart'; +import '../../oath/state.dart'; +import '../rpc.dart'; +import '../state.dart'; + +final log = Logger('desktop.oath.state'); + +final _sessionProvider = + Provider.autoDispose.family>( + (ref, devicePath) => + RpcNodeSession(ref.watch(rpcProvider), devicePath, ['ccid', 'oath']), +); + +final desktopOathState = StateNotifierProvider.autoDispose + .family>( + (ref, devicePath) { + final session = ref.watch(_sessionProvider(devicePath)); + final notifier = _DesktopOathStateNotifier(session, ref); + 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 _DesktopOathStateNotifier extends OathStateNotifier { + final RpcNodeSession _session; + final Ref _ref; + _DesktopOathStateNotifier(this._session, this._ref) : super(); + + refresh() async { + var result = await _session.command('get'); + log.config('application status', jsonEncode(result)); + var oathState = OathState.fromJson(result['data']); + final key = _ref.read(oathLockKeyProvider(_session.devicePath)); + if (oathState.locked && key != null) { + final result = await _session.command('validate', params: {'key': key}); + if (result['unlocked']) { + oathState = oathState.copyWith(locked: false); + } else { + _ref.read(oathLockKeyProvider(_session.devicePath).notifier).unsetKey(); + } + } + if (mounted) { + state = oathState; + } + } + + @override + Future reset() async { + await _session.command('reset'); + _ref.read(oathLockKeyProvider(_session.devicePath).notifier).unsetKey(); + _ref.refresh(_sessionProvider(_session.devicePath)); + } + + @override + Future unlock(String password) async { + var result = + await _session.command('derive', params: {'password': password}); + var key = result['key']; + final status = await _session.command('validate', params: {'key': key}); + if (mounted && status['unlocked']) { + log.config('applet unlocked'); + _ref.read(oathLockKeyProvider(_session.devicePath).notifier).setKey(key); + state = state?.copyWith(locked: false); + } + return status['unlocked']; + } + + Future _checkPassword(String password) async { + var result = + await _session.command('derive', params: {'password': password}); + return _ref.read(oathLockKeyProvider(_session.devicePath)) == result['key']; + } + + @override + Future setPassword(String? current, String password) async { + if (state?.hasKey ?? false) { + if (current != null) { + if (!await _checkPassword(current)) { + return false; + } + } else { + return false; + } + } + + var result = + await _session.command('derive', params: {'password': password}); + var key = result['key']; + await _session.command('set_key', params: {'key': key}); + log.config('OATH key set'); + _ref.read(oathLockKeyProvider(_session.devicePath).notifier).setKey(key); + if (mounted) { + state = state?.copyWith(hasKey: true); + } + return true; + } + + @override + Future unsetPassword(String current) async { + if (state?.hasKey ?? false) { + if (!await _checkPassword(current)) { + return false; + } + } + await _session.command('unset_key'); + _ref.read(oathLockKeyProvider(_session.devicePath).notifier).unsetKey(); + if (mounted) { + state = state?.copyWith(hasKey: false, locked: false); + } + return true; + } +} + +final desktopOathCredentialListProvider = StateNotifierProvider.autoDispose + .family?, List>( + (ref, devicePath) { + var notifier = _DesktopCredentialListNotifier( + ref.watch(_sessionProvider(devicePath)), + ref.watch(oathStateProvider(devicePath).select((s) => s?.locked ?? true)), + ); + ref.listen(windowStateProvider, (_, windowState) { + notifier._notifyWindowState(windowState); + }, fireImmediately: true); + return notifier; + }, +); + +extension on OathCredential { + bool get isSteam => issuer == 'Steam' && oathType == OathType.totp; +} + +const String _steamCharTable = '23456789BCDFGHJKMNPQRTVWXY'; +String _formatSteam(String response) { + final offset = int.parse(response.substring(response.length - 1), radix: 16); + var number = + int.parse(response.substring(offset * 2, offset * 2 + 8), radix: 16) & + 0x7fffffff; + var value = ''; + for (var i = 0; i < 5; i++) { + value += _steamCharTable[number % _steamCharTable.length]; + number ~/= _steamCharTable.length; + } + return value; +} + +class _DesktopCredentialListNotifier extends OathCredentialListNotifier { + final RpcNodeSession _session; + final bool _locked; + Timer? _timer; + _DesktopCredentialListNotifier(this._session, this._locked) : super(); + + void _notifyWindowState(WindowState windowState) { + if (_locked) return; + if (windowState.active) { + _scheduleRefresh(); + } else { + _timer?.cancel(); + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Future calculate(OathCredential credential, + {bool update = true}) async { + final OathCode code; + if (credential.isSteam) { + final timeStep = DateTime.now().millisecondsSinceEpoch ~/ 30000; + var result = await _session.command('calculate', target: [ + 'accounts', + credential.id + ], params: { + 'challenge': timeStep.toRadixString(16).padLeft(16, '0'), + }); + code = OathCode( + _formatSteam(result['response']), timeStep * 30, (timeStep + 1) * 30); + } else { + var result = + await _session.command('code', target: ['accounts', credential.id]); + code = OathCode.fromJson(result); + } + log.config('Calculate', jsonEncode(code)); + 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; + } + + @override + Future addAccount(Uri otpauth, + {bool requireTouch = false, bool update = true}) async { + var result = await _session.command('put', target: [ + 'accounts' + ], params: { + 'uri': otpauth.toString(), + 'require_touch': requireTouch, + }); + final credential = OathCredential.fromJson(result); + if (update && mounted) { + state = state!.toList()..add(OathPair(credential, null)); + if (!requireTouch && credential.oathType == OathType.totp) { + await calculate(credential); + } + } + return credential; + } + + @override + Future renameAccount( + OathCredential credential, + String? issuer, + String name, + ) async { + final result = await _session.command('rename', target: [ + 'accounts', + credential.id, + ], params: { + 'issuer': issuer, + 'name': name, + }); + String credentialId = result['credential_id']; + final renamedCredential = + credential.copyWith(id: credentialId, issuer: issuer, name: name); + if (mounted) { + final newState = state!.toList(); + final index = newState.indexWhere((e) => e.credential == credential); + final oldPair = newState.removeAt(index); + newState.add(OathPair( + renamedCredential, + oldPair.code, + )); + state = newState; + } + return renamedCredential; + } + + @override + Future deleteAccount(OathCredential credential) async { + await _session.command('delete', target: ['accounts', credential.id]); + if (mounted) { + state = state!.toList()..removeWhere((e) => e.credential == credential); + } + } + + refresh() async { + if (_locked) return; + log.config('refreshing credentials...'); + var result = await _session.command('calculate_all', target: ['accounts']); + log.config('Entries', jsonEncode(result)); + + final pairs = []; + 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']); + pairs.add(OathPair(credential, code)); + } + + if (mounted) { + final current = state?.toList() ?? []; + for (var pair in pairs) { + final i = + current.indexWhere((e) => e.credential.id == pair.credential.id); + if (i < 0) { + current.add(pair); + } else if (pair.code != null) { + current[i] = current[i].copyWith(code: pair.code); + } + } + state = current; + _scheduleRefresh(); + } + } + + _scheduleRefresh() { + _timer?.cancel(); + if (_locked) return; + if (state == null) { + refresh(); + } else if (mounted) { + final expirations = (state ?? []) + .where((pair) => + pair.credential.oathType == OathType.totp && + !pair.credential.touchRequired) + .map((e) => e.code) + .whereType() + .map((e) => e.validTo); + if (expirations.isEmpty) { + _timer = null; + } else { + final earliest = expirations.reduce(min) * 1000; + final now = DateTime.now().millisecondsSinceEpoch; + if (earliest < now) { + refresh(); + } else { + _timer = Timer(Duration(milliseconds: earliest - now), refresh); + } + } + } + } +} diff --git a/lib/core/rpc.dart b/lib/desktop/rpc.dart similarity index 77% rename from lib/core/rpc.dart rename to lib/desktop/rpc.dart index 6f10a17a..dfc77344 100644 --- a/lib/core/rpc.dart +++ b/lib/desktop/rpc.dart @@ -142,3 +142,46 @@ class RpcSession { } } } + +typedef ErrorHandler = Future Function(RpcError e); + +class RpcNodeSession { + final RpcSession _rpc; + final List devicePath; + final List subPath; + final Map _errorHandlers = {}; + + RpcNodeSession(this._rpc, this.devicePath, this.subPath); + + void setErrorHandler(String status, ErrorHandler handler) { + _errorHandlers[status] = handler; + } + + void unserErrorHandler(String status) { + _errorHandlers.remove(status); + } + + Future> command( + String action, { + List target = const [], + Map? params, + Signaler? signal, + }) async { + try { + return await _rpc.command( + action, + devicePath + subPath + target, + params: params, + signal: signal, + ); + } on RpcError catch (e) { + 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; + } + } +} diff --git a/lib/desktop/state.dart b/lib/desktop/state.dart new file mode 100755 index 00000000..c4baebe8 --- /dev/null +++ b/lib/desktop/state.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../core/state.dart'; +import '../app/models.dart'; +import 'models.dart'; +import 'rpc.dart'; + +// This must be initialized before use in initialize.dart. +final rpcProvider = Provider((ref) { + throw UnimplementedError(); +}); + +final rpcStateProvider = StateNotifierProvider<_RpcStateNotifier, RpcState>( + (ref) { + final rpc = ref.watch(rpcProvider); + ref.listen(logLevelProvider, (_, level) { + rpc.setLogLevel(level); + }, fireImmediately: true); + return _RpcStateNotifier(rpc); + }, +); + +class _RpcStateNotifier extends StateNotifier { + final RpcSession rpc; + _RpcStateNotifier(this.rpc) : super(const RpcState('unknown')) { + _init(); + } + + _init() async { + final response = await rpc.command('get', []); + if (mounted) { + state = state.copyWith(version: response['data']['version']); + } + } +} + +final _windowStateProvider = + StateNotifierProvider<_WindowStateNotifier, WindowState>( + (ref) => _WindowStateNotifier()); + +final desktopWindowStateProvider = Provider( + (ref) => ref.watch(_windowStateProvider), +); + +class _WindowStateNotifier extends StateNotifier + with WindowListener { + Timer? _idleTimer; + _WindowStateNotifier() + : super(WindowState(focused: true, visible: true, active: true)) { + _init(); + } + + void _init() async { + windowManager.addListener(this); + if (!await windowManager.isVisible() && mounted) { + state = WindowState(focused: false, visible: false, active: true); + _idleTimer = Timer(const Duration(seconds: 5), () { + if (mounted) { + state = state.copyWith(active: false); + } + }); + } + } + + @override + void dispose() { + windowManager.removeListener(this); + super.dispose(); + } + + @override + set state(WindowState value) { + log.config('Window state changed: $value'); + super.state = value; + } + + @override + void onWindowEvent(String eventName) { + if (mounted) { + switch (eventName) { + case 'blur': + state = state.copyWith(focused: false); + _idleTimer?.cancel(); + _idleTimer = Timer(const Duration(seconds: 5), () { + if (mounted) { + state = state.copyWith(active: false); + } + }); + break; + case 'focus': + state = state.copyWith(focused: true, active: true); + _idleTimer?.cancel(); + break; + case 'minimize': + state = state.copyWith(visible: false, active: false); + _idleTimer?.cancel(); + break; + case 'restore': + state = state.copyWith(visible: true, active: true); + break; + default: + log.fine('Window event ignored: $eventName'); + } + } + } +} diff --git a/lib/main.dart b/lib/main.dart index b25b3727..d288e321 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,14 @@ -import 'dart:async'; -import 'dart:io'; import 'dart:developer' as developer; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:logging/logging.dart'; -import 'package:window_manager/window_manager.dart'; import 'app/app.dart'; import 'app/views/main_page.dart'; -import 'core/rpc.dart'; import 'core/state.dart'; +import 'desktop/init.dart' as desktop; import 'error_page.dart'; @@ -20,47 +17,23 @@ final log = Logger('main'); void main() async { _initLogging(Level.INFO); WidgetsFlutterBinding.ensureInitialized(); - await windowManager.ensureInitialized(); - // Either use the _YKMAN_EXE environment variable, or look relative to executable. - var exe = Platform.environment['_YKMAN_PATH']; - if (exe?.isEmpty ?? true) { - var relativePath = 'ykman/ykman'; - if (Platform.isMacOS) { - relativePath = '../Resources/' + relativePath; - } else if (Platform.isWindows) { - relativePath += '.exe'; - } - exe = Uri.file(Platform.resolvedExecutable) - .resolve(relativePath) - .toFilePath(); - } - - Widget page; List overrides = [ - prefProvider.overrideWithValue(await SharedPreferences.getInstance()) + prefProvider.overrideWithValue(await SharedPreferences.getInstance()), ]; - - log.info('Starting subprocess: $exe'); + Widget page; try { - var rpc = await RpcSession.launch(exe!); - // Enable logging TODO: Make this configurable - log.info('ykman process started', exe); - rpc.setLogLevel(Logger.root.level); - overrides.add(rpcProvider.overrideWithValue(rpc)); + // Platform specific initialization + if (isDesktop) { + log.config('Initializing desktop platform.'); + overrides.addAll(await desktop.initializeAndGetOverrides()); + } page = const MainPage(); } catch (e) { - log.warning('ykman process failed: $e'); + log.warning('Platform initialization failed: $e'); page = ErrorPage(error: e.toString()); } - // Linux doesn't currently support hiding the window at start currently. - // For now, this size should match linux/flutter/my_application.cc to avoid window flicker at startup. - unawaited(windowManager.waitUntilReadyToShow().then((_) async { - await windowManager.setSize(const Size(400, 720)); - await windowManager.show(); - })); - runApp(ProviderScope( overrides: overrides, child: YubicoAuthenticatorApp(page: page), diff --git a/lib/oath/state.dart b/lib/oath/state.dart index 05a34dec..9e2e0164 100755 --- a/lib/oath/state.dart +++ b/lib/oath/state.dart @@ -1,12 +1,9 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:logging/logging.dart'; -import 'package:yubico_authenticator/app/models.dart'; import '../app/state.dart'; import '../core/state.dart'; @@ -14,12 +11,8 @@ import 'models.dart'; final log = Logger('oath.state'); -final _sessionProvider = Provider.autoDispose - .family>((ref, devicePath) => - RpcNodeSession(ref.watch(rpcProvider), devicePath, ['ccid', 'oath'])); - // This remembers the key for all devices for the duration of the process. -final _lockKeyProvider = +final oathLockKeyProvider = StateNotifierProvider.family<_LockKeyNotifier, String?, List>( (ref, devicePath) => _LockKeyNotifier(null)); @@ -37,163 +30,26 @@ class _LockKeyNotifier extends StateNotifier { final oathStateProvider = StateNotifierProvider.autoDispose .family>( - (ref, devicePath) { - final session = ref.watch(_sessionProvider(devicePath)); - final notifier = OathStateNotifier(session, ref); - 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(); - }, + (ref, devicePath) => throw UnimplementedError(), ); -class OathStateNotifier extends StateNotifier { - final RpcNodeSession _session; - final Ref _ref; - OathStateNotifier(this._session, this._ref) : super(null); +abstract class OathStateNotifier extends StateNotifier { + OathStateNotifier() : super(null); - refresh() async { - var result = await _session.command('get'); - log.config('application status', jsonEncode(result)); - var oathState = OathState.fromJson(result['data']); - final key = _ref.read(_lockKeyProvider(_session.devicePath)); - if (oathState.locked && key != null) { - final result = await _session.command('validate', params: {'key': key}); - if (result['unlocked']) { - oathState = oathState.copyWith(locked: false); - } else { - _ref.read(_lockKeyProvider(_session.devicePath).notifier).unsetKey(); - } - } - if (mounted) { - state = oathState; - } - } - - Future reset() async { - await _session.command('reset'); - _ref.read(_lockKeyProvider(_session.devicePath).notifier).unsetKey(); - _ref.refresh(_sessionProvider(_session.devicePath)); - } - - Future unlock(String password) async { - var result = - await _session.command('derive', params: {'password': password}); - var key = result['key']; - final status = await _session.command('validate', params: {'key': key}); - if (mounted && status['unlocked']) { - log.config('applet unlocked'); - _ref.read(_lockKeyProvider(_session.devicePath).notifier).setKey(key); - state = state?.copyWith(locked: false); - } - return status['unlocked']; - } - - Future _checkPassword(String password) async { - var result = - await _session.command('derive', params: {'password': password}); - return _ref.read(_lockKeyProvider(_session.devicePath)) == result['key']; - } - - Future setPassword(String? current, String password) async { - if (state?.hasKey ?? false) { - if (current != null) { - if (!await _checkPassword(current)) { - return false; - } - } else { - return false; - } - } - - var result = - await _session.command('derive', params: {'password': password}); - var key = result['key']; - await _session.command('set_key', params: {'key': key}); - log.config('OATH key set'); - _ref.read(_lockKeyProvider(_session.devicePath).notifier).setKey(key); - if (mounted) { - state = state?.copyWith(hasKey: true); - } - return true; - } - - Future unsetPassword(String current) async { - if (state?.hasKey ?? false) { - if (!await _checkPassword(current)) { - return false; - } - } - await _session.command('unset_key'); - _ref.read(_lockKeyProvider(_session.devicePath).notifier).unsetKey(); - if (mounted) { - state = state?.copyWith(hasKey: false, locked: false); - } - return true; - } + Future reset(); + Future unlock(String password); + Future setPassword(String? current, String password); + Future unsetPassword(String current); } final credentialListProvider = StateNotifierProvider.autoDispose - .family?, List>( - (ref, devicePath) { - var notifier = CredentialListNotifier( - ref.watch(_sessionProvider(devicePath)), - ref.watch(oathStateProvider(devicePath).select((s) => s?.locked ?? true)), - ); - ref.listen(windowStateProvider, (_, windowState) { - notifier._notifyWindowState(windowState); - }, fireImmediately: true); - return notifier; - }, + .family?, List>( + (ref, arg) => throw UnimplementedError(), ); -extension on OathCredential { - bool get isSteam => issuer == 'Steam' && oathType == OathType.totp; -} - -const String _steamCharTable = '23456789BCDFGHJKMNPQRTVWXY'; -String _formatSteam(String response) { - final offset = int.parse(response.substring(response.length - 1), radix: 16); - var number = - int.parse(response.substring(offset * 2, offset * 2 + 8), radix: 16) & - 0x7fffffff; - var value = ''; - for (var i = 0; i < 5; i++) { - value += _steamCharTable[number % _steamCharTable.length]; - number ~/= _steamCharTable.length; - } - return value; -} - -class CredentialListNotifier extends StateNotifier?> { - final RpcNodeSession _session; - final bool _locked; - Timer? _timer; - CredentialListNotifier(this._session, this._locked) : super(null); - - void _notifyWindowState(WindowState windowState) { - if (_locked) return; - if (windowState.active) { - _scheduleRefresh(); - } else { - _timer?.cancel(); - } - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } +abstract class OathCredentialListNotifier + extends StateNotifier?> { + OathCredentialListNotifier() : super(null); @override @protected @@ -201,142 +57,11 @@ class CredentialListNotifier extends StateNotifier?> { super.state = value != null ? List.unmodifiable(value) : null; } - Future calculate(OathCredential credential, - {bool update = true}) async { - final OathCode code; - if (credential.isSteam) { - final timeStep = DateTime.now().millisecondsSinceEpoch ~/ 30000; - var result = await _session.command('calculate', target: [ - 'accounts', - credential.id - ], params: { - 'challenge': timeStep.toRadixString(16).padLeft(16, '0'), - }); - code = OathCode( - _formatSteam(result['response']), timeStep * 30, (timeStep + 1) * 30); - } else { - var result = - await _session.command('code', target: ['accounts', credential.id]); - code = OathCode.fromJson(result); - } - log.config('Calculate', jsonEncode(code)); - 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; - } - - Future addAccount(Uri otpauth, - {bool requireTouch = false, bool update = true}) async { - var result = await _session.command('put', target: [ - 'accounts' - ], params: { - 'uri': otpauth.toString(), - 'require_touch': requireTouch, - }); - final credential = OathCredential.fromJson(result); - if (update && mounted) { - state = state!.toList()..add(OathPair(credential, null)); - if (!requireTouch && credential.oathType == OathType.totp) { - await calculate(credential); - } - } - return credential; - } - - Future renameAccount( - OathCredential credential, - String? issuer, - String name, - ) async { - final result = await _session.command('rename', target: [ - 'accounts', - credential.id, - ], params: { - 'issuer': issuer, - 'name': name, - }); - String credentialId = result['credential_id']; - if (mounted) { - final newState = state!.toList(); - final index = newState.indexWhere((e) => e.credential == credential); - final oldPair = newState.removeAt(index); - newState.add(OathPair( - credential.copyWith(id: credentialId, issuer: issuer, name: name), - oldPair.code, - )); - state = newState; - } - } - - Future deleteAccount(OathCredential credential) async { - await _session.command('delete', target: ['accounts', credential.id]); - if (mounted) { - state = state!.toList()..removeWhere((e) => e.credential == credential); - } - } - - refresh() async { - if (_locked) return; - log.config('refreshing credentials...'); - var result = await _session.command('calculate_all', target: ['accounts']); - log.config('Entries', jsonEncode(result)); - - final pairs = []; - 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']); - pairs.add(OathPair(credential, code)); - } - - if (mounted) { - final current = state?.toList() ?? []; - for (var pair in pairs) { - final i = - current.indexWhere((e) => e.credential.id == pair.credential.id); - if (i < 0) { - current.add(pair); - } else if (pair.code != null) { - current[i] = current[i].copyWith(code: pair.code); - } - } - state = current; - _scheduleRefresh(); - } - } - - _scheduleRefresh() { - _timer?.cancel(); - if (_locked) return; - if (state == null) { - refresh(); - } else if (mounted) { - final expirations = (state ?? []) - .where((pair) => - pair.credential.oathType == OathType.totp && - !pair.credential.touchRequired) - .map((e) => e.code) - .whereType() - .map((e) => e.validTo); - if (expirations.isEmpty) { - _timer = null; - } else { - final earliest = expirations.reduce(min) * 1000; - final now = DateTime.now().millisecondsSinceEpoch; - if (earliest < now) { - refresh(); - } else { - _timer = Timer(Duration(milliseconds: earliest - now), refresh); - } - } - } - } + Future calculate(OathCredential credential); + Future addAccount(Uri otpauth, {bool requireTouch = false}); + Future renameAccount( + OathCredential credential, String? issuer, String name); + Future deleteAccount(OathCredential credential); } final favoritesProvider =