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
15 changes: 10 additions & 5 deletions lib/core/updater/app_updater_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import 'package:path_provider/path_provider.dart';

import '../storage/app_settings.dart';
import 'github_releases_client.dart';
import 'installers/update_install_context.dart';
import 'sha256_checksums.dart';
import 'update_manifest.dart';
import 'update_platform_installer.dart';
import 'update_version.dart';

/// Core service for checking GitHub Releases and downloading verified update artifacts.
Expand Down Expand Up @@ -165,12 +167,15 @@ class AppUpdaterService {
return destination;
}

/// Phase 3 (#282) will replace this with native in-place installers.
/// Installs a verified update package using the platform-specific installer.
Future<void> installDownloadedUpdate(File verifiedPackage) async {
throw AppUpdaterException(
'In-app installation is not available yet on this platform. '
'Verified package: ${verifiedPackage.path}',
);
await UpdatePlatformInstaller.forCurrentPlatform().install(verifiedPackage);
}

/// Whether in-app install is blocked by the current packaging (snap/flatpak).
bool get isInstallBlockedByPackageManager {
final context = UpdateInstallContext.current();
return context.isManagedPackage;
}

/// Picks the platform zip for the current OS from [manifest].
Expand Down
106 changes: 106 additions & 0 deletions lib/core/updater/installers/linux_appimage_installer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import 'dart:io';

import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

import '../app_updater_service.dart';
import 'update_install_context.dart';
import 'update_install_utils.dart';

/// Linux in-place updates for AppImage builds and extracted bundle zips.
class LinuxAppImageInstaller {
LinuxAppImageInstaller({required this.context});

final UpdateInstallContext context;

Future<void> install(File package) async {
final lower = package.path.toLowerCase();
if (lower.endsWith('.zip')) {
await installLinuxZipBundle(context: context, zipFile: package);
return;
}
if (lower.endsWith('.appimage')) {
await _installAppImageFile(package);
return;
}
throw AppUpdaterException(
'Unsupported Linux update package: ${p.basename(package.path)}',
);
}

static Future<void> installLinuxZipBundle({
required UpdateInstallContext context,
required File zipFile,
}) async {
final tempRoot = await getTemporaryDirectory();
final extractDir = Directory(
p.join(tempRoot.path, 'querya-update-${DateTime.now().millisecondsSinceEpoch}'),
);

try {
await extractZipSecurely(zipFile: zipFile, destinationDir: extractDir);

if (context.isLinuxAppImage) {
final appImage = await findAppImageInDirectory(extractDir);
if (appImage != null) {
await LinuxAppImageInstaller(context: context)
._installAppImageFile(appImage);
return;
}
}

final targetDir = context.linuxBundleRoot;
if (targetDir == null || targetDir.isEmpty) {
throw const AppUpdaterException(
'Could not determine the Linux install directory for in-place update',
);
}

final executable = context.resolvedExecutable;
final scriptFile = File(
p.join(tempRoot.path, 'querya-linux-update-$pid.sh'),
);
await scriptFile.writeAsString(
buildLinuxBundleReplaceScript(
pid: pid,
sourceDir: extractDir.path,
targetDir: targetDir,
executable: executable,
),
);
await launchDetachedScript(scriptFile.path, const []);
exit(0);
} finally {
// Extract dir is consumed by the detached script; leave cleanup to the script/OS.
}
}

Future<void> _installAppImageFile(File newAppImage) async {
final target = context.appImagePath;
if (target == null || target.isEmpty) {
throw const AppUpdaterException(
'APPIMAGE path is not available; cannot perform in-place AppImage update',
);
}

final targetFile = File(target);
final staged = File('$target.new');
if (await staged.exists()) {
await staged.delete();
}
await newAppImage.copy(staged.path);
await Process.run('chmod', ['+x', staged.path]);

final tempRoot = await getTemporaryDirectory();
final scriptFile = File(p.join(tempRoot.path, 'querya-appimage-update-$pid.sh'));
await scriptFile.writeAsString(
buildLinuxAppImageReplaceScript(
pid: pid,
targetAppImage: targetFile.path,
stagedAppImage: staged.path,
),
);
await launchDetachedScript(scriptFile.path, const []);
exit(0);
}
}
76 changes: 76 additions & 0 deletions lib/core/updater/installers/macos_sparkle_installer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import 'dart:io';

