diff --git a/lib/app/state.dart b/lib/app/state.dart index 2c28cc1c..1ccb1cc2 100755 --- a/lib/app/state.dart +++ b/lib/app/state.dart @@ -125,7 +125,7 @@ final menuActionsProvider = Provider.autoDispose>((ref) { }); abstract class QrScanner { - Future scanQr(); + Future scanQr([String? imageData]); } final qrScannerProvider = Provider( diff --git a/lib/desktop/qr_scanner.dart b/lib/desktop/qr_scanner.dart index 93b1ba59..fe3b2ee8 100755 --- a/lib/desktop/qr_scanner.dart +++ b/lib/desktop/qr_scanner.dart @@ -9,8 +9,8 @@ class RpcQrScanner implements QrScanner { RpcQrScanner(this._rpc); @override - Future scanQr() async { - final result = await _rpc.command('qr', []); + Future scanQr([String? imageData]) async { + final result = await _rpc.command('qr', [], params: {'image': imageData}); return result['result']; } } diff --git a/lib/oath/views/add_account_page.dart b/lib/oath/views/add_account_page.dart index ded7d9e9..f65b896b 100755 --- a/lib/oath/views/add_account_page.dart +++ b/lib/oath/views/add_account_page.dart @@ -1,4 +1,5 @@ import 'dart:math'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -7,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/state.dart'; import '../../app/models.dart'; import '../../app/views/responsive_dialog.dart'; +import '../../widgets/file_drop_target.dart'; import '../models.dart'; import '../state.dart'; import 'utils.dart'; @@ -109,196 +111,215 @@ class _OathAddAccountPageState extends ConsumerState { return ResponsiveDialog( title: const Text('Add account'), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Account details', - style: Theme.of(context).textTheme.headline6, - ), - TextField( - controller: _issuerController, - autofocus: true, - enabled: issuerRemaining > 0, - maxLength: max(issuerRemaining, 1), - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Issuer (optional)', - helperText: '', // Prevents dialog resizing when enabled = false + child: FileDropTarget( + onFileDropped: (fileData) async { + if (qrScanner != null) { + final b64Image = base64Encode(fileData); + final otpauth = await qrScanner.scanQr(b64Image); + final data = CredentialData.fromUri(Uri.parse(otpauth)); + setState(() { + _issuerController.text = data.issuer ?? ''; + _accountController.text = data.name; + _secretController.text = data.secret; + _oathType = data.oathType; + _hashAlgorithm = data.hashAlgorithm; + _periodController.text = '${data.period}'; + _digits = data.digits; + _qrState = _QrScanState.success; + }); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Account details', + style: Theme.of(context).textTheme.headline6, ), - onChanged: (value) { - setState(() { - // Update maxlengths - }); - }, - ), - TextField( - controller: _accountController, - maxLength: nameRemaining, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Account name', - helperText: '', // Prevents dialog resizing when enabled = false - ), - onChanged: (value) { - setState(() { - // Update maxlengths - }); - }, - ), - TextField( - controller: _secretController, - inputFormatters: [ - FilteringTextInputFormatter.allow(_secretFormatterPattern) - ], - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: 'Secret key', - errorText: _validateSecretLength && !secretLengthValid - ? 'Invalid length' - : null), - enabled: _qrState != _QrScanState.success, - onChanged: (value) { - setState(() { - _validateSecretLength = false; - }); - }, - ), - if (qrScanner != null) - Padding( - padding: const EdgeInsets.only(top: 24.0), - child: Row( - children: [ - OutlinedButton.icon( - onPressed: () { - _scanQrCode(qrScanner); - }, - icon: const Icon(Icons.qr_code), - label: const Text('Scan QR code'), - ), - const SizedBox(width: 8.0), - ..._buildQrStatus(), - ], + TextField( + controller: _issuerController, + autofocus: true, + enabled: issuerRemaining > 0, + maxLength: max(issuerRemaining, 1), + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Issuer (optional)', + helperText: '', // Prevents dialog resizing when enabled = false ), + onChanged: (value) { + setState(() { + // Update maxlengths + }); + }, ), - const Divider(), - Text( - 'Options', - style: Theme.of(context).textTheme.headline6, - ), - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 4.0, - runSpacing: 8.0, - children: [ - FilterChip( - label: const Text('Require touch'), - selected: _touch, - onSelected: (value) { - setState(() { - _touch = value; - }); - }, + TextField( + controller: _accountController, + maxLength: nameRemaining, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Account name', + helperText: '', // Prevents dialog resizing when enabled = false ), - Chip( - label: DropdownButtonHideUnderline( - child: DropdownButton( - value: _oathType, - isDense: true, - underline: null, - items: OathType.values - .map((e) => DropdownMenuItem( - value: e, - child: Text(e.name.toUpperCase()), - )) - .toList(), - onChanged: _qrState != _QrScanState.success - ? (type) { - setState(() { - _oathType = type ?? OathType.totp; - }); - } - : null, - ), + onChanged: (value) { + setState(() { + // Update maxlengths + }); + }, + ), + TextField( + controller: _secretController, + inputFormatters: [ + FilteringTextInputFormatter.allow(_secretFormatterPattern) + ], + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'Secret key', + errorText: _validateSecretLength && !secretLengthValid + ? 'Invalid length' + : null), + enabled: _qrState != _QrScanState.success, + onChanged: (value) { + setState(() { + _validateSecretLength = false; + }); + }, + ), + if (qrScanner != null) + Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: () { + _scanQrCode(qrScanner); + }, + icon: const Icon(Icons.qr_code), + label: const Text('Scan QR code'), + ), + const SizedBox(width: 8.0), + ..._buildQrStatus(), + ], ), ), - Chip( - label: DropdownButtonHideUnderline( - child: DropdownButton( - value: _hashAlgorithm, - isDense: true, - underline: null, - items: HashAlgorithm.values - .map((e) => DropdownMenuItem( - value: e, - child: Text(e.name.toUpperCase()), - )) - .toList(), - onChanged: _qrState != _QrScanState.success - ? (type) { - setState(() { - _hashAlgorithm = type ?? HashAlgorithm.sha1; - }); - } - : null, - ), + const Divider(), + Text( + 'Options', + style: Theme.of(context).textTheme.headline6, + ), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4.0, + runSpacing: 8.0, + children: [ + FilterChip( + label: const Text('Require touch'), + selected: _touch, + onSelected: (value) { + setState(() { + _touch = value; + }); + }, ), - ), - if (_oathType == OathType.totp) Chip( label: DropdownButtonHideUnderline( - child: DropdownButton( - value: - int.tryParse(_periodController.text) ?? defaultPeriod, + child: DropdownButton( + value: _oathType, isDense: true, underline: null, - items: [20, 30, 45, 60] + items: OathType.values .map((e) => DropdownMenuItem( value: e, - child: Text('$e sec'), + child: Text(e.name.toUpperCase()), )) .toList(), onChanged: _qrState != _QrScanState.success - ? (period) { + ? (type) { setState(() { - _periodController.text = - '${period ?? defaultPeriod}'; + _oathType = type ?? OathType.totp; }); } : null, ), ), ), - Chip( - label: DropdownButtonHideUnderline( - child: DropdownButton( - value: _digits, - isDense: true, - underline: null, - items: [6, 7, 8] - .map((e) => DropdownMenuItem( - value: e, - child: Text('$e digits'), - )) - .toList(), - onChanged: _qrState != _QrScanState.success - ? (digits) { - setState(() { - _digits = digits ?? defaultDigits; - }); - } - : null, + Chip( + label: DropdownButtonHideUnderline( + child: DropdownButton( + value: _hashAlgorithm, + isDense: true, + underline: null, + items: HashAlgorithm.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.name.toUpperCase()), + )) + .toList(), + onChanged: _qrState != _QrScanState.success + ? (type) { + setState(() { + _hashAlgorithm = type ?? HashAlgorithm.sha1; + }); + } + : null, + ), ), ), - ), - ], - ), - ] - .map((e) => Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: e, - )) - .toList(), + if (_oathType == OathType.totp) + Chip( + label: DropdownButtonHideUnderline( + child: DropdownButton( + value: int.tryParse(_periodController.text) ?? + defaultPeriod, + isDense: true, + underline: null, + items: [20, 30, 45, 60] + .map((e) => DropdownMenuItem( + value: e, + child: Text('$e sec'), + )) + .toList(), + onChanged: _qrState != _QrScanState.success + ? (period) { + setState(() { + _periodController.text = + '${period ?? defaultPeriod}'; + }); + } + : null, + ), + ), + ), + Chip( + label: DropdownButtonHideUnderline( + child: DropdownButton( + value: _digits, + isDense: true, + underline: null, + items: [6, 7, 8] + .map((e) => DropdownMenuItem( + value: e, + child: Text('$e digits'), + )) + .toList(), + onChanged: _qrState != _QrScanState.success + ? (digits) { + setState(() { + _digits = digits ?? defaultDigits; + }); + } + : null, + ), + ), + ), + ], + ), + ] + .map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: e, + )) + .toList(), + ), ), actions: [ TextButton( diff --git a/lib/widgets/file_drop_target.dart b/lib/widgets/file_drop_target.dart new file mode 100755 index 00000000..5b5bb307 --- /dev/null +++ b/lib/widgets/file_drop_target.dart @@ -0,0 +1,59 @@ +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/material.dart'; + +class FileDropTarget extends StatefulWidget { + final Widget child; + final Function(List filedata) onFileDropped; + final Widget? overlay; + + const FileDropTarget({ + Key? key, + required this.child, + required this.onFileDropped, + this.overlay, + }) : super(key: key); + + @override + State createState() => _FileDropTargetState(); +} + +class _FileDropTargetState extends State { + bool _hovering = false; + + Widget _buildDefaultOverlay() => Positioned.fill( + child: Container( + color: Colors.blue.withOpacity(0.4), + child: Icon( + Icons.upload_file, + size: 200, + color: Colors.black.withOpacity(0.6), + ), + ), + ); + + @override + Widget build(BuildContext context) => DropTarget( + onDragEntered: (_) { + setState(() { + _hovering = true; + }); + }, + onDragExited: (_) { + setState(() { + _hovering = false; + }); + }, + onDragDone: (details) async { + for (final file in details.files) { + widget.onFileDropped(await file.readAsBytes()); + } + }, + child: Stack( + alignment: Alignment.center, + children: [ + widget.child, + if (_hovering) widget.overlay ?? _buildDefaultOverlay(), + ], + ), + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index 57878d11..3abd7111 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: json_annotation: ^4.4.0 freezed_annotation: ^1.0.0 window_manager: ^0.2.0 + desktop_drop: ^0.3.3 dev_dependencies: flutter_test: diff --git a/ykman-rpc/rpc/device.py b/ykman-rpc/rpc/device.py index f3adbc5a..a78988d4 100644 --- a/ykman-rpc/rpc/device.py +++ b/ykman-rpc/rpc/device.py @@ -110,7 +110,7 @@ class RootNode(RpcNode): @action(closes_child=False) def qr(self, params, event, signal): - return dict(result=scan_qr()) + return dict(result=scan_qr(params.get("image"))) def _id_from_fingerprint(fp): diff --git a/ykman-rpc/rpc/qr.py b/ykman-rpc/rpc/qr.py index b74b514b..301701e9 100644 --- a/ykman-rpc/rpc/qr.py +++ b/ykman-rpc/rpc/qr.py @@ -1,14 +1,20 @@ import mss import zxingcpp +import base64 +import io from PIL import Image -def scan_qr(): - with mss.mss() as sct: - monitor = sct.monitors[0] # 0 is the special "all monitors" value. - sct_img = sct.grab(monitor) # mss format - img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") - +def scan_qr(image_data = None): + if (image_data): + msg = base64.b64decode(image_data) + buf = io.BytesIO(msg) + img = Image.open(buf) + else: + with mss.mss() as sct: + monitor = sct.monitors[0] # 0 is the special "all monitors" value. + sct_img = sct.grab(monitor) # mss format + img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") result = zxingcpp.read_barcode(img) if result.valid: return result.text