diff --git a/assets/scripts/event.py b/assets/scripts/event.py new file mode 100644 index 00000000..c217762f --- /dev/null +++ b/assets/scripts/event.py @@ -0,0 +1,14 @@ +_handlers = {} + +def listen(event_name, callback): + if event_name not in _handlers: + _handlers[event_name] = [] + _handlers[event_name].append(callback) + +def call(event_name, *args): + if event_name in _handlers: + for cb in _handlers[event_name]: + try: + cb(*args) + except Exception as e: + print(f"Error in event {event_name}: {e}") diff --git a/assets/scripts/loader.py b/assets/scripts/loader.py new file mode 100644 index 00000000..699c6fac --- /dev/null +++ b/assets/scripts/loader.py @@ -0,0 +1,17 @@ +import os +import sys + +def load_plugins(): + plugins_dir = os.path.join(sys.path[0], 'plugins') + if not os.path.exists(plugins_dir): + print(f"Plugins directory not found: {plugins_dir}") + return + + for filename in os.listdir(plugins_dir): + if filename.endswith('.py'): + module_name = filename[:-3] + try: + __import__(module_name) + print(f"Imported plugin: {module_name}") + except Exception as e: + print(f"Failed to import plugin {module_name}: {e}") diff --git a/lib/core/services/python_service.dart b/lib/core/services/python_service.dart new file mode 100644 index 00000000..a659a59b --- /dev/null +++ b/lib/core/services/python_service.dart @@ -0,0 +1,2 @@ +export 'python_service_stub.dart' + if (dart.library.io) 'python_service_native.dart'; diff --git a/lib/core/services/python_service_native.dart b/lib/core/services/python_service_native.dart new file mode 100644 index 00000000..e74ab2ba --- /dev/null +++ b/lib/core/services/python_service_native.dart @@ -0,0 +1,74 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/services.dart'; +import 'package:pocketpy/pocketpy.dart' as pkpy; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'package:logging/logging.dart'; + +pkpy.VM? _vm; +bool _isInitialized = false; + +bool isPythonAvailable() => _isInitialized; + +Future initPython() async { + if (_isInitialized) return; + + try { + Directory baseDir; + if (kIsWeb) { + Logger.root.info('[python_service] Web platform, skipping'); + return; + } else if (Platform.isAndroid || Platform.isIOS) { + baseDir = await getApplicationSupportDirectory(); + } else { + final exeDir = path.dirname(Platform.resolvedExecutable); + baseDir = Directory(exeDir); + } + + final pluginsDir = Directory(path.join(baseDir.path, 'plugins')); + if (!await pluginsDir.exists()) { + await pluginsDir.create(recursive: true); + Logger.root.info('[python_service] Created plugins directory: ${pluginsDir.path}'); + } + + final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); + final assets = manifest.listAssets(); + final scriptPaths = assets.where((asset) => asset.startsWith('assets/scripts/') && asset.endsWith('.py')).toList(); + + for (final assetPath in scriptPaths) { + final fileName = path.basename(assetPath); + final destFile = File(path.join(baseDir.path, fileName)); + final content = await rootBundle.loadString(assetPath); + await destFile.writeAsString(content); + Logger.root.info('[python_service] Wrote $fileName to ${destFile.path}'); + } + + _vm = pkpy.VM(); + + _vm!.exec('import sys'); + _vm!.exec('sys.path.insert(0, r"${baseDir.path}")'); + + _vm!.exec('import loader'); + _vm!.exec('loader.load_plugins()'); + + final out = _vm!.read_output(); + if (out.stdout.isNotEmpty) Logger.root.info('[Python stdout] ${out.stdout}'); + if (out.stderr.isNotEmpty) Logger.root.warning('[Python stderr] ${out.stderr}'); + + _isInitialized = true; + Logger.root.info('[python_service] Initialized, base dir: ${baseDir.path}'); + } catch (e) { + Logger.root.severe('[python_service] Init failed: $e'); + _isInitialized = false; + _vm = null; + } +} + +Future evalPythonCode(String code) async { + if (!_isInitialized || _vm == null) return; + _vm!.exec(code); + final out = _vm!.read_output(); + if (out.stdout.isNotEmpty) Logger.root.info('[Python stdout] ${out.stdout}'); + if (out.stderr.isNotEmpty) Logger.root.warning('[Python stderr] ${out.stderr}'); +} diff --git a/lib/core/services/python_service_stub.dart b/lib/core/services/python_service_stub.dart new file mode 100644 index 00000000..a9da65ad --- /dev/null +++ b/lib/core/services/python_service_stub.dart @@ -0,0 +1,6 @@ +// Web 平台的空实现 +Future initPython() async {} + +bool isPythonAvailable() => false; + +Future evalPythonCode(String code) async {} diff --git a/lib/main.dart b/lib/main.dart index afc53902..52c2c616 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,4 @@ -import 'dart:developer'; +import 'dart:developer' as developer; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart' hide TextDirection; @@ -38,6 +38,10 @@ import 'package:protocol_handler/protocol_handler.dart'; import 'package:island/core/services/unifiedpush_service.dart'; import 'package:media_kit/media_kit.dart'; +// 注意:不再导入 python_service + +final List _earlyLogs = []; + @pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); @@ -45,17 +49,9 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { } void main(List args) async { - // Initialize logging + Logger.root.level = Level.ALL; Logger.root.onRecord.listen((record) { - log( - [ - '[${record.time}] [${record.level}] ${record.message}', - if (record.error != null) 'Error: ${record.error}', - ?record.stackTrace, - ].join('\n'), - time: record.time, - level: record.level.value, - ); + _earlyLogs.add(record); }); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); @@ -84,7 +80,6 @@ void main(List args) async { try { await EasyLocalization.ensureInitialized(); - // Disable logs EasyLocalization.logger.enableBuildModes = []; if (kIsWeb || !Platform.isLinux) { @@ -94,9 +89,6 @@ void main(List args) async { FirebaseMessaging.onBackgroundMessage( _firebaseMessagingBackgroundHandler, ); - // Although previous if case checked this. Still check is web or not - // Otherwise the web platform will broke due to there is no Platform api on the web - // Skip crashlytics setup on debug mode to prevent unexpected report to firebase if ((kIsWeb || !Platform.isWindows) && !kDebugMode) { FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; @@ -147,12 +139,12 @@ void main(List args) async { final prefs = await SharedPreferences.getInstance(); + // 移除 Python 初始化代码 + if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) { await windowManager.ensureInitialized(); const defaultSize = Size(360, 640); - - // Get saved window size from preferences final savedSizeString = prefs.getString(kAppWindowSize); Size initialSize = defaultSize; @@ -218,6 +210,23 @@ void main(List args) async { Logger.root.info("[SplashScreen] Now hiding splash screen..."); } + Logger.root.onRecord.listen((record) { + developer.log( + record.message, + time: record.time, + level: record.level.value, + name: record.loggerName, + ); + }); + for (final record in _earlyLogs) { + developer.log( + record.message, + time: record.time, + level: record.level.value, + name: record.loggerName, + ); + } + runApp( ProviderScope( retry: (retryCount, error) { @@ -254,8 +263,7 @@ void main(List args) async { ); } -// Router will be provided through Riverpod - +// 以下是 IslandApp 等代码保持不变... final globalOverlay = GlobalKey(); final globalScaffoldMessengerKey = GlobalKey(); @@ -264,14 +272,11 @@ class IslandApp extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Make sure it's active final _ = ref.read(logsProvider); - // Theme data and prefs final theme = ref.watch(themeProvider); final settings = ref.watch(appSettingsProvider); - // Convert string theme mode to ThemeMode enum ThemeMode getThemeMode() { final themeMode = settings.themeMode ?? 'system'; switch (themeMode) { @@ -289,11 +294,9 @@ class IslandApp extends HookConsumerWidget { if (notification.data['meta']?['action_uri'] != null) { var uri = notification.data['meta']['action_uri'] as String; if (uri.startsWith('/')) { - // In-app routes final router = ref.read(routerProvider); router.push(notification.data['meta']['action_uri']); } else { - // External links launchUrlString(uri); } } @@ -304,19 +307,16 @@ class IslandApp extends HookConsumerWidget { return null; } - // When the app is opened from a terminated state. FirebaseMessaging.instance.getInitialMessage().then((message) { if (message != null) { handleMessage(message); } }); - // When the app is in the background and opened. final onMessageOpenedAppSubscription = FirebaseMessaging .onMessageOpenedApp .listen(handleMessage); - // When the app is in the foreground. final onMessageSubscription = FirebaseMessaging.onMessage.listen(( message, ) { diff --git a/lib/shared/widgets/app_startup_splash.dart b/lib/shared/widgets/app_startup_splash.dart index e9e72dcc..137d728b 100644 --- a/lib/shared/widgets/app_startup_splash.dart +++ b/lib/shared/widgets/app_startup_splash.dart @@ -9,6 +9,7 @@ import 'package:island/core/audio.dart'; import 'package:island/core/network.dart'; import 'package:island/core/services/notify.dart'; import 'package:island/core/websocket.dart'; +import 'package:island/core/services/python_service.dart' as python; const kDefaultBootstrapRetryTimeouts = [ Duration(milliseconds: 1000), @@ -98,6 +99,14 @@ class StartupSplashScreen extends HookConsumerWidget { await ref.read(websocketStateProvider.notifier).connect(); }, ), + // Python 初始化阶段 - 放在网络连接之后,推送通知之前 + _BootstrapStage( + label: 'Initializing Python scripts', + isCritical: false, + action: () async { + await python.initPython(); + }, + ), _BootstrapStage( label: 'Registering push notifications', isCritical: false, diff --git a/lib/shared/widgets/app_wrapper.dart b/lib/shared/widgets/app_wrapper.dart index aa15dde4..2e169ae2 100644 --- a/lib/shared/widgets/app_wrapper.dart +++ b/lib/shared/widgets/app_wrapper.dart @@ -44,6 +44,8 @@ import 'package:tray_manager/tray_manager.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:island/core/services/python_service.dart' as python; + const kForceShowStartupSplashForTesting = false; const kOnboardingLastShownVersion = 'app_onboarding_last_shown_version'; @@ -67,19 +69,16 @@ class AppWrapper extends HookConsumerWidget { final startupGateResolved = useState(false); final onboardingChecked = useState(false); - // Initialize progression WebSocket listener useEffect(() { ref.read(progressionWebSocketProvider); return null; }, []); - // Initialize friend status listener for toast notifications useEffect(() { ref.read(friendStatusListenerProvider); return null; }, []); - // Handle network status modal useEffect(() { bool triedOpen = false; if (!hasConnectivity && !networkStateShowing.value && !triedOpen) { @@ -147,7 +146,20 @@ class AppWrapper extends HookConsumerWidget { return null; }, [hasConnectivity, token, websocketState]); - // Initialize services and listeners + useEffect(() { + if (!kIsWeb) { + Future(() async { + await python.initPython(); + if (python.isPythonAvailable()) { + Logger.root.info("[pocketpy] Initialized from AppWrapper"); + } else { + Logger.root.info("[pocketpy] Not available (folder missing or init failed)"); + } + }); + } + return null; + }, []); + useEffect(() { final ntySubs = setupNotificationListener(context, ref); final sharingService = SharingIntentService(); @@ -199,7 +211,6 @@ class AppWrapper extends HookConsumerWidget { ref.read(rpcServerStateProvider.notifier).start(); ref.read(webAuthServerStateProvider.notifier).start(); - // Listen to special action events final composeSheetSubs = eventBus.on().listen(( event, ) { @@ -221,7 +232,6 @@ class AppWrapper extends HookConsumerWidget { if (ctx.mounted) _showThoughtSheet(ctx, event); }); - // Web auth request listener final webAuthSubs = eventBus.on().listen((event) { final ctx = ref.read(routerProvider).navigatorKey.currentContext!; if (ctx.mounted) _showWebAuthSheet(ctx, event); @@ -466,23 +476,16 @@ class AppWrapper extends HookConsumerWidget { void _handleDeepLink(Uri uri, WidgetRef ref, BuildContext context) async { String path = '/${uri.host}${uri.path}'; - // Web auth deep links for native apps: - // 1) Request challenge: - // solian://auth/web?app=MyApp&redirect_uri=myapp://auth-callback - // 2) Exchange signed challenge: - // solian://auth/web?signed_challenge=...&redirect_uri=myapp://auth-callback if (path == '/auth/web') { await _handleProtocolWebAuth(uri, ref, context); return; } - // Special handling for OIDC auth callback if (path == '/auth/callback' && uri.queryParameters.containsKey('token')) { final token = uri.queryParameters['token']!; setToken(ref.read(sharedPreferencesProvider), token); ref.invalidate(tokenProvider); - // Do post login tasks await performPostLogin(context, ref); if (!kIsWeb && @@ -492,9 +495,6 @@ class AppWrapper extends HookConsumerWidget { return; } - // Special handling for share intent deep links - // Share intents are handled by SharingIntentService showing a modal, - // not by routing to a page if (path == '/share') { if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { @@ -508,7 +508,6 @@ class AppWrapper extends HookConsumerWidget { return; } - // Handle NFC tag deep links: solian://phpass/ if (path.startsWith('/phpass/')) { final tagId = path.substring('/phpass/'.length); if (tagId.isNotEmpty) { @@ -517,17 +516,13 @@ class AppWrapper extends HookConsumerWidget { } } - // final router = ref.read(routerProvider); if (path == '/dashboard') { context.router.navigate(const DashboardRoute()); return; } - // Handle bottom navigation routes properly to prevent navigation bar disappearance - // These routes should navigate within the bottom navigation shell final bottomNavRoutes = ['/', '/explore', '/chat', '/realms', '/account']; if (bottomNavRoutes.contains(path)) { - // Navigate within the bottom navigation shell using go() to maintain shell context context.router.navigatePath(path); if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { @@ -541,7 +536,6 @@ class AppWrapper extends HookConsumerWidget { path, ).replace(queryParameters: uri.queryParameters).toString(); } - // For non-bottom navigation routes, use push() to navigate outside the shell context.router.navigatePath(path); if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { diff --git a/pubspec.yaml b/pubspec.yaml index 1ed0b7f5..3bc6b72a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -190,6 +190,7 @@ dependencies: health: ^13.1.4 logging: ^1.3.0 connectivity_plus: ^6.1.5 + pocketpy: ^0.8.0+2 dev_dependencies: flutter_test: @@ -228,6 +229,7 @@ flutter: - assets/images/michan/ - assets/icons/ - assets/audio/ + - assets/scripts/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images