import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:auto_updater/auto_updater.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:plezy/utils/app_logger.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'base_shared_preferences_service.dart'; /// Service to check for new versions on GitHub /// Only enabled when ENABLE_UPDATE_CHECK build flag is set /// /// On macOS (non-Homebrew) and installed Windows: delegates to Sparkle/WinSparkle /// via auto_updater for native update dialogs and in-app installs. /// On all other platforms: falls back to GitHub API check + browser link dialog. class UpdateService { static const String _githubRepo = 'edde746/plezy'; static const String _feedUrl = 'https://cdn.jsdelivr.net/gh/edde746/plezy@appcast/appcast.xml'; static const String _keySkippedVersion = 'update_skipped_version'; static const String _keyLastCheckTime = 'update_last_check_time'; // Check cooldown: 6 hours static const Duration _checkCooldown = Duration(hours: 6); static bool _nativeUpdaterInitialized = false; /// Check if update checking is enabled via build flag static bool get isUpdateCheckEnabled { return const bool.fromEnvironment('ENABLE_UPDATE_CHECK', defaultValue: false); } /// Whether any in-app update path applies to this install. /// False inside a packaged (MSIX/Store) install: the Store owns updates and /// the package directory is read-only, so neither WinSparkle nor the GitHub /// fallback dialog has anything it can do. Gates the settings entry too, so /// no dead affordance ships. static bool get isUpdateCheckAvailable => isUpdateCheckEnabled && !PlatformDetector.isPackagedInstall(); /// Whether the native auto_updater (Sparkle/WinSparkle) should be used. /// True on macOS (non-Homebrew) and installed Windows (has uninstaller). static bool get useNativeUpdater { if (!isUpdateCheckAvailable) return false; if (Platform.isMacOS) return !_isHomebrewInstall(); if (Platform.isWindows) return _isInstalledApp() && !_isWingetInstall(); return false; } /// Initialize the native auto_updater (Sparkle/WinSparkle). /// Call once at startup if [useNativeUpdater] is true. static Future initNativeUpdater() async { if (_nativeUpdaterInitialized) return; try { await autoUpdater.setFeedURL(_feedUrl); _nativeUpdaterInitialized = true; } catch (error, stackTrace) { appLogger.e('Failed to initialize native auto updater', error: error, stackTrace: stackTrace); } } /// Trigger a background update check via Sparkle/WinSparkle. /// Only shows UI if an update is found. static Future checkForUpdatesNative({bool inBackground = true}) async { if (!_nativeUpdaterInitialized) { await initNativeUpdater(); if (!_nativeUpdaterInitialized) return; } try { await autoUpdater.checkForUpdates(inBackground: inBackground); } catch (error, stackTrace) { appLogger.e('Native update check failed', error: error, stackTrace: stackTrace); } } /// Check if the macOS app was installed via Homebrew. /// Homebrew casks live under /opt/homebrew/Caskroom/ or /usr/local/Caskroom/. static bool _isHomebrewInstall() { try { final execPath = Platform.resolvedExecutable; return execPath.contains('/Caskroom/') || execPath.contains('/homebrew/'); } catch (error, stackTrace) { appLogger.e('Failed to determine Homebrew install status', error: error, stackTrace: stackTrace); return false; } } /// Check if the Windows app was installed via winget. /// The Inno Setup installer writes a .winget marker file when invoked with /WINGET=1. static bool _isWingetInstall() { try { final exeDir = File(Platform.resolvedExecutable).parent.path; return File('$exeDir\\.winget').existsSync(); } catch (error, stackTrace) { appLogger.e('Failed to determine winget install status', error: error, stackTrace: stackTrace); return false; } } /// Check if the Windows app is an installed copy (not portable). /// The Inno Setup installer places unins000.exe next to the executable. static bool _isInstalledApp() { try { final exeDir = File(Platform.resolvedExecutable).parent.path; return File('$exeDir\\unins000.exe').existsSync(); } catch (error, stackTrace) { appLogger.e('Failed to determine Windows installation status', error: error, stackTrace: stackTrace); return false; } } static Future skipVersion(String version) async { final prefs = await BaseSharedPreferencesService.sharedCache(); await prefs.setString(_keySkippedVersion, version); } static Future getSkippedVersion() async { final prefs = await BaseSharedPreferencesService.sharedCache(); return prefs.getString(_keySkippedVersion); } /// Check if cooldown period has passed since last check static Future shouldCheckForUpdates() async { final prefs = await BaseSharedPreferencesService.sharedCache(); final lastCheckString = prefs.getString(_keyLastCheckTime); if (lastCheckString == null) return true; final now = DateTime.now(); final lastCheck = DateTime.tryParse(lastCheckString); if (lastCheck == null || lastCheck.isAfter(now)) { await prefs.remove(_keyLastCheckTime); return true; } return now.difference(lastCheck) >= _checkCooldown; } static Future _updateLastCheckTime() async { final prefs = await BaseSharedPreferencesService.sharedCache(); await prefs.setString(_keyLastCheckTime, DateTime.now().toIso8601String()); } /// Internal method that performs the actual update check /// [respectCooldown] - if true, checks cooldown and records the attempt before the request static Future?> _performUpdateCheck({ required bool respectCooldown, MediaServerHttpClient? client, bool forceEnabled = false, }) async { if (!forceEnabled && !isUpdateCheckAvailable) { return null; } // Check cooldown if requested if (respectCooldown && !await shouldCheckForUpdates()) { return null; } try { final packageInfo = await PackageInfo.fromPlatform(); final currentVersion = packageInfo.version; if (respectCooldown) { await _updateLastCheckTime(); } final response = await (client ?? httpClient).get( 'https://api.github.com/repos/$_githubRepo/releases/latest', headers: {'Accept': 'application/vnd.github+json'}, ); if (response.statusCode == 200) { final data = response.data; final latestVersion = data['tag_name'] as String; // Remove 'v' prefix if present final cleanVersion = latestVersion.startsWith('v') ? latestVersion.substring(1) : latestVersion; final hasUpdate = _isNewerVersion(cleanVersion, currentVersion); if (hasUpdate) { // Check if this version was skipped final skippedVersion = await getSkippedVersion(); if (skippedVersion == cleanVersion) { return null; } return { 'hasUpdate': true, 'currentVersion': currentVersion, 'latestVersion': cleanVersion, 'releaseUrl': data['html_url'] as String, 'releaseName': data['name'] as String? ?? 'Version $cleanVersion', 'releaseNotes': data['body'] as String? ?? '', 'publishedAt': data['published_at'] as String, }; } } } catch (error, stackTrace) { appLogger.e('Failed to check for updates', error: error, stackTrace: stackTrace); } return null; } @visibleForTesting static Future?> debugPerformUpdateCheck({ required bool respectCooldown, required MediaServerHttpClient client, }) { return _performUpdateCheck(respectCooldown: respectCooldown, client: client, forceEnabled: true); } /// Check for updates on GitHub (manual check, ignores cooldown) /// Returns a map with update info, or null if no update or error static Future?> checkForUpdates() { return _performUpdateCheck(respectCooldown: false); } /// Check for updates on startup (respects cooldown and skipped versions) /// Returns update info if available, null otherwise static Future?> checkForUpdatesOnStartup() { return _performUpdateCheck(respectCooldown: true); } /// Parse version string into list of integers /// Handles versions like "1.2.3+4" by taking only the numeric parts static List _parseVersionParts(String version) { return version.split('.').map((p) { final numPart = p.split('+').first.split('-').first; return int.tryParse(numPart) ?? 0; }).toList(); } /// Compare two version strings /// Returns true if newVersion is newer than currentVersion static bool _isNewerVersion(String newVersion, String currentVersion) { try { final newParts = _parseVersionParts(newVersion); final currentParts = _parseVersionParts(currentVersion); // Compare each part final maxLength = newParts.length > currentParts.length ? newParts.length : currentParts.length; for (int i = 0; i < maxLength; i++) { final newPart = i < newParts.length ? newParts[i] : 0; final currentPart = i < currentParts.length ? currentParts[i] : 0; if (newPart > currentPart) return true; if (newPart < currentPart) return false; } return false; } catch (error, stackTrace) { appLogger.e('Error comparing versions', error: error, stackTrace: stackTrace); return false; } } }