import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

import '../app_updater_service.dart';
import 'update_install_context.dart';
import 'update_install_utils.dart';

/// macOS `.app` bundle replacement with Gatekeeper codesign verification.
///
/// Sparkle integration can wrap this path later; for now we verify `codesign`
/// on the downloaded bundle before swapping it in place.
class MacosSparkleInstaller {
MacosSparkleInstaller({required this.context});

final UpdateInstallContext context;

Future<void> install(File package) async {
final lower = package.path.toLowerCase();
if (!lower.endsWith('.zip')) {
throw AppUpdaterException(
'Unsupported macOS update package: ${p.basename(package.path)}',
);
}

final targetApp = context.macAppBundlePath;
if (targetApp == null || targetApp.isEmpty) {
throw const AppUpdaterException(
'Could not locate the running .app bundle for in-place update',
);
}

final tempRoot = await getTemporaryDirectory();
final extractDir = Directory(
p.join(tempRoot.path, 'querya-update-${DateTime.now().millisecondsSinceEpoch}'),
);
await extractZipSecurely(zipFile: package, destinationDir: extractDir);

final newApp = await findMacAppBundleInDirectory(extractDir);
if (newApp == null) {
throw const AppUpdaterException(
'Downloaded macOS update zip does not contain a .app bundle',
);
}

await _verifyCodesign(newApp);

final scriptFile = File(p.join(tempRoot.path, 'querya-macos-update-$pid.sh'));
await scriptFile.writeAsString(
buildMacAppReplaceScript(
pid: pid,
newAppBundle: newApp.path,
targetAppBundle: targetApp,
executable: context.resolvedExecutable,
),
);
await launchDetachedScript(scriptFile.path, const []);
exit(0);
}

Future<void> _verifyCodesign(Directory appBundle) async {
final result = await Process.run(
'codesign',
['--verify', '--deep', '--strict', appBundle.path],
);
if (result.exitCode != 0) {
final detail = (result.stderr as String?)?.trim();
throw AppUpdaterException(
detail == null || detail.isEmpty
? 'Gatekeeper verification failed for downloaded app bundle'
: 'Gatekeeper verification failed: $detail',
);
}
}
}
79 changes: 79 additions & 0 deletions lib/core/updater/installers/update_install_context.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import 'dart:io';

import 'package:path/path.dart' as p;

import '../app_updater_service.dart';

/// Runtime packaging context for in-place update installation.
class UpdateInstallContext {
const UpdateInstallContext({
required this.environment,
required this.resolvedExecutable,
});

final Map<String, String> environment;
final String resolvedExecutable;

factory UpdateInstallContext.current() {
return UpdateInstallContext(
environment: Map.unmodifiable(Platform.environment),
resolvedExecutable: Platform.resolvedExecutable,
);
}

String? get appImagePath {
final fromEnv = environment['APPIMAGE'];
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
return null;
}

bool get isLinuxAppImage => Platform.isLinux && appImagePath != null;

bool get isSnap =>
Platform.isLinux && environment.containsKey('SNAP');

bool get isFlatpak =>
Platform.isLinux &&
(environment.containsKey('FLATPAK_ID') ||
environment.containsKey('container'));

bool get isManagedPackage => isSnap || isFlatpak;

String? get linuxBundleRoot {
if (!Platform.isLinux) return null;
return p.dirname(resolvedExecutable);
}

String? get windowsInstallRoot {
if (!Platform.isWindows) return null;
return p.dirname(resolvedExecutable);
}

String? get macAppBundlePath {
if (!Platform.isMacOS) return null;
return macAppBundlePathFromExecutable(resolvedExecutable);
}

/// Locates the enclosing `.app` bundle for a macOS executable path.
static String? macAppBundlePathFromExecutable(String executable) {
var dir = p.dirname(executable);
while (dir.length > 1 && dir != '/') {
if (p.basename(dir).endsWith('.app')) return dir;
final parent = p.dirname(dir);
if (parent == dir) break;
dir = parent;
}
return null;
}
}

/// Thrown when updates must be applied through the system package manager.
class PackageManagerUpdateRequiredException extends AppUpdaterException {
PackageManagerUpdateRequiredException({
required this.manager,
required this.hint,
}) : super(hint);

final String manager;
final String hint;
}
Loading
Loading