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.
261 lines
9.6 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|