diff --git a/lib/core/updater/app_updater_service.dart b/lib/core/updater/app_updater_service.dart index 8cca2f77..28ec2aaa 100644 --- a/lib/core/updater/app_updater_service.dart +++ b/lib/core/updater/app_updater_service.dart @@ -10,6 +10,7 @@ import '../storage/app_settings.dart'; import 'github_releases_client.dart'; import 'installers/update_install_context.dart'; import 'sha256_checksums.dart'; +import 'update_asset_selection.dart'; import 'update_manifest.dart'; import 'update_platform_installer.dart'; import 'update_version.dart'; @@ -178,20 +179,15 @@ class AppUpdaterService { return context.isManagedPackage; } - /// Picks the platform zip for the current OS from [manifest]. - UpdateAsset? platformAssetFor(UpdateManifest manifest) { - final suffix = switch (Platform.operatingSystem) { - 'linux' => '-linux.zip', - 'windows' => '-windows.zip', - 'macos' => '-macos.zip', - _ => null, - }; - if (suffix == null) return null; - - for (final asset in manifest.assets) { - if (asset.name.endsWith(suffix)) return asset; - } - return null; + /// Picks the best Release asset for the current packaging context. + UpdateAsset? platformAssetFor( + UpdateManifest manifest, { + UpdateInstallContext? context, + }) { + return selectUpdateAsset( + manifest, + context ?? UpdateInstallContext.current(), + ); } Future> _resolveChecksums({ diff --git a/lib/core/updater/update_asset_selection.dart b/lib/core/updater/update_asset_selection.dart new file mode 100644 index 00000000..fdec6f2f --- /dev/null +++ b/lib/core/updater/update_asset_selection.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/updater/installers/update_install_context.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; + +/// Ordered filename suffixes to try when picking a GitHub Release asset. +/// +/// Prefer the packaging family of the running binary so portable users are not +/// switched onto installers (and vice versa). +List preferredUpdateAssetSuffixes( + UpdateInstallContext context, { + String? operatingSystem, + bool Function(String path)? fileExists, +}) { + final os = operatingSystem ?? Platform.operatingSystem; + final exists = fileExists ?? ((path) => File(path).existsSync()); + + switch (os) { + case 'linux': + if (context.isSnap || context.isFlatpak) { + // In-app install is blocked; still prefer zip if selection is asked. + return const ['-linux.zip']; + } + if (context.appImagePath != null) { + return const ['-linux.AppImage', '-linux.appimage', '-linux.zip']; + } + return const ['-linux.zip', '-linux.AppImage', '-linux.appimage']; + case 'windows': + if (_looksLikeWindowsSetupInstall(context, exists)) { + return const ['-windows-setup.exe', '-windows.zip']; + } + return const ['-windows.zip', '-windows-setup.exe']; + case 'macos': + return const ['-macos.zip']; + default: + return const []; + } +} + +/// Picks the first manifest asset whose name ends with a preferred suffix. +UpdateAsset? selectUpdateAsset( + UpdateManifest manifest, + UpdateInstallContext context, { + String? operatingSystem, + bool Function(String path)? fileExists, +}) { + final suffixes = preferredUpdateAssetSuffixes( + context, + operatingSystem: operatingSystem, + fileExists: fileExists, + ); + for (final suffix in suffixes) { + final needle = suffix.toLowerCase(); + for (final asset in manifest.assets) { + if (asset.name.toLowerCase().endsWith(needle)) return asset; + } + } + return null; +} + +bool _looksLikeWindowsSetupInstall( + UpdateInstallContext context, + bool Function(String path) fileExists, +) { + final root = p.dirname(context.resolvedExecutable); + // Inno Setup default uninstaller next to the app. + if (fileExists(p.join(root, 'unins000.exe'))) return true; + final channel = context.environment['QUERYA_INSTALL_CHANNEL']?.toLowerCase(); + return channel == 'installer' || channel == 'setup'; +} diff --git a/test/core/updater/update_asset_selection_test.dart b/test/core/updater/update_asset_selection_test.dart new file mode 100644 index 00000000..8b6ad4db --- /dev/null +++ b/test/core/updater/update_asset_selection_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/updater/installers/update_install_context.dart'; +import 'package:querya_desktop/core/updater/update_asset_selection.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; + +UpdateManifest _manifest(List names) { + return UpdateManifest( + version: '0.5.0', + changelog: '', + assets: [ + for (final name in names) + UpdateAsset( + name: name, + downloadUrl: 'https://example.com/$name', + ), + ], + ); +} + +void main() { + group('selectUpdateAsset', () { + test('Linux AppImage runtime prefers .AppImage then zip', () { + const ctx = UpdateInstallContext( + environment: {'APPIMAGE': '/opt/Querya.AppImage'}, + resolvedExecutable: '/tmp/.mount_Querya/querya_desktop', + ); + final asset = selectUpdateAsset( + _manifest([ + 'Querya-Desktop-0.5.0-linux.zip', + 'Querya-Desktop-0.5.0-linux.AppImage', + ]), + ctx, + operatingSystem: 'linux', + ); + expect(asset?.name, 'Querya-Desktop-0.5.0-linux.AppImage'); + }); + + test('Linux portable prefers zip over AppImage', () { + const ctx = UpdateInstallContext( + environment: {}, + resolvedExecutable: '/home/u/Querya/querya_desktop', + ); + final asset = selectUpdateAsset( + _manifest([ + 'Querya-Desktop-0.5.0-linux.AppImage', + 'Querya-Desktop-0.5.0-linux.zip', + ]), + ctx, + operatingSystem: 'linux', + ); + expect(asset?.name, 'Querya-Desktop-0.5.0-linux.zip'); + }); + + test('Windows Inno install prefers setup.exe', () { + const ctx = UpdateInstallContext( + environment: {}, + resolvedExecutable: r'C:\Program Files\Querya\querya_desktop.exe', + ); + final asset = selectUpdateAsset( + _manifest([ + 'Querya-Desktop-0.5.0-windows.zip', + 'Querya-Desktop-0.5.0-windows-setup.exe', + ]), + ctx, + operatingSystem: 'windows', + fileExists: (path) => path.toLowerCase().endsWith('unins000.exe'), + ); + expect(asset?.name, 'Querya-Desktop-0.5.0-windows-setup.exe'); + }); + + test('Windows portable prefers zip', () { + const ctx = UpdateInstallContext( + environment: {}, + resolvedExecutable: r'D:\portable\querya_desktop.exe', + ); + final asset = selectUpdateAsset( + _manifest([ + 'Querya-Desktop-0.5.0-windows-setup.exe', + 'Querya-Desktop-0.5.0-windows.zip', + ]), + ctx, + operatingSystem: 'windows', + fileExists: (_) => false, + ); + expect(asset?.name, 'Querya-Desktop-0.5.0-windows.zip'); + }); + + test('macOS uses macos.zip', () { + const ctx = UpdateInstallContext( + environment: {}, + resolvedExecutable: + '/Applications/querya_desktop.app/Contents/MacOS/querya_desktop', + ); + final asset = selectUpdateAsset( + _manifest([ + 'Querya-Desktop-0.5.0-macos.zip', + 'Querya-Desktop-0.5.0-linux.zip', + ]), + ctx, + operatingSystem: 'macos', + ); + expect(asset?.name, 'Querya-Desktop-0.5.0-macos.zip'); + }); + }); +}