Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 18 additions & 15 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:desktop_window_bootstrap/desktop_window_bootstrap.dart';
import 'src/app_bootstrap.dart';
import 'src/core/layout/app_layout.dart';
import 'src/core/motion/onboarding_motion.dart';
import 'src/core/security/wallet_lock_controller.dart';
import 'src/core/theme/app_theme_host.dart';
import 'src/core/theme/legacy_material_theme.dart';
import 'src/core/widgets/network_fallback_toast.dart';
Expand Down Expand Up @@ -591,21 +592,23 @@ class ZcashWalletApp extends ConsumerWidget {
// (buttons, TextFields) win the gesture arena first, keeping
// focused buttons focused when re-clicked.
child: _RpcEndpointFailoverToastListener(
child: DesktopWindowTitlebarSafeArea(
child: GestureDetector(
onTap: () {
// Leaf-only: skip when the primary focus is a
// `FocusScopeNode` rather than a concrete `FocusNode`.
// Unfocusing the scope itself strips the scope's
// "most-recently-focused child" memory, which leaves the
// next Tab with no deterministic starting point.
final primary = FocusManager.instance.primaryFocus;
if (primary != null && primary is! FocusScopeNode) {
primary.unfocus();
}
},
behavior: HitTestBehavior.translucent,
child: child!,
child: AutoLockObserver(
child: DesktopWindowTitlebarSafeArea(
child: GestureDetector(
onTap: () {
// Leaf-only: skip when the primary focus is a
// `FocusScopeNode` rather than a concrete `FocusNode`.
// Unfocusing the scope itself strips the scope's
// "most-recently-focused child" memory, which leaves the
// next Tab with no deterministic starting point.
final primary = FocusManager.instance.primaryFocus;
if (primary != null && primary is! FocusScopeNode) {
primary.unfocus();
}
},
behavior: HitTestBehavior.translucent,
child: child!,
),
),
),
),
Expand Down
11 changes: 8 additions & 3 deletions lib/src/core/layout/app_main_sidebar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import '../../providers/account_provider.dart';
import '../../providers/app_security_provider.dart';
import '../../providers/sync_provider.dart';
import '../profile_pictures.dart';
import '../security/wallet_lock_controller.dart';
import '../theme/app_theme.dart';
import '../widgets/app_icon.dart';
import '../widgets/app_profile_picture.dart';
Expand Down Expand Up @@ -44,12 +45,16 @@ class _AppMainSidebarState extends ConsumerState<AppMainSidebar> {
});

try {
securityNotifier.lock();
accountNotifier.clearSensitiveStateForLock();
final lockFuture = lockWalletSession(
securityNotifier: securityNotifier,
accountNotifier: accountNotifier,
syncNotifier: syncNotifier,
awaitSync: true,
);
if (mounted) {
context.go('/unlock');
}
await syncNotifier.clearSensitiveStateForLock();
await lockFuture;
} finally {
if (mounted) {
setState(() {
Expand Down
91 changes: 91 additions & 0 deletions lib/src/core/security/wallet_lock_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import 'dart:async';

import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../../main.dart' show log;
import '../../providers/account_provider.dart';
import '../../providers/app_security_provider.dart';
import '../../providers/sync_provider.dart';

const Duration kAutoLockBackgroundTimeout = Duration(minutes: 10);

bool shouldAutoLock({
required DateTime? hiddenAt,
required DateTime now,
Duration threshold = kAutoLockBackgroundTimeout,
}) {
if (hiddenAt == null) return false;
return now.difference(hiddenAt) >= threshold;
}

Future<void> lockWalletSession({
required AppSecurityNotifier securityNotifier,
required AccountNotifier accountNotifier,
required SyncNotifier syncNotifier,
bool awaitSync = false,
}) async {
securityNotifier.lock();
accountNotifier.clearSensitiveStateForLock();
final syncFuture = syncNotifier.clearSensitiveStateForLock();
if (awaitSync) {
await syncFuture;
} else {
unawaited(syncFuture);
}
}

class AutoLockObserver extends ConsumerStatefulWidget {
const AutoLockObserver({super.key, required this.child});

final Widget child;

@override
ConsumerState<AutoLockObserver> createState() => _AutoLockObserverState();
}

class _AutoLockObserverState extends ConsumerState<AutoLockObserver> {
AppLifecycleListener? _listener;
DateTime? _hiddenAt;

@override
void initState() {
super.initState();
_listener = AppLifecycleListener(onHide: _onHide, onShow: _onShow);
}

@override
void dispose() {
_listener?.dispose();
_listener = null;
super.dispose();
}

void _onHide() {
final security = ref.read(appSecurityProvider);
if (!security.isUnlocked) return;
_hiddenAt = DateTime.now();
}

void _onShow() {
final hiddenAt = _hiddenAt;
_hiddenAt = null;
if (hiddenAt == null) return;
final security = ref.read(appSecurityProvider);
if (!security.isUnlocked) return;
final now = DateTime.now();
if (!shouldAutoLock(hiddenAt: hiddenAt, now: now)) return;
Comment thread
Ryz0nd marked this conversation as resolved.
Outdated
log(
'AutoLock: hidden for ${now.difference(hiddenAt)}, '
'locking wallet session',
);
lockWalletSession(
securityNotifier: ref.read(appSecurityProvider.notifier),
accountNotifier: ref.read(accountProvider.notifier),
syncNotifier: ref.read(syncProvider.notifier),
);
}

@override
Widget build(BuildContext context) => widget.child;
}
Loading