Files
plezy/lib/services/update_service.dart
T
edde746 944a8d89f5 feat(windows): package for the Microsoft Store as an MSIX bundle
The Store's unpackaged EXE path would require Authenticode-signing the
installer and every PE file inside it. MSIX submissions are re-signed by the
Store instead, so this route needs no code-signing certificate. build-msix.ps1
mirrors build-installer.ps1 and consumes the same per-architecture build
artifacts, leaving the installer, portable archives and WinSparkle appcast
untouched.

One template generates the manifest for both architectures, carrying the
identity reserved in Partner Center. check_windows_msix.py recomputes the
package family name from the publisher DN, so a mistyped identity fails CI
rather than a submission, and it parses the script rather than running it
because root CI is Linux. Qualified logo assets are indexed into
resources.pri; without the altform-unplated variants the shell draws the
taskbar icon on an accent-coloured plate.

PlatformDetector.isPackagedInstall gates the in-app updater and the Liberapay
tile, which the read-only package directory and Store commerce policy
respectively rule out. Gating at runtime keeps one Windows build feeding both
the installer and the Store package.
2026-07-30 20:03:04 +02:00

261 lines
9.6 KiB
Dart

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<void> 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<void> 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<void> skipVersion(String version) async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString(_keySkippedVersion, version);
}
static Future<String?> getSkippedVersion() async {
final prefs = await BaseSharedPreferencesService.sharedCache();
return prefs.getString(_keySkippedVersion);
}
/// Check if cooldown period has passed since last check
static Future<bool> 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<void> _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<Map<String, dynamic>?> _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<Map<String, dynamic>?> 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<Map<String, dynamic>?> checkForUpdates() {
return _performUpdateCheck(respectCooldown: false);
}
/// Check for updates on startup (respects cooldown and skipped versions)
/// Returns update info if available, null otherwise
static Future<Map<String, dynamic>?> 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<int> _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;
}
}
}