import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import 'package:xml/xml.dart'; class UpdateService { static const String _releasesRssUrl = 'https://git.hudsonriggs.systems/LambdaBankingConglomerate/rmtPocketWatcher/releases.rss'; /// Check if the app was installed via MSIX (Windows Store-style installation) static bool isInstalledViaMsix() { if (!Platform.isWindows) return false; try { // MSIX apps are installed in WindowsApps folder final exePath = Platform.resolvedExecutable; return exePath.contains('WindowsApps') || exePath.contains('Program Files\\WindowsApps'); } catch (e) { return false; } } /// Check if an update is available by comparing current version with latest release Future checkForUpdates() async { try { // Get current app version final packageInfo = await PackageInfo.fromPlatform(); final currentVersion = packageInfo.version; if (kDebugMode) { print('Current app version: $currentVersion'); print('Checking for updates at: $_releasesRssUrl'); } // Fetch latest release from Gitea RSS feed final response = await http.get( Uri.parse(_releasesRssUrl), headers: { 'Accept': 'application/rss+xml, application/xml, text/xml', 'User-Agent': 'rmtPocketWatcher/$currentVersion', }, ); if (response.statusCode == 200) { final document = XmlDocument.parse(response.body); final items = document.findAllElements('item'); if (items.isEmpty) { if (kDebugMode) { print('No releases found in RSS feed'); } return null; } // Get the latest release (first item in RSS feed) final latestItem = items.first; final title = latestItem.findElements('title').first.innerText; final link = latestItem.findElements('link').first.innerText; final description = latestItem.findElements('description').firstOrNull?.innerText ?? ''; final pubDate = latestItem.findElements('pubDate').first.innerText; // Extract version from title (assuming format like "v1.2.3" or "Release v1.2.3") final versionMatch = RegExp(r'v?(\d+\.\d+\.\d+)').firstMatch(title); if (versionMatch == null) { if (kDebugMode) { print('Could not extract version from title: $title'); } return null; } final latestVersion = versionMatch.group(1)!; if (kDebugMode) { print('Latest release version: $latestVersion'); print('Release title: $title'); } // Compare versions if (isNewerVersion(latestVersion, currentVersion)) { return UpdateInfo( currentVersion: currentVersion, latestVersion: latestVersion, releaseUrl: link, releaseName: title, releaseNotes: description, publishedAt: _parseRssDate(pubDate), assets: _generateAssetUrls(latestVersion), // Generate expected asset URLs ); } else { if (kDebugMode) { print('App is up to date'); } return null; } } else { if (kDebugMode) { print('Failed to fetch RSS feed: ${response.statusCode}'); print('Response: ${response.body}'); } return null; } } catch (e) { if (kDebugMode) { print('Error checking for updates: $e'); } return null; } } /// Extract version number from git tag (e.g., "v1.2.3" -> "1.2.3") @visibleForTesting String extractVersionFromTag(String tag) { return tag.startsWith('v') ? tag.substring(1) : tag; } /// Compare two version strings (e.g., "1.2.3" vs "1.2.2") @visibleForTesting bool isNewerVersion(String latest, String current) { final latestParts = latest.split('.').map(int.parse).toList(); final currentParts = current.split('.').map(int.parse).toList(); // Ensure both have same number of parts while (latestParts.length < currentParts.length) { latestParts.add(0); } while (currentParts.length < latestParts.length) { currentParts.add(0); } for (int i = 0; i < latestParts.length; i++) { if (latestParts[i] > currentParts[i]) { return true; } else if (latestParts[i] < currentParts[i]) { return false; } } return false; // Versions are equal } /// Parse RSS date format to DateTime DateTime _parseRssDate(String rssDate) { try { // RSS dates are typically in RFC 2822 format // Example: "Mon, 02 Jan 2006 15:04:05 MST" return DateTime.parse(rssDate); } catch (e) { if (kDebugMode) { print('Failed to parse RSS date: $rssDate, error: $e'); } return DateTime.now(); } } /// Generate expected asset URLs based on version and platform List _generateAssetUrls(String version) { final baseUrl = 'https://git.hudsonriggs.systems/LambdaBankingConglomerate/rmtPocketWatcher/releases/download/v$version'; return [ // Windows Full Package (ZIP with all DLLs) ReleaseAsset( name: 'rmtPocketWatcher-Windows-v$version.zip', downloadUrl: '$baseUrl/rmtPocketWatcher-Windows-v$version.zip', size: 0, contentType: 'application/zip', ), // Windows Portable (self-extracting EXE) ReleaseAsset( name: 'rmtPocketWatcher-Windows-Portable-v$version.exe', downloadUrl: '$baseUrl/rmtPocketWatcher-Windows-Portable-v$version.exe', size: 0, contentType: 'application/octet-stream', ), // Windows MSIX Installer ReleaseAsset( name: 'rmtPocketWatcher-Windows-v$version.msix', downloadUrl: '$baseUrl/rmtPocketWatcher-Windows-v$version.msix', size: 0, contentType: 'application/msix', ), // Certificate for code signing verification ReleaseAsset( name: 'rmtPocketWatcher-Certificate.cer', downloadUrl: '$baseUrl/rmtPocketWatcher-Certificate.cer', size: 0, contentType: 'application/x-x509-ca-cert', ), // Android APK ReleaseAsset( name: 'rmtPocketWatcher-Android-v$version.apk', downloadUrl: '$baseUrl/rmtPocketWatcher-Android-v$version.apk', size: 0, contentType: 'application/vnd.android.package-archive', ), ]; } } class UpdateInfo { final String currentVersion; final String latestVersion; final String releaseUrl; final String releaseName; final String releaseNotes; final DateTime publishedAt; final List assets; UpdateInfo({ required this.currentVersion, required this.latestVersion, required this.releaseUrl, required this.releaseName, required this.releaseNotes, required this.publishedAt, required this.assets, }); /// Get the appropriate download asset for the current platform ReleaseAsset? getAssetForCurrentPlatform() { if (kIsWeb) return null; switch (defaultTargetPlatform) { case TargetPlatform.windows: // If installed via MSIX, prefer MSIX for updates if (UpdateService.isInstalledViaMsix()) { var msix = assets.where((asset) => asset.name.endsWith('.msix')).firstOrNull; if (msix != null) return msix; } // Prefer portable self-extracting exe for non-MSIX installs var portable = assets.where((asset) => asset.name.contains('Portable') && asset.name.endsWith('.exe')).firstOrNull; if (portable != null) return portable; // Fall back to full Windows package (ZIP) var windows = assets.where((asset) => asset.name.contains('Windows') && asset.name.endsWith('.zip')).firstOrNull; if (windows != null) return windows; // Last resort: MSIX installer return assets.where((asset) => asset.name.endsWith('.msix')).firstOrNull; case TargetPlatform.macOS: return assets.where((asset) => RegExp(r'macOS|macos|mac|darwin|\.dmg$|\.pkg$', caseSensitive: false).hasMatch(asset.name) ).firstOrNull; case TargetPlatform.linux: return assets.where((asset) => RegExp(r'Linux|linux|\.deb$|\.rpm$|\.appimage$', caseSensitive: false).hasMatch(asset.name) ).firstOrNull; case TargetPlatform.android: return assets.where((asset) => asset.name.endsWith('.apk')).firstOrNull; case TargetPlatform.iOS: return assets.where((asset) => asset.name.endsWith('.ipa')).firstOrNull; default: return null; } } } class ReleaseAsset { final String name; final String downloadUrl; final int size; final String contentType; ReleaseAsset({ required this.name, required this.downloadUrl, required this.size, required this.contentType, }); String get formattedSize { if (size < 1024) return '${size}B'; if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)}KB'; if (size < 1024 * 1024 * 1024) return '${(size / (1024 * 1024)).toStringAsFixed(1)}MB'; return '${(size / (1024 * 1024 * 1024)).toStringAsFixed(1)}GB'; } }