From 5843661207ac246602c8cb18b851eea0b6f152c4 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 18:32:14 +0300 Subject: [PATCH 1/2] feat(core): implement AppUpdaterService with GitHub Releases and SHA256 (#280) Add Phase 1 auto-update core: SemVer version gate, GitHub Releases client, SHA256 manifest verification on download, and update channel preferences (stable vs dev) with optional startup background check hook. --- lib/core/storage/app_settings.dart | 39 +++ lib/core/updater/app_updater_service.dart | 211 +++++++++++++ lib/core/updater/github_releases_client.dart | 129 ++++++++ lib/core/updater/sha256_checksums.dart | 63 ++++ lib/core/updater/update_manifest.dart | 95 ++++++ lib/core/updater/update_version.dart | 83 +++++ test/core/storage/app_settings_test.dart | 17 ++ .../updater/app_updater_service_test.dart | 289 ++++++++++++++++++ .../updater/github_releases_parser_test.dart | 48 +++ test/core/updater/update_version_test.dart | 92 ++++++ 10 files changed, 1066 insertions(+) create mode 100644 lib/core/updater/app_updater_service.dart create mode 100644 lib/core/updater/github_releases_client.dart create mode 100644 lib/core/updater/sha256_checksums.dart create mode 100644 lib/core/updater/update_manifest.dart create mode 100644 lib/core/updater/update_version.dart create mode 100644 test/core/updater/app_updater_service_test.dart create mode 100644 test/core/updater/github_releases_parser_test.dart create mode 100644 test/core/updater/update_version_test.dart diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 0919cf08..72dd0db6 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -4,6 +4,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../motion/querya_motion_scope.dart'; import '../theme/querya_theme_preset.dart'; +import '../updater/update_manifest.dart'; import 'local_db.dart'; /// Default cap on rows shown in SQL workspace result grids (full result may be larger). @@ -113,6 +114,8 @@ abstract final class AppSettingsKeys { static const themeAnimationEnabled = 'theme_animation_enabled'; static const uiScale = 'ui_scale'; static const motionLevel = 'motion_level'; + static const updateChannel = 'update_channel'; + static const checkForUpdatesOnStartup = 'check_for_updates_on_startup'; } /// Bumps [listenable] when any preference is persisted (theme, legacy listeners). @@ -550,4 +553,40 @@ class AppSettings { await LocalDb.instance.setAppSetting(AppSettingsKeys.motionLevel, stored); AppSettingsRevision.bump(); } + + /// Update distribution channel (`stable` hides pre-releases). + Future getUpdateChannel() async { + final v = + await LocalDb.instance.getAppSetting(AppSettingsKeys.updateChannel); + return switch (v) { + 'dev' => UpdateChannel.dev, + _ => UpdateChannel.stable, + }; + } + + Future setUpdateChannel(UpdateChannel channel) async { + final stored = switch (channel) { + UpdateChannel.dev => 'dev', + UpdateChannel.stable => 'stable', + }; + await LocalDb.instance.setAppSetting(AppSettingsKeys.updateChannel, stored); + AppSettingsRevision.bump(); + } + + /// Whether to poll GitHub Releases silently when the app starts. + Future getCheckForUpdatesOnStartup() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.checkForUpdatesOnStartup, + ); + if (v == null || v.isEmpty) return true; + return v == 'true' || v == '1'; + } + + Future setCheckForUpdatesOnStartup(bool enabled) async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.checkForUpdatesOnStartup, + enabled ? 'true' : 'false', + ); + AppSettingsRevision.bump(); + } } diff --git a/lib/core/updater/app_updater_service.dart b/lib/core/updater/app_updater_service.dart new file mode 100644 index 00000000..e4154993 --- /dev/null +++ b/lib/core/updater/app_updater_service.dart @@ -0,0 +1,211 @@ +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:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../storage/app_settings.dart'; +import 'github_releases_client.dart'; +import 'sha256_checksums.dart'; +import 'update_manifest.dart'; +import 'update_version.dart'; + +/// Core service for checking GitHub Releases and downloading verified update artifacts. +class AppUpdaterService { + AppUpdaterService({ + GitHubReleasesClient? releasesClient, + http.Client? downloadClient, + Future Function()? packageInfoProvider, + AppSettings? settings, + }) : _releasesClient = releasesClient ?? GitHubReleasesClient(), + _downloadClient = downloadClient ?? http.Client(), + _packageInfoProvider = + packageInfoProvider ?? (() => PackageInfo.fromPlatform()), + _settings = settings ?? AppSettings.instance; + + final GitHubReleasesClient _releasesClient; + final http.Client _downloadClient; + final Future Function() _packageInfoProvider; + final AppSettings _settings; + + static final AppUpdaterService instance = AppUpdaterService(); + + /// Checks GitHub Releases for a newer version than the running app. + /// + /// When [background] is true, errors are returned in [UpdateCheckResult.errorMessage] + /// instead of being rethrown (for silent startup checks). + Future checkForUpdates({bool background = false}) async { + try { + final packageInfo = await _packageInfoProvider(); + final currentRaw = packageInfo.version; + final currentVersion = UpdateVersion.tryParse(currentRaw); + if (currentVersion == null) { + throw AppUpdaterException('Invalid current app version: $currentRaw'); + } + + final channel = await _settings.getUpdateChannel(); + final manifest = await _releasesClient.fetchLatest(channel: channel); + final candidateVersion = UpdateVersion.tryParse(manifest.version); + if (candidateVersion == null) { + throw AppUpdaterException( + 'Invalid release version tag: ${manifest.version}', + ); + } + + final allowPreRelease = channel == UpdateChannel.dev; + final available = UpdateVersion.isUpdateAvailable( + current: currentVersion, + candidate: candidateVersion, + allowPreRelease: allowPreRelease, + ); + + if (!available) { + return UpdateCheckResult(currentVersion: currentRaw); + } + + return UpdateCheckResult( + currentVersion: currentRaw, + availableUpdate: manifest, + ); + } on AppUpdaterException catch (e) { + if (background) { + return UpdateCheckResult( + currentVersion: '', + errorMessage: e.message, + ); + } + rethrow; + } on GitHubReleasesException catch (e) { + if (background) { + return UpdateCheckResult( + currentVersion: '', + errorMessage: e.message, + ); + } + throw AppUpdaterException(e.message, cause: e); + } catch (e, st) { + debugPrint('AppUpdaterService.checkForUpdates: $e\n$st'); + if (background) { + return UpdateCheckResult( + currentVersion: '', + errorMessage: e.toString(), + ); + } + rethrow; + } + } + + /// Runs a background update check on startup when enabled in Preferences. + Future maybeCheckOnStartup() async { + final enabled = await _settings.getCheckForUpdatesOnStartup(); + if (!enabled) return null; + return checkForUpdates(background: true); + } + + /// Downloads [asset] to a temp file and verifies SHA256 before returning the path. + /// + /// When [manifest] is provided, its [UpdateManifest.checksumsUrl] is used to load + /// `SHA256SUMS.txt` before downloading the binary. + Future downloadAsset( + UpdateAsset asset, { + UpdateManifest? manifest, + UpdateDownloadProgressCallback? onProgress, + }) async { + final checksums = await _resolveChecksums(asset: asset, manifest: manifest); + final expected = checksums[asset.name] ?? asset.sha256; + if (expected == null || expected.isEmpty) { + throw AppUpdaterException( + 'Missing SHA256 checksum for ${asset.name}; refusing insecure download', + ); + } + + final tempDir = await getTemporaryDirectory(); + final destination = File(p.join(tempDir.path, asset.name)); + if (await destination.exists()) { + await destination.delete(); + } + + final request = http.Request('GET', Uri.parse(asset.downloadUrl)); + final response = await _downloadClient.send(request); + if (response.statusCode != 200) { + throw AppUpdaterException( + 'Download failed for ${asset.name} (HTTP ${response.statusCode})', + ); + } + + final total = response.contentLength ?? asset.sizeBytes ?? 0; + var received = 0; + final sink = destination.openWrite(); + try { + await for (final chunk in response.stream) { + received += chunk.length; + sink.add(chunk); + if (onProgress != null) { + onProgress(received, total > 0 ? total : received); + } + } + } finally { + await sink.close(); + } + + await verifyFileSha256(file: destination, expectedHex: expected); + return destination; + } + + /// 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; + } + + Future> _resolveChecksums({ + required UpdateAsset asset, + UpdateManifest? manifest, + }) async { + if (manifest != null && manifest.checksums.isNotEmpty) { + return manifest.checksums; + } + + final checksumsUrl = manifest?.checksumsUrl ?? + manifest?.assetNamed(kSha256SumsFileName)?.downloadUrl; + if (checksumsUrl == null || checksumsUrl.isEmpty) { + if (asset.sha256 != null) { + return {asset.name: asset.sha256!}; + } + return const {}; + } + + final text = await _releasesClient.downloadText(checksumsUrl); + return parseSha256SumsText(text); + } + + void dispose() { + _releasesClient.close(); + _downloadClient.close(); + } +} + +class AppUpdaterException implements Exception { + const AppUpdaterException(this.message, {this.cause}); + + final String message; + final Object? cause; + + @override + String toString() { + if (cause == null) return 'AppUpdaterException: $message'; + return 'AppUpdaterException: $message ($cause)'; + } +} diff --git a/lib/core/updater/github_releases_client.dart b/lib/core/updater/github_releases_client.dart new file mode 100644 index 00000000..a22fed25 --- /dev/null +++ b/lib/core/updater/github_releases_client.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'update_manifest.dart'; +import 'update_version.dart'; + +const String kGitHubReleasesLatestUrl = + 'https://api.github.com/repos/QueryaHub/Querya-Desktop/releases/latest'; + +const String kGitHubReleasesListUrl = + 'https://api.github.com/repos/QueryaHub/Querya-Desktop/releases'; + +const String kSha256SumsFileName = 'SHA256SUMS.txt'; + +/// Fetches and parses GitHub Releases JSON for update checks. +class GitHubReleasesClient { + GitHubReleasesClient({http.Client? httpClient}) + : _httpClient = httpClient ?? http.Client(); + + final http.Client _httpClient; + + Future fetchLatest({required UpdateChannel channel}) async { + if (channel == UpdateChannel.stable) { + final response = await _httpClient.get(Uri.parse(kGitHubReleasesLatestUrl)); + if (response.statusCode != 200) { + throw GitHubReleasesException( + 'GitHub Releases API returned HTTP ${response.statusCode}', + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const GitHubReleasesException('Unexpected GitHub Releases payload'); + } + return parseGitHubRelease(decoded); + } + + final response = await _httpClient.get(Uri.parse(kGitHubReleasesListUrl)); + if (response.statusCode != 200) { + throw GitHubReleasesException( + 'GitHub Releases API returned HTTP ${response.statusCode}', + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! List) { + throw const GitHubReleasesException('Unexpected GitHub Releases list payload'); + } + + for (final entry in decoded) { + if (entry is! Map) continue; + if (entry['draft'] == true) continue; + return parseGitHubRelease(entry); + } + + throw const GitHubReleasesException('No published releases found'); + } + + Future downloadText(String url) async { + final response = await _httpClient.get(Uri.parse(url)); + if (response.statusCode != 200) { + throw GitHubReleasesException( + 'Failed to download $url (HTTP ${response.statusCode})', + ); + } + return response.body; + } + + void close() => _httpClient.close(); +} + +/// Parses a single GitHub release object into [UpdateManifest]. +UpdateManifest parseGitHubRelease(Map json) { + final tagName = json['tag_name']?.toString(); + if (tagName == null || tagName.isEmpty) { + throw const GitHubReleasesException('Release is missing tag_name'); + } + + final version = UpdateVersion.normalizeTag(tagName); + final publishedAtRaw = json['published_at']?.toString(); + DateTime? releaseDate; + if (publishedAtRaw != null && publishedAtRaw.isNotEmpty) { + releaseDate = DateTime.tryParse(publishedAtRaw); + } + + final body = json['body']?.toString() ?? ''; + final rawAssets = json['assets']; + final assets = []; + String? checksumsUrl; + + if (rawAssets is List) { + for (final raw in rawAssets) { + if (raw is! Map) continue; + final name = raw['name']?.toString(); + final url = raw['browser_download_url']?.toString(); + if (name == null || name.isEmpty || url == null || url.isEmpty) { + continue; + } + final size = raw['size']; + final sizeBytes = size is int ? size : int.tryParse('$size'); + if (name == kSha256SumsFileName) { + checksumsUrl = url; + } + assets.add( + UpdateAsset( + name: name, + downloadUrl: url, + sizeBytes: sizeBytes, + ), + ); + } + } + + return UpdateManifest( + version: version, + releaseDate: releaseDate, + changelog: body, + assets: assets, + checksumsUrl: checksumsUrl, + ); +} + +class GitHubReleasesException implements Exception { + const GitHubReleasesException(this.message); + + final String message; + + @override + String toString() => 'GitHubReleasesException: $message'; +} diff --git a/lib/core/updater/sha256_checksums.dart b/lib/core/updater/sha256_checksums.dart new file mode 100644 index 00000000..b5231f28 --- /dev/null +++ b/lib/core/updater/sha256_checksums.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; + +/// Parses `sha256sum`-style manifest lines: ` `. +Map parseSha256SumsText(String text) { + final out = {}; + for (final rawLine in const LineSplitter().convert(text)) { + final line = rawLine.trim(); + if (line.isEmpty || line.startsWith('#')) continue; + + final parts = line.split(RegExp(r'\s+')); + if (parts.length < 2) continue; + + final hash = parts.first.toLowerCase(); + if (hash.length != 64 || !RegExp(r'^[0-9a-f]+$').hasMatch(hash)) { + continue; + } + + final fileName = parts.sublist(1).join(' '); + out[fileName] = hash; + } + return out; +} + +Future sha256HexOfFile(File file) async { + final digest = await sha256.bind(file.openRead()).first; + return digest.toString(); +} + +/// Throws [UpdateChecksumMismatchException] when [expectedHex] does not match. +Future verifyFileSha256({ + required File file, + required String expectedHex, +}) async { + final actual = await sha256HexOfFile(file); + final expected = expectedHex.toLowerCase(); + if (actual != expected) { + throw UpdateChecksumMismatchException( + fileName: file.path.split(Platform.pathSeparator).last, + expected: expected, + actual: actual, + ); + } +} + +class UpdateChecksumMismatchException implements Exception { + const UpdateChecksumMismatchException({ + required this.fileName, + required this.expected, + required this.actual, + }); + + final String fileName; + final String expected; + final String actual; + + @override + String toString() => + 'UpdateChecksumMismatchException: SHA256 mismatch for $fileName ' + '(expected $expected, got $actual)'; +} diff --git a/lib/core/updater/update_manifest.dart b/lib/core/updater/update_manifest.dart new file mode 100644 index 00000000..7ef6b201 --- /dev/null +++ b/lib/core/updater/update_manifest.dart @@ -0,0 +1,95 @@ +/// Distribution channel for desktop update checks. +enum UpdateChannel { + stable, + dev, +} + +/// A downloadable release artifact (platform zip, checksum file, etc.). +class UpdateAsset { + const UpdateAsset({ + required this.name, + required this.downloadUrl, + this.sizeBytes, + this.sha256, + }); + + final String name; + final String downloadUrl; + final int? sizeBytes; + + /// Expected SHA256 hex digest from [UpdateManifest.checksums], if known. + final String? sha256; + + UpdateAsset copyWith({String? sha256}) { + return UpdateAsset( + name: name, + downloadUrl: downloadUrl, + sizeBytes: sizeBytes, + sha256: sha256 ?? this.sha256, + ); + } +} + +/// Parsed release metadata from GitHub Releases (or a compatible proxy feed). +class UpdateManifest { + const UpdateManifest({ + required this.version, + required this.changelog, + required this.assets, + this.releaseDate, + this.checksumsUrl, + this.checksums = const {}, + }); + + final String version; + final DateTime? releaseDate; + final String changelog; + final List assets; + final String? checksumsUrl; + + /// File name → lowercase SHA256 hex digest. + final Map checksums; + + UpdateAsset? assetNamed(String name) { + for (final asset in assets) { + if (asset.name == name) return asset; + } + return null; + } + + UpdateManifest withChecksums(Map checksums) { + final enriched = assets + .map( + (asset) => checksums.containsKey(asset.name) + ? asset.copyWith(sha256: checksums[asset.name]) + : asset, + ) + .toList(growable: false); + return UpdateManifest( + version: version, + releaseDate: releaseDate, + changelog: changelog, + assets: enriched, + checksumsUrl: checksumsUrl, + checksums: checksums, + ); + } +} + +/// Result of [AppUpdaterService.checkForUpdates]. +class UpdateCheckResult { + const UpdateCheckResult({ + required this.currentVersion, + this.availableUpdate, + this.errorMessage, + }); + + final String currentVersion; + final UpdateManifest? availableUpdate; + final String? errorMessage; + + bool get hasUpdate => availableUpdate != null; + bool get isUpToDate => availableUpdate == null && errorMessage == null; +} + +typedef UpdateDownloadProgressCallback = void Function(int received, int total); diff --git a/lib/core/updater/update_version.dart b/lib/core/updater/update_version.dart new file mode 100644 index 00000000..7d0e35d2 --- /dev/null +++ b/lib/core/updater/update_version.dart @@ -0,0 +1,83 @@ +/// Lightweight SemVer parser for update comparison (major.minor.patch + optional pre-release). +class UpdateVersion implements Comparable { + const UpdateVersion({ + required this.major, + required this.minor, + required this.patch, + this.preRelease, + }); + + final int major; + final int minor; + final int patch; + + /// Lowercase pre-release label after `-`, e.g. `beta.1`; `null` for stable. + final String? preRelease; + + bool get isPreRelease => preRelease != null && preRelease!.isNotEmpty; + + /// Strips an optional leading `v` from Git tag names. + static String normalizeTag(String tag) { + final trimmed = tag.trim(); + if (trimmed.isEmpty) return trimmed; + return trimmed.startsWith('v') ? trimmed.substring(1) : trimmed; + } + + static UpdateVersion? tryParse(String raw) { + final normalized = normalizeTag(raw); + if (normalized.isEmpty) return null; + + final dash = normalized.indexOf('-'); + final core = dash >= 0 ? normalized.substring(0, dash) : normalized; + final pre = dash >= 0 ? normalized.substring(dash + 1) : null; + + final parts = core.split('.'); + if (parts.length < 3) return null; + + final major = int.tryParse(parts[0]); + final minor = int.tryParse(parts[1]); + final patch = int.tryParse(parts[2]); + if (major == null || minor == null || patch == null) return null; + + return UpdateVersion( + major: major, + minor: minor, + patch: patch, + preRelease: (pre == null || pre.isEmpty) ? null : pre.toLowerCase(), + ); + } + + /// Whether [candidate] is strictly newer than [current] for the given channel. + static bool isUpdateAvailable({ + required UpdateVersion current, + required UpdateVersion candidate, + required bool allowPreRelease, + }) { + if (candidate.compareTo(current) <= 0) return false; + if (!allowPreRelease && candidate.isPreRelease) return false; + return true; + } + + @override + int compareTo(UpdateVersion other) { + final core = _compareCore(other); + if (core != 0) return core; + + if (!isPreRelease && !other.isPreRelease) return 0; + if (!isPreRelease && other.isPreRelease) return 1; + if (isPreRelease && !other.isPreRelease) return -1; + return preRelease!.compareTo(other.preRelease!); + } + + int _compareCore(UpdateVersion other) { + final majorCmp = major.compareTo(other.major); + if (majorCmp != 0) return majorCmp; + final minorCmp = minor.compareTo(other.minor); + if (minorCmp != 0) return minorCmp; + return patch.compareTo(other.patch); + } + + @override + String toString() => + isPreRelease ? '$major.$minor.$patch-$preRelease' : '$major.$minor.$patch'; +} diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 44e41357..bbb79882 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -5,6 +5,7 @@ import 'package:path_provider_platform_interface/path_provider_platform_interfac import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// path_provider has no implementation in plain `flutter test`; LocalDb needs a path. @@ -70,6 +71,10 @@ void main() { await LocalDb.instance .deleteAppSetting(AppSettingsKeys.sqlHistoryMaxEntries); await AppSettings.instance.clearThemeSettings(); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.updateChannel); + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.checkForUpdatesOnStartup, + ); }); group('AppSettings', () { @@ -353,5 +358,17 @@ void main() { expect(sqlCalls, 0); SqlWorkspaceSettingsRevision.listenable.removeListener(listener); }); + + test('update channel defaults to stable and roundtrips dev', () async { + expect(await AppSettings.instance.getUpdateChannel(), UpdateChannel.stable); + await AppSettings.instance.setUpdateChannel(UpdateChannel.dev); + expect(await AppSettings.instance.getUpdateChannel(), UpdateChannel.dev); + }); + + test('check for updates on startup defaults to true', () async { + expect(await AppSettings.instance.getCheckForUpdatesOnStartup(), isTrue); + await AppSettings.instance.setCheckForUpdatesOnStartup(false); + expect(await AppSettings.instance.getCheckForUpdatesOnStartup(), isFalse); + }); }); } diff --git a/test/core/updater/app_updater_service_test.dart b/test/core/updater/app_updater_service_test.dart new file mode 100644 index 00000000..f259e121 --- /dev/null +++ b/test/core/updater/app_updater_service_test.dart @@ -0,0 +1,289 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/updater/app_updater_service.dart'; +import 'package:querya_desktop/core/updater/github_releases_client.dart'; +import 'package:querya_desktop/core/updater/sha256_checksums.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; + + @override + Future getApplicationCachePath() async => _root; + + @override + Future getLibraryPath() async => _root; + + @override + Future getExternalStoragePath() async => _root; + + @override + Future?> getExternalCachePaths() async => [_root]; + + @override + Future?> getExternalStoragePaths( + {StorageDirectory? type}) async => + [_root]; + + @override + Future getDownloadsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_updater_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.updateChannel); + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.checkForUpdatesOnStartup, + ); + }); + + group('parseSha256SumsText', () { + test('parses sha256sum lines', () { + const hash = + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + const text = + '$hash Querya-Desktop-0.5.0-linux.zip\n'; + final parsed = parseSha256SumsText(text); + expect(parsed['Querya-Desktop-0.5.0-linux.zip'], hash); + }); + }); + + group('AppUpdaterService.checkForUpdates', () { + test('returns update when GitHub latest is newer', () async { + final client = MockClient((request) async { + expect(request.url.toString(), kGitHubReleasesLatestUrl); + return http.Response( + jsonEncode(_sampleRelease(version: '0.5.0')), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + final result = await service.checkForUpdates(); + expect(result.currentVersion, '0.4.9'); + expect(result.hasUpdate, isTrue); + expect(result.availableUpdate?.version, '0.5.0'); + service.dispose(); + }); + + test('returns up to date when versions match', () async { + final client = MockClient((request) async { + return http.Response( + jsonEncode(_sampleRelease(version: '0.4.9')), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + final result = await service.checkForUpdates(); + expect(result.isUpToDate, isTrue); + service.dispose(); + }); + + test('dev channel uses releases list and accepts pre-release', () async { + await AppSettings.instance.setUpdateChannel(UpdateChannel.dev); + + final client = MockClient((request) async { + expect(request.url.toString(), kGitHubReleasesListUrl); + return http.Response( + jsonEncode([ + _sampleRelease(version: '0.5.0-beta.1'), + _sampleRelease(version: '0.4.9'), + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + final result = await service.checkForUpdates(); + expect(result.availableUpdate?.version, '0.5.0-beta.1'); + service.dispose(); + }); + + test('background mode returns error message instead of throwing', () async { + final client = MockClient((request) async { + return http.Response('rate limited', 403); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + final result = await service.checkForUpdates(background: true); + expect(result.errorMessage, isNotNull); + service.dispose(); + }); + }); + + group('AppUpdaterService.downloadAsset', () { + test('verifies SHA256 and reports progress', () async { + final payload = utf8.encode('querya-update-payload'); + final digest = sha256.convert(payload).toString(); + const fileName = 'Querya-Desktop-0.5.0-linux.zip'; + final checksums = '$digest $fileName\n'; + + final client = MockClient((request) async { + final url = request.url.toString(); + if (url.contains('SHA256SUMS.txt')) { + return http.Response(checksums, 200); + } + if (url.contains(fileName)) { + return http.Response.bytes(payload, 200); + } + return http.Response('not found', 404); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + final asset = UpdateAsset( + name: fileName, + downloadUrl: 'https://example.com/$fileName', + ); + final manifest = UpdateManifest( + version: '0.5.0', + changelog: '', + assets: [asset], + checksumsUrl: 'https://example.com/SHA256SUMS.txt', + ); + + final progress = >[]; + final file = await service.downloadAsset( + asset, + manifest: manifest, + onProgress: (received, total) => progress.add([received, total]), + ); + + expect(await file.readAsBytes(), payload); + expect(progress, isNotEmpty); + service.dispose(); + }); + + test('throws when checksum mismatches', () async { + final payload = utf8.encode('tampered'); + const fileName = 'Querya-Desktop-0.5.0-linux.zip'; + final checksums = + '${'a' * 64} $fileName\n'; // wrong hash on purpose + + final client = MockClient((request) async { + final url = request.url.toString(); + if (url.contains('SHA256SUMS.txt')) { + return http.Response(checksums, 200); + } + return http.Response.bytes(payload, 200); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + final asset = UpdateAsset( + name: fileName, + downloadUrl: 'https://example.com/$fileName', + ); + final manifest = UpdateManifest( + version: '0.5.0', + changelog: '', + assets: [asset], + checksumsUrl: 'https://example.com/SHA256SUMS.txt', + ); + + expect( + () => service.downloadAsset(asset, manifest: manifest), + throwsA(isA()), + ); + service.dispose(); + }); + }); +} + +Future _packageInfo(String version) async { + return PackageInfo( + appName: 'Querya', + packageName: 'querya_desktop', + version: version, + buildNumber: '1', + ); +} + +Map _sampleRelease({required String version}) { + return { + 'tag_name': 'v$version', + 'published_at': '2026-07-10T12:00:00Z', + 'body': 'Release notes', + 'assets': [ + { + 'name': 'Querya-Desktop-$version-linux.zip', + 'browser_download_url': + 'https://github.com/example/Querya-Desktop-$version-linux.zip', + 'size': 100, + }, + { + 'name': 'SHA256SUMS.txt', + 'browser_download_url': 'https://github.com/example/SHA256SUMS.txt', + 'size': 64, + }, + ], + }; +} diff --git a/test/core/updater/github_releases_parser_test.dart b/test/core/updater/github_releases_parser_test.dart new file mode 100644 index 00000000..c7327ccd --- /dev/null +++ b/test/core/updater/github_releases_parser_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/updater/github_releases_client.dart'; + +void main() { + group('parseGitHubRelease', () { + test('maps tag, date, changelog, assets, and checksums URL', () { + final manifest = parseGitHubRelease({ + 'tag_name': 'v0.5.0', + 'published_at': '2026-07-10T12:00:00Z', + 'body': '## Highlights\n- Auto-updater core', + 'assets': [ + { + 'name': 'Querya-Desktop-0.5.0-linux.zip', + 'browser_download_url': + 'https://github.com/example/Querya-Desktop-0.5.0-linux.zip', + 'size': 123456, + }, + { + 'name': 'SHA256SUMS.txt', + 'browser_download_url': + 'https://github.com/example/SHA256SUMS.txt', + 'size': 256, + }, + ], + }); + + expect(manifest.version, '0.5.0'); + expect(manifest.releaseDate, DateTime.parse('2026-07-10T12:00:00Z')); + expect(manifest.changelog, contains('Auto-updater core')); + expect(manifest.assets, hasLength(2)); + expect( + manifest.checksumsUrl, + 'https://github.com/example/SHA256SUMS.txt', + ); + expect( + manifest.assetNamed('Querya-Desktop-0.5.0-linux.zip')?.sizeBytes, + 123456, + ); + }); + + test('throws when tag_name is missing', () { + expect( + () => parseGitHubRelease({'body': 'x'}), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/core/updater/update_version_test.dart b/test/core/updater/update_version_test.dart new file mode 100644 index 00000000..744a6761 --- /dev/null +++ b/test/core/updater/update_version_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/updater/update_version.dart'; + +void main() { + group('UpdateVersion.tryParse', () { + test('parses stable semver and strips leading v', () { + expect(UpdateVersion.tryParse('v0.5.0')?.toString(), '0.5.0'); + expect(UpdateVersion.tryParse('1.2.3')?.isPreRelease, isFalse); + }); + + test('parses pre-release suffix', () { + final version = UpdateVersion.tryParse('0.5.0-beta.1'); + expect(version?.isPreRelease, isTrue); + expect(version?.preRelease, 'beta.1'); + }); + + test('returns null for invalid tags', () { + expect(UpdateVersion.tryParse(''), isNull); + expect(UpdateVersion.tryParse('not-a-version'), isNull); + expect(UpdateVersion.tryParse('1.2'), isNull); + }); + }); + + group('UpdateVersion.compareTo', () { + test('orders core versions numerically', () { + final a = UpdateVersion.tryParse('0.4.9')!; + final b = UpdateVersion.tryParse('0.5.0')!; + expect(a.compareTo(b), lessThan(0)); + expect(b.compareTo(a), greaterThan(0)); + }); + + test('stable release is newer than same core pre-release', () { + final stable = UpdateVersion.tryParse('1.0.0')!; + final beta = UpdateVersion.tryParse('1.0.0-beta.1')!; + expect(stable.compareTo(beta), greaterThan(0)); + expect(beta.compareTo(stable), lessThan(0)); + }); + }); + + group('UpdateVersion.isUpdateAvailable', () { + test('detects newer stable version', () { + final current = UpdateVersion.tryParse('0.4.9')!; + final candidate = UpdateVersion.tryParse('0.5.0')!; + expect( + UpdateVersion.isUpdateAvailable( + current: current, + candidate: candidate, + allowPreRelease: false, + ), + isTrue, + ); + }); + + test('ignores pre-release on stable channel', () { + final current = UpdateVersion.tryParse('0.4.9')!; + final candidate = UpdateVersion.tryParse('0.5.0-beta.1')!; + expect( + UpdateVersion.isUpdateAvailable( + current: current, + candidate: candidate, + allowPreRelease: false, + ), + isFalse, + ); + }); + + test('allows pre-release on dev channel', () { + final current = UpdateVersion.tryParse('0.4.9')!; + final candidate = UpdateVersion.tryParse('0.5.0-beta.1')!; + expect( + UpdateVersion.isUpdateAvailable( + current: current, + candidate: candidate, + allowPreRelease: true, + ), + isTrue, + ); + }); + + test('returns false when already up to date', () { + final current = UpdateVersion.tryParse('0.5.0')!; + expect( + UpdateVersion.isUpdateAvailable( + current: current, + candidate: current, + allowPreRelease: true, + ), + isFalse, + ); + }); + }); +} From 73147a5b3d3937119b421cbaafa2b435e5a124da Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 18:35:51 +0300 Subject: [PATCH 2/2] test(updater): satisfy prefer_const_constructors in updater tests --- test/core/updater/app_updater_service_test.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/core/updater/app_updater_service_test.dart b/test/core/updater/app_updater_service_test.dart index f259e121..83c7ab6f 100644 --- a/test/core/updater/app_updater_service_test.dart +++ b/test/core/updater/app_updater_service_test.dart @@ -195,11 +195,11 @@ void main() { packageInfoProvider: () async => _packageInfo('0.4.9'), ); - final asset = UpdateAsset( + const asset = UpdateAsset( name: fileName, - downloadUrl: 'https://example.com/$fileName', + downloadUrl: 'https://example.com/Querya-Desktop-0.5.0-linux.zip', ); - final manifest = UpdateManifest( + const manifest = UpdateManifest( version: '0.5.0', changelog: '', assets: [asset], @@ -238,11 +238,11 @@ void main() { packageInfoProvider: () async => _packageInfo('0.4.9'), ); - final asset = UpdateAsset( + const asset = UpdateAsset( name: fileName, - downloadUrl: 'https://example.com/$fileName', + downloadUrl: 'https://example.com/Querya-Desktop-0.5.0-linux.zip', ); - final manifest = UpdateManifest( + const manifest = UpdateManifest( version: '0.5.0', changelog: '', assets: [asset],