Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions lib/core/storage/app_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -550,4 +553,40 @@ class AppSettings {
await LocalDb.instance.setAppSetting(AppSettingsKeys.motionLevel, stored);
AppSettingsRevision.bump();
}

/// Update distribution channel (`stable` hides pre-releases).
Future<UpdateChannel> getUpdateChannel() async {
final v =
await LocalDb.instance.getAppSetting(AppSettingsKeys.updateChannel);
return switch (v) {
'dev' => UpdateChannel.dev,
_ => UpdateChannel.stable,
};
}

Future<void> 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<bool> getCheckForUpdatesOnStartup() async {
final v = await LocalDb.instance.getAppSetting(
AppSettingsKeys.checkForUpdatesOnStartup,
);
if (v == null || v.isEmpty) return true;
return v == 'true' || v == '1';
}

Future<void> setCheckForUpdatesOnStartup(bool enabled) async {
await LocalDb.instance.setAppSetting(
AppSettingsKeys.checkForUpdatesOnStartup,
enabled ? 'true' : 'false',
);
AppSettingsRevision.bump();
}
}
211 changes: 211 additions & 0 deletions lib/core/updater/app_updater_service.dart
Original file line number Diff line number Diff line change
@@ -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<PackageInfo> 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<PackageInfo> 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<UpdateCheckResult> 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<UpdateCheckResult?> 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<File> 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<Map<String, String>> _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)';
}
}
129 changes: 129 additions & 0 deletions lib/core/updater/github_releases_client.dart
Original file line number Diff line number Diff line change
@@ -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<UpdateManifest> 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<String, dynamic>) {
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<String, dynamic>) continue;
if (entry['draft'] == true) continue;
return parseGitHubRelease(entry);
}

throw const GitHubReleasesException('No published releases found');
}

Future<String> 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<String, dynamic> 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 = <UpdateAsset>[];
String? checksumsUrl;

if (rawAssets is List) {
for (final raw in rawAssets) {
if (raw is! Map<String, dynamic>) 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';
}
Loading
Loading