✨ Windows auto update
This commit is contained in:
		| @@ -2,6 +2,7 @@ import 'dart:async'; | |||||||
| import 'dart:developer'; | import 'dart:developer'; | ||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
|  |  | ||||||
|  | import 'package:archive/archive.dart'; | ||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| @@ -10,6 +11,9 @@ import 'package:flutter_app_update/update_model.dart'; | |||||||
| import 'package:island/widgets/content/markdown.dart'; | import 'package:island/widgets/content/markdown.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:package_info_plus/package_info_plus.dart'; | import 'package:package_info_plus/package_info_plus.dart'; | ||||||
|  | import 'package:path_provider/path_provider.dart'; | ||||||
|  | import 'package:path/path.dart' as path; | ||||||
|  | import 'package:process_run/process_run.dart'; | ||||||
| import 'package:collection/collection.dart'; // Added for firstWhereOrNull | import 'package:collection/collection.dart'; // Added for firstWhereOrNull | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| import 'package:url_launcher/url_launcher.dart'; | import 'package:url_launcher/url_launcher.dart'; | ||||||
| @@ -180,9 +184,13 @@ class UpdateService { | |||||||
|       useRootNavigator: true, |       useRootNavigator: true, | ||||||
|       builder: (ctx) { |       builder: (ctx) { | ||||||
|         String? androidUpdateUrl; |         String? androidUpdateUrl; | ||||||
|  |         String? windowsUpdateUrl; | ||||||
|         if (Platform.isAndroid) { |         if (Platform.isAndroid) { | ||||||
|           androidUpdateUrl = _getAndroidUpdateUrl(release.assets); |           androidUpdateUrl = _getAndroidUpdateUrl(release.assets); | ||||||
|         } |         } | ||||||
|  |         if (Platform.isWindows) { | ||||||
|  |           windowsUpdateUrl = _getWindowsUpdateUrl(); | ||||||
|  |         } | ||||||
|         return _UpdateSheet( |         return _UpdateSheet( | ||||||
|           release: release, |           release: release, | ||||||
|           onOpen: () async { |           onOpen: () async { | ||||||
| @@ -192,6 +200,7 @@ class UpdateService { | |||||||
|             } |             } | ||||||
|           }, |           }, | ||||||
|           androidUpdateUrl: androidUpdateUrl, |           androidUpdateUrl: androidUpdateUrl, | ||||||
|  |           windowsUpdateUrl: windowsUpdateUrl, | ||||||
|           useProxy: useProxy, // Pass the useProxy flag |           useProxy: useProxy, // Pass the useProxy flag | ||||||
|         ); |         ); | ||||||
|       }, |       }, | ||||||
| @@ -220,6 +229,261 @@ class UpdateService { | |||||||
|     return null; |     return null; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   String _getWindowsUpdateUrl() { | ||||||
|  |     return 'https://fs.solsynth.dev/d/official/solian/build-output-windows-installer.zip'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /// Downloads the Windows installer ZIP file | ||||||
|  |   Future<String?> _downloadWindowsInstaller(String url) async { | ||||||
|  |     try { | ||||||
|  |       log('[Update] Starting Windows installer download from: $url'); | ||||||
|  |  | ||||||
|  |       final tempDir = await getTemporaryDirectory(); | ||||||
|  |       final fileName = | ||||||
|  |           'solian-installer-${DateTime.now().millisecondsSinceEpoch}.zip'; | ||||||
|  |       final filePath = path.join(tempDir.path, fileName); | ||||||
|  |  | ||||||
|  |       final response = await _dio.download( | ||||||
|  |         url, | ||||||
|  |         filePath, | ||||||
|  |         onReceiveProgress: (received, total) { | ||||||
|  |           if (total != -1) { | ||||||
|  |             log( | ||||||
|  |               '[Update] Download progress: ${(received / total * 100).toStringAsFixed(1)}%', | ||||||
|  |             ); | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       if (response.statusCode == 200) { | ||||||
|  |         log('[Update] Windows installer downloaded successfully to: $filePath'); | ||||||
|  |         return filePath; | ||||||
|  |       } else { | ||||||
|  |         log( | ||||||
|  |           '[Update] Failed to download Windows installer. Status: ${response.statusCode}', | ||||||
|  |         ); | ||||||
|  |         return null; | ||||||
|  |       } | ||||||
|  |     } catch (e) { | ||||||
|  |       log('[Update] Error downloading Windows installer: $e'); | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /// Extracts the ZIP file to a temporary directory | ||||||
|  |   Future<String?> _extractWindowsInstaller(String zipPath) async { | ||||||
|  |     try { | ||||||
|  |       log('[Update] Extracting Windows installer from: $zipPath'); | ||||||
|  |  | ||||||
|  |       final tempDir = await getTemporaryDirectory(); | ||||||
|  |       final extractDir = path.join( | ||||||
|  |         tempDir.path, | ||||||
|  |         'solian-installer-${DateTime.now().millisecondsSinceEpoch}', | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       final zipFile = File(zipPath); | ||||||
|  |       final bytes = await zipFile.readAsBytes(); | ||||||
|  |       final archive = ZipDecoder().decodeBytes(bytes); | ||||||
|  |  | ||||||
|  |       for (final file in archive) { | ||||||
|  |         final filename = file.name; | ||||||
|  |         if (file.isFile) { | ||||||
|  |           final data = file.content as List<int>; | ||||||
|  |           final filePath = path.join(extractDir, filename); | ||||||
|  |           await Directory(path.dirname(filePath)).create(recursive: true); | ||||||
|  |           await File(filePath).writeAsBytes(data); | ||||||
|  |         } else { | ||||||
|  |           final dirPath = path.join(extractDir, filename); | ||||||
|  |           await Directory(dirPath).create(recursive: true); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       log('[Update] Windows installer extracted successfully to: $extractDir'); | ||||||
|  |       return extractDir; | ||||||
|  |     } catch (e) { | ||||||
|  |       log('[Update] Error extracting Windows installer: $e'); | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /// Runs the setup.exe file | ||||||
|  |   Future<bool> _runWindowsInstaller(String extractDir) async { | ||||||
|  |     try { | ||||||
|  |       log('[Update] Running Windows installer from: $extractDir'); | ||||||
|  |  | ||||||
|  |       final setupExePath = path.join(extractDir, 'setup.exe'); | ||||||
|  |  | ||||||
|  |       if (!await File(setupExePath).exists()) { | ||||||
|  |         log('[Update] setup.exe not found in extracted directory'); | ||||||
|  |         return false; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       final shell = Shell(); | ||||||
|  |       final results = await shell.run(setupExePath); | ||||||
|  |       final result = results.first; | ||||||
|  |  | ||||||
|  |       if (result.exitCode == 0) { | ||||||
|  |         log('[Update] Windows installer completed successfully'); | ||||||
|  |         return true; | ||||||
|  |       } else { | ||||||
|  |         log( | ||||||
|  |           '[Update] Windows installer failed with exit code: ${result.exitCode}', | ||||||
|  |         ); | ||||||
|  |         log('[Update] Installer output: ${result.stdout}'); | ||||||
|  |         log('[Update] Installer errors: ${result.stderr}'); | ||||||
|  |         return false; | ||||||
|  |       } | ||||||
|  |     } catch (e) { | ||||||
|  |       log('[Update] Error running Windows installer: $e'); | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /// Performs automatic Windows update: download, extract, and install | ||||||
|  |   Future<void> _performAutomaticWindowsUpdate( | ||||||
|  |     BuildContext context, | ||||||
|  |     String url, | ||||||
|  |   ) async { | ||||||
|  |     if (!context.mounted) return; | ||||||
|  |  | ||||||
|  |     // Show progress dialog | ||||||
|  |     showDialog( | ||||||
|  |       context: context, | ||||||
|  |       barrierDismissible: false, | ||||||
|  |       builder: | ||||||
|  |           (context) => const AlertDialog( | ||||||
|  |             title: Text('Installing Update'), | ||||||
|  |             content: Column( | ||||||
|  |               mainAxisSize: MainAxisSize.min, | ||||||
|  |               children: [ | ||||||
|  |                 CircularProgressIndicator(), | ||||||
|  |                 SizedBox(height: 16), | ||||||
|  |                 Text('Downloading installer...'), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       // Step 1: Download | ||||||
|  |       if (!context.mounted) return; | ||||||
|  |       Navigator.of(context).pop(); // Close progress dialog | ||||||
|  |       showDialog( | ||||||
|  |         context: context, | ||||||
|  |         barrierDismissible: false, | ||||||
|  |         builder: | ||||||
|  |             (context) => const AlertDialog( | ||||||
|  |               title: Text('Installing Update'), | ||||||
|  |               content: Column( | ||||||
|  |                 mainAxisSize: MainAxisSize.min, | ||||||
|  |                 children: [ | ||||||
|  |                   CircularProgressIndicator(), | ||||||
|  |                   SizedBox(height: 16), | ||||||
|  |                   Text('Extracting installer...'), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       final zipPath = await _downloadWindowsInstaller(url); | ||||||
|  |       if (zipPath == null) { | ||||||
|  |         if (!context.mounted) return; | ||||||
|  |         Navigator.of(context).pop(); | ||||||
|  |         _showErrorDialog(context, 'Failed to download installer'); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Step 2: Extract | ||||||
|  |       if (!context.mounted) return; | ||||||
|  |       Navigator.of(context).pop(); // Close progress dialog | ||||||
|  |       showDialog( | ||||||
|  |         context: context, | ||||||
|  |         barrierDismissible: false, | ||||||
|  |         builder: | ||||||
|  |             (context) => const AlertDialog( | ||||||
|  |               title: Text('Installing Update'), | ||||||
|  |               content: Column( | ||||||
|  |                 mainAxisSize: MainAxisSize.min, | ||||||
|  |                 children: [ | ||||||
|  |                   CircularProgressIndicator(), | ||||||
|  |                   SizedBox(height: 16), | ||||||
|  |                   Text('Running installer...'), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       final extractDir = await _extractWindowsInstaller(zipPath); | ||||||
|  |       if (extractDir == null) { | ||||||
|  |         if (!context.mounted) return; | ||||||
|  |         Navigator.of(context).pop(); | ||||||
|  |         _showErrorDialog(context, 'Failed to extract installer'); | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Step 3: Run installer | ||||||
|  |       if (!context.mounted) return; | ||||||
|  |       Navigator.of(context).pop(); // Close progress dialog | ||||||
|  |  | ||||||
|  |       final success = await _runWindowsInstaller(extractDir); | ||||||
|  |       if (!context.mounted) return; | ||||||
|  |  | ||||||
|  |       if (success) { | ||||||
|  |         showDialog( | ||||||
|  |           context: context, | ||||||
|  |           builder: | ||||||
|  |               (context) => AlertDialog( | ||||||
|  |                 title: const Text('Update Complete'), | ||||||
|  |                 content: const Text( | ||||||
|  |                   'The application has been updated successfully. Please restart the application.', | ||||||
|  |                 ), | ||||||
|  |                 actions: [ | ||||||
|  |                   TextButton( | ||||||
|  |                     onPressed: () { | ||||||
|  |                       Navigator.of(context).pop(); | ||||||
|  |                       // Close the update sheet | ||||||
|  |                       Navigator.of(context).pop(); | ||||||
|  |                     }, | ||||||
|  |                     child: const Text('OK'), | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |         ); | ||||||
|  |       } else { | ||||||
|  |         _showErrorDialog(context, 'Failed to run installer'); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Cleanup | ||||||
|  |       try { | ||||||
|  |         await File(zipPath).delete(); | ||||||
|  |         await Directory(extractDir).delete(recursive: true); | ||||||
|  |       } catch (e) { | ||||||
|  |         log('[Update] Error cleaning up temporary files: $e'); | ||||||
|  |       } | ||||||
|  |     } catch (e) { | ||||||
|  |       if (!context.mounted) return; | ||||||
|  |       Navigator.of(context).pop(); // Close any open dialogs | ||||||
|  |       _showErrorDialog(context, 'Update failed: $e'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _showErrorDialog(BuildContext context, String message) { | ||||||
|  |     showDialog( | ||||||
|  |       context: context, | ||||||
|  |       builder: | ||||||
|  |           (context) => AlertDialog( | ||||||
|  |             title: const Text('Update Failed'), | ||||||
|  |             content: Text(message), | ||||||
|  |             actions: [ | ||||||
|  |               TextButton( | ||||||
|  |                 onPressed: () => Navigator.of(context).pop(), | ||||||
|  |                 child: const Text('OK'), | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   /// Fetch the latest release info from GitHub. |   /// Fetch the latest release info from GitHub. | ||||||
|   /// Public so other screens (e.g., About) can manually trigger update checks. |   /// Public so other screens (e.g., About) can manually trigger update checks. | ||||||
|   Future<GithubReleaseInfo?> fetchLatestRelease() async { |   Future<GithubReleaseInfo?> fetchLatestRelease() async { | ||||||
| @@ -277,10 +541,12 @@ class _UpdateSheet extends StatefulWidget { | |||||||
|     required this.release, |     required this.release, | ||||||
|     required this.onOpen, |     required this.onOpen, | ||||||
|     this.androidUpdateUrl, |     this.androidUpdateUrl, | ||||||
|  |     this.windowsUpdateUrl, | ||||||
|     this.useProxy = false, |     this.useProxy = false, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   final String? androidUpdateUrl; |   final String? androidUpdateUrl; | ||||||
|  |   final String? windowsUpdateUrl; | ||||||
|   final bool useProxy; |   final bool useProxy; | ||||||
|   final GithubReleaseInfo release; |   final GithubReleaseInfo release; | ||||||
|   final VoidCallback onOpen; |   final VoidCallback onOpen; | ||||||
| @@ -379,6 +645,25 @@ class _UpdateSheetState extends State<_UpdateSheet> { | |||||||
|                           label: const Text('Install update'), |                           label: const Text('Install update'), | ||||||
|                         ), |                         ), | ||||||
|                       ), |                       ), | ||||||
|  |                     if (!kIsWeb && | ||||||
|  |                         Platform.isWindows && | ||||||
|  |                         widget.windowsUpdateUrl != null) | ||||||
|  |                       Expanded( | ||||||
|  |                         child: FilledButton.icon( | ||||||
|  |                           onPressed: () { | ||||||
|  |                             // Access the UpdateService instance to call the automatic update method | ||||||
|  |                             final updateService = UpdateService( | ||||||
|  |                               useProxy: widget.useProxy, | ||||||
|  |                             ); | ||||||
|  |                             updateService._performAutomaticWindowsUpdate( | ||||||
|  |                               context, | ||||||
|  |                               widget.windowsUpdateUrl!, | ||||||
|  |                             ); | ||||||
|  |                           }, | ||||||
|  |                           icon: const Icon(Symbols.update), | ||||||
|  |                           label: const Text('Install update'), | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|                     Expanded( |                     Expanded( | ||||||
|                       child: FilledButton.icon( |                       child: FilledButton.icon( | ||||||
|                         onPressed: widget.onOpen, |                         onPressed: widget.onOpen, | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -50,7 +50,7 @@ packages: | |||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.3" |     version: "2.0.3" | ||||||
|   archive: |   archive: | ||||||
|     dependency: transitive |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
|       name: archive |       name: archive | ||||||
|       sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" |       sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" | ||||||
| @@ -1877,6 +1877,14 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.0.3" |     version: "6.0.3" | ||||||
|  |   process_run: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: process_run | ||||||
|  |       sha256: "6ec839cdd3e6de4685318e7686cd4abb523c3d3a55af0e8d32a12ae19bc66622" | ||||||
|  |       url: "https://pub.dev" | ||||||
|  |     source: hosted | ||||||
|  |     version: "1.2.4" | ||||||
|   protobuf: |   protobuf: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|   | |||||||
| @@ -132,6 +132,8 @@ dependencies: | |||||||
|   flutter_typeahead: ^5.2.0 |   flutter_typeahead: ^5.2.0 | ||||||
|   waveform_flutter: ^1.2.0 |   waveform_flutter: ^1.2.0 | ||||||
|   flutter_app_update: ^3.2.2 |   flutter_app_update: ^3.2.2 | ||||||
|  |   archive: ^4.0.7 | ||||||
|  |   process_run: ^1.2.0 | ||||||
|   firebase_crashlytics: ^5.0.1 |   firebase_crashlytics: ^5.0.1 | ||||||
|   firebase_analytics: ^12.0.1 |   firebase_analytics: ^12.0.1 | ||||||
|   material_color_utilities: ^0.11.1 |   material_color_utilities: ^0.11.1 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user