mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 16:18:31 +08:00
619 lines
20 KiB
Dart
619 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:io' show Platform;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'core/api/api_client.dart';
|
|
import 'core/api/cookie_store.dart';
|
|
import 'core/api/api_result.dart';
|
|
import 'core/storage/app_storage.dart';
|
|
import 'core/storage/secure_storage.dart';
|
|
import 'core/security/server_certificate_trust.dart';
|
|
import 'features/auth/auth_session.dart';
|
|
import 'features/auth/login_controller.dart';
|
|
import 'features/auth/login_page.dart';
|
|
import 'features/ai_agent/agent_overlay.dart';
|
|
import 'features/shell/main_shell_page.dart';
|
|
import 'l10n/app_localizations.dart';
|
|
import 'state/api_access_notifier.dart';
|
|
import 'state/auth_notifier.dart';
|
|
import 'state/auth_state.dart';
|
|
import 'state/color_theme_notifier.dart';
|
|
import 'state/locale_notifier.dart';
|
|
import 'state/storage_providers.dart';
|
|
import 'state/theme_mode_notifier.dart';
|
|
|
|
/// Bootstrap result. Wraps the values [EasyNodeApp] needs to install on the
|
|
/// root [ProviderScope]. Building these synchronously up front keeps the
|
|
/// providers free of async initialization and lets storage be read in
|
|
/// `build` without futures.
|
|
class _Bootstrap {
|
|
_Bootstrap({
|
|
required this.appStorage,
|
|
required this.secureStorage,
|
|
required this.cookieStore,
|
|
required this.flutterSecureStorage,
|
|
required this.appVersion,
|
|
required this.initialPassword,
|
|
required this.initialAuthState,
|
|
required this.initialIpAccessDenied,
|
|
required this.certificateTrust,
|
|
});
|
|
|
|
final AppStorage appStorage;
|
|
final SecureAppStorage secureStorage;
|
|
final SessionCookieStore cookieStore;
|
|
final FlutterSecureStorage flutterSecureStorage;
|
|
final String appVersion;
|
|
final String initialPassword;
|
|
final AuthState initialAuthState;
|
|
final bool initialIpAccessDenied;
|
|
final ServerCertificateTrustStore certificateTrust;
|
|
}
|
|
|
|
class EasyNodeApp extends StatelessWidget {
|
|
const EasyNodeApp._({required _Bootstrap bootstrap}) : _b = bootstrap;
|
|
|
|
final _Bootstrap _b;
|
|
|
|
static Future<Widget> bootstrap() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final secure = const FlutterSecureStorage();
|
|
final secureWrapper = SecureAppStorage(secure);
|
|
final appStorage = AppStorage(prefs);
|
|
final cookieStore = SessionCookieStore(secureWrapper);
|
|
final certificateTrust = ServerCertificateTrustStore(secureWrapper);
|
|
final packageInfo = await PackageInfo.fromPlatform();
|
|
final appVersion = packageInfo.buildNumber.isEmpty
|
|
? packageInfo.version
|
|
: '${packageInfo.version}+${packageInfo.buildNumber}';
|
|
|
|
var initialPassword = '';
|
|
if (appStorage.savePassword) {
|
|
initialPassword =
|
|
await secureWrapper.readPassword(
|
|
appStorage.serverAddress,
|
|
appStorage.username,
|
|
) ??
|
|
'';
|
|
}
|
|
|
|
AuthState initialAuthState = AuthState.empty;
|
|
var initialIpAccessDenied = false;
|
|
final token = await secureWrapper.readToken();
|
|
final cookie = await secureWrapper.readSessionCookie();
|
|
final deviceId = await secureWrapper.readDeviceId();
|
|
final hasStoredLogin =
|
|
appStorage.serverAddress.isNotEmpty &&
|
|
appStorage.username.isNotEmpty &&
|
|
token != null &&
|
|
token.isNotEmpty &&
|
|
cookie != null &&
|
|
cookie.isNotEmpty &&
|
|
deviceId != null &&
|
|
deviceId.isNotEmpty;
|
|
|
|
if (hasStoredLogin) {
|
|
await certificateTrust.prepare(appStorage.serverAddress);
|
|
final api = ApiClient(
|
|
serverAddress: appStorage.serverAddress,
|
|
cookieStore: cookieStore,
|
|
token: token,
|
|
appVersion: appVersion,
|
|
certificateTrust: certificateTrust,
|
|
);
|
|
try {
|
|
final pubKey = await api.getPublicKey();
|
|
initialAuthState = AuthState(
|
|
session: AuthSession(
|
|
serverAddress: appStorage.serverAddress,
|
|
username: appStorage.username,
|
|
token: token,
|
|
deviceId: deviceId,
|
|
),
|
|
apiClient: api,
|
|
publicKeyPem: pubKey,
|
|
);
|
|
} on IpAccessDeniedFailure {
|
|
// The saved session can still be used from an allowed network. Keep
|
|
// the main shell behind the blocking prompt; its data requests cannot
|
|
// proceed while the IP policy is active, but showing LoginPage here
|
|
// incorrectly implies that the credentials were invalidated.
|
|
initialAuthState = AuthState(
|
|
session: AuthSession(
|
|
serverAddress: appStorage.serverAddress,
|
|
username: appStorage.username,
|
|
token: token,
|
|
deviceId: deviceId,
|
|
),
|
|
apiClient: api,
|
|
// Repositories are not used while the access-denied prompt blocks
|
|
// the UI. A non-null placeholder preserves the restored shell until
|
|
// the user exits or explicitly signs out.
|
|
publicKeyPem: '',
|
|
);
|
|
initialIpAccessDenied = true;
|
|
} catch (_) {
|
|
await secureWrapper.deleteToken();
|
|
await secureWrapper.deleteSessionCookie();
|
|
await secureWrapper.deleteDeviceId();
|
|
}
|
|
}
|
|
|
|
return EasyNodeApp._(
|
|
bootstrap: _Bootstrap(
|
|
appStorage: appStorage,
|
|
secureStorage: secureWrapper,
|
|
cookieStore: cookieStore,
|
|
flutterSecureStorage: secure,
|
|
appVersion: appVersion,
|
|
initialPassword: initialPassword,
|
|
initialAuthState: initialAuthState,
|
|
initialIpAccessDenied: initialIpAccessDenied,
|
|
certificateTrust: certificateTrust,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ProviderScope(
|
|
overrides: [
|
|
appStorageProvider.overrideWithValue(_b.appStorage),
|
|
secureStorageProvider.overrideWithValue(_b.secureStorage),
|
|
cookieStoreProvider.overrideWithValue(_b.cookieStore),
|
|
certificateTrustProvider.overrideWithValue(_b.certificateTrust),
|
|
authProvider.overrideWith(
|
|
(ref) => AuthNotifier(ref, _b.initialAuthState),
|
|
),
|
|
],
|
|
child: _AppRoot(
|
|
initialPassword: _b.initialPassword,
|
|
appVersion: _b.appVersion,
|
|
initialIpAccessDenied: _b.initialIpAccessDenied,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AppRoot extends ConsumerStatefulWidget {
|
|
const _AppRoot({
|
|
required this.initialPassword,
|
|
required this.appVersion,
|
|
required this.initialIpAccessDenied,
|
|
});
|
|
|
|
final String initialPassword;
|
|
final String appVersion;
|
|
final bool initialIpAccessDenied;
|
|
|
|
@override
|
|
ConsumerState<_AppRoot> createState() => _AppRootState();
|
|
}
|
|
|
|
class _AppRootState extends ConsumerState<_AppRoot> {
|
|
late final LoginController _loginController;
|
|
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
|
final GlobalKey<ScaffoldMessengerState> _messengerKey =
|
|
GlobalKey<ScaffoldMessengerState>();
|
|
var _ipAccessDeniedDialogShowing = false;
|
|
var _splashFinished = false;
|
|
final AgentNavigationObserver _agentNavigationObserver =
|
|
AgentNavigationObserver();
|
|
|
|
@override
|
|
void dispose() {
|
|
_agentNavigationObserver.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loginController = LoginController(
|
|
apiClientFactory: _buildAnonymousApiClient,
|
|
certificateTrust: ref.read(certificateTrustProvider),
|
|
)..onLoginSuccess(_onLoginSuccess);
|
|
// The restored client is constructed before ProviderScope exists. Bind
|
|
// its session-failure handler once the global coordinator is available.
|
|
ref
|
|
.read(authProvider)
|
|
.apiClient
|
|
?.setOnSessionFailure(_handleSessionFailure);
|
|
if (widget.initialIpAccessDenied) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
ref
|
|
.read(apiAccessProvider.notifier)
|
|
.handleSessionFailure(
|
|
IpAccessDeniedFailure('IP access denied', statusCode: 403),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _handleSessionFailure(ApiSessionFailure failure) {
|
|
return ref.read(apiAccessProvider.notifier).handleSessionFailure(failure);
|
|
}
|
|
|
|
ApiClient _buildAnonymousApiClient(String serverAddress, {String? token}) {
|
|
return ApiClient(
|
|
serverAddress: serverAddress,
|
|
cookieStore: ref.read(cookieStoreProvider),
|
|
token: token,
|
|
appVersion: widget.appVersion,
|
|
certificateTrust: ref.read(certificateTrustProvider),
|
|
);
|
|
}
|
|
|
|
Future<void> _onLoginSuccess(
|
|
AuthSession session,
|
|
String? passwordToSave,
|
|
) async {
|
|
// Public-key validation is part of the login flow, so errors still belong
|
|
// to LoginPage. Bind global session handling only after it succeeds.
|
|
final api = _buildAnonymousApiClient(
|
|
session.serverAddress,
|
|
token: session.token,
|
|
);
|
|
final pubKey = await api.getPublicKey();
|
|
api.setOnSessionFailure(_handleSessionFailure);
|
|
await ref
|
|
.read(authProvider.notifier)
|
|
.signIn(
|
|
session: session,
|
|
apiClient: api,
|
|
publicKeyPem: pubKey,
|
|
passwordToSave: passwordToSave,
|
|
);
|
|
ref.read(apiAccessProvider.notifier).reset();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final auth = ref.watch(authProvider);
|
|
final appStorage = ref.watch(appStorageProvider);
|
|
|
|
ref.listen<bool>(authProvider.select((state) => state.signedIn), (
|
|
previous,
|
|
next,
|
|
) {
|
|
if (previous == true && !next) {
|
|
_navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
|
}
|
|
});
|
|
|
|
// Show the server-provided reason after the REST coordinator signs out,
|
|
// then clear it so the same message isn't shown twice on rebuilds.
|
|
ref.listen<String?>(
|
|
apiAccessProvider.select((state) => state.signOutReason),
|
|
(_, next) {
|
|
if (next == null || next.isEmpty) return;
|
|
final messenger = _messengerKey.currentState;
|
|
if (messenger == null) return;
|
|
messenger
|
|
..clearSnackBars()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
content: Text(next),
|
|
behavior: SnackBarBehavior.floating,
|
|
duration: const Duration(seconds: 4),
|
|
),
|
|
);
|
|
// Reset on the next frame so we don't trigger another listener pass
|
|
// while the current one is still running.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
ref.read(apiAccessProvider.notifier).consumeSignOutReason();
|
|
});
|
|
},
|
|
);
|
|
|
|
ref.listen<bool>(
|
|
apiAccessProvider.select((state) => state.ipAccessDenied),
|
|
(previous, next) {
|
|
if (next && previous != true && ref.read(authProvider).signedIn) {
|
|
_showIpAccessDeniedDialog();
|
|
}
|
|
},
|
|
);
|
|
|
|
final Widget home;
|
|
if (auth.signedIn) {
|
|
home = const MainShellPage();
|
|
} else {
|
|
home = LoginPage(
|
|
controller: _loginController,
|
|
initialServerAddress: appStorage.serverAddress,
|
|
initialUsername: appStorage.username,
|
|
initialSavePassword: appStorage.savePassword,
|
|
initialPassword: widget.initialPassword,
|
|
onLoginSuccess: (_) {},
|
|
);
|
|
}
|
|
|
|
final palette = ref.watch(colorThemeProvider);
|
|
final lightColors = palette.colorsFor(Brightness.light);
|
|
final darkColors = palette.colorsFor(Brightness.dark);
|
|
|
|
return MaterialApp(
|
|
title: 'EasyNode',
|
|
debugShowCheckedModeBanner: false,
|
|
navigatorKey: _navigatorKey,
|
|
navigatorObservers: [_agentNavigationObserver],
|
|
scaffoldMessengerKey: _messengerKey,
|
|
themeMode: ref.watch(themeModeProvider),
|
|
theme: ThemeData(
|
|
useMaterial3: true,
|
|
colorScheme: ColorScheme.fromSeed(seedColor: palette.seed),
|
|
extensions: [lightColors],
|
|
appBarTheme: AppBarTheme(
|
|
centerTitle: false,
|
|
titleSpacing: 4,
|
|
titleTextStyle: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w800,
|
|
color: lightColors.text,
|
|
),
|
|
),
|
|
),
|
|
darkTheme: ThemeData(
|
|
useMaterial3: true,
|
|
brightness: Brightness.dark,
|
|
colorScheme: ColorScheme.fromSeed(
|
|
seedColor: palette.seed,
|
|
brightness: Brightness.dark,
|
|
),
|
|
extensions: [darkColors],
|
|
appBarTheme: AppBarTheme(
|
|
centerTitle: false,
|
|
titleSpacing: 4,
|
|
titleTextStyle: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w800,
|
|
color: darkColors.text,
|
|
),
|
|
),
|
|
),
|
|
locale: ref.watch(localeProvider),
|
|
localizationsDelegates: const [
|
|
AppLocalizations.delegate,
|
|
GlobalMaterialLocalizations.delegate,
|
|
GlobalWidgetsLocalizations.delegate,
|
|
GlobalCupertinoLocalizations.delegate,
|
|
],
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
localeResolutionCallback: (deviceLocale, supported) {
|
|
return AppLocalizations.resolve(deviceLocale, supported);
|
|
},
|
|
builder: (context, child) => Stack(
|
|
children: [
|
|
?child,
|
|
if (auth.signedIn && _splashFinished)
|
|
AgentGlobalOverlay(
|
|
navigationObserver: _agentNavigationObserver,
|
|
navigatorKey: _navigatorKey,
|
|
),
|
|
],
|
|
),
|
|
home: _BrandedSplashGate(
|
|
onFinished: () {
|
|
if (mounted && !_splashFinished) {
|
|
setState(() => _splashFinished = true);
|
|
}
|
|
},
|
|
child: home,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showIpAccessDeniedDialog() async {
|
|
if (_ipAccessDeniedDialogShowing || !mounted) return;
|
|
final dialogContext = _navigatorKey.currentContext;
|
|
if (dialogContext == null) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _showIpAccessDeniedDialog();
|
|
});
|
|
return;
|
|
}
|
|
_ipAccessDeniedDialogShowing = true;
|
|
final l = AppLocalizations.of(dialogContext);
|
|
await showDialog<void>(
|
|
context: dialogContext,
|
|
barrierDismissible: false,
|
|
builder: (dialogContext) => PopScope(
|
|
canPop: false,
|
|
child: AlertDialog(
|
|
title: Text(l.tr('access.ipDeniedTitle')),
|
|
content: Text(l.tr('access.ipDeniedBody')),
|
|
actions: [
|
|
if (!Platform.isIOS)
|
|
TextButton(
|
|
onPressed: _exitApp,
|
|
child: Text(l.tr('common.exitApp')),
|
|
),
|
|
FilledButton(
|
|
onPressed: () async {
|
|
Navigator.of(dialogContext).pop();
|
|
await ref
|
|
.read(apiAccessProvider.notifier)
|
|
.signOutFromIpAccessDenied();
|
|
},
|
|
child: Text(l.tr('settings.logout')),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
_ipAccessDeniedDialogShowing = false;
|
|
}
|
|
|
|
Future<void> _exitApp() async {
|
|
await SystemNavigator.pop();
|
|
}
|
|
}
|
|
|
|
class _BrandedSplashGate extends StatefulWidget {
|
|
const _BrandedSplashGate({required this.child, required this.onFinished});
|
|
|
|
final Widget child;
|
|
final VoidCallback onFinished;
|
|
|
|
@override
|
|
State<_BrandedSplashGate> createState() => _BrandedSplashGateState();
|
|
}
|
|
|
|
class _BrandedSplashGateState extends State<_BrandedSplashGate>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _controller;
|
|
late final Animation<double> _logoScale;
|
|
late final Animation<double> _logoFade;
|
|
late final Animation<Offset> _textOffset;
|
|
bool _showSplash = true;
|
|
Timer? _timer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 850),
|
|
);
|
|
_logoScale = CurvedAnimation(parent: _controller, curve: Curves.elasticOut);
|
|
_logoFade = CurvedAnimation(
|
|
parent: _controller,
|
|
curve: const Interval(0, 0.45, curve: Curves.easeOut),
|
|
);
|
|
_textOffset = Tween<Offset>(begin: const Offset(0, 0.35), end: Offset.zero)
|
|
.animate(
|
|
CurvedAnimation(
|
|
parent: _controller,
|
|
curve: const Interval(0.18, 0.7, curve: Curves.easeOutBack),
|
|
),
|
|
);
|
|
unawaited(_controller.forward());
|
|
_timer = Timer(const Duration(milliseconds: 1250), () {
|
|
if (mounted) {
|
|
setState(() => _showSplash = false);
|
|
widget.onFinished();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 420),
|
|
switchInCurve: Curves.easeOut,
|
|
switchOutCurve: Curves.easeIn,
|
|
transitionBuilder: (child, animation) {
|
|
final offset = Tween<Offset>(
|
|
begin: const Offset(0, 0.02),
|
|
end: Offset.zero,
|
|
).animate(animation);
|
|
return FadeTransition(
|
|
opacity: animation,
|
|
child: SlideTransition(position: offset, child: child),
|
|
);
|
|
},
|
|
child: _showSplash
|
|
? _BrandedSplash(
|
|
key: const ValueKey('splash'),
|
|
controller: _controller,
|
|
logoScale: _logoScale,
|
|
logoFade: _logoFade,
|
|
textOffset: _textOffset,
|
|
)
|
|
: KeyedSubtree(key: const ValueKey('home'), child: widget.child),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BrandedSplash extends StatelessWidget {
|
|
const _BrandedSplash({
|
|
super.key,
|
|
required this.controller,
|
|
required this.logoScale,
|
|
required this.logoFade,
|
|
required this.textOffset,
|
|
});
|
|
|
|
final AnimationController controller;
|
|
final Animation<double> logoScale;
|
|
final Animation<double> logoFade;
|
|
final Animation<Offset> textOffset;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
final theme = Theme.of(context);
|
|
return Scaffold(
|
|
backgroundColor: colors.surfaceContainerLowest,
|
|
body: Center(
|
|
child: AnimatedBuilder(
|
|
animation: controller,
|
|
builder: (context, _) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
FadeTransition(
|
|
opacity: logoFade,
|
|
child: ScaleTransition(
|
|
scale: logoScale,
|
|
child: Container(
|
|
width: 104,
|
|
height: 104,
|
|
clipBehavior: Clip.antiAlias,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(28),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: colors.primary.withValues(alpha: 0.18),
|
|
blurRadius: 28,
|
|
offset: const Offset(0, 12),
|
|
),
|
|
],
|
|
),
|
|
child: Image.asset(
|
|
'assets/logo_v2_01.png',
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
SlideTransition(
|
|
position: textOffset,
|
|
child: FadeTransition(
|
|
opacity: logoFade,
|
|
child: Text(
|
|
'EasyNode',
|
|
style: theme.textTheme.headlineSmall?.copyWith(
|
|
fontWeight: FontWeight.w800,
|
|
color: colors.onSurface,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|