diff --git a/lib/core/updater/app_updater_service.dart b/lib/core/updater/app_updater_service.dart index 3b78cb9b..8cca2f77 100644 --- a/lib/core/updater/app_updater_service.dart +++ b/lib/core/updater/app_updater_service.dart @@ -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. @@ -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 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]. diff --git a/lib/core/updater/installers/linux_appimage_installer.dart b/lib/core/updater/installers/linux_appimage_installer.dart new file mode 100644 index 00000000..f6cdfd95 --- /dev/null +++ b/lib/core/updater/installers/linux_appimage_installer.dart @@ -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 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 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 _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); + } +} diff --git a/lib/core/updater/installers/macos_sparkle_installer.dart b/lib/core/updater/installers/macos_sparkle_installer.dart new file mode 100644 index 00000000..8028eb8b --- /dev/null +++ b/lib/core/updater/installers/macos_sparkle_installer.dart @@ -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 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 _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', + ); + } + } +} diff --git a/lib/core/updater/installers/update_install_context.dart b/lib/core/updater/installers/update_install_context.dart new file mode 100644 index 00000000..d363e741 --- /dev/null +++ b/lib/core/updater/installers/update_install_context.dart @@ -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 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; +} diff --git a/lib/core/updater/installers/update_install_utils.dart b/lib/core/updater/installers/update_install_utils.dart new file mode 100644 index 00000000..f9f2038d --- /dev/null +++ b/lib/core/updater/installers/update_install_utils.dart @@ -0,0 +1,187 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:path/path.dart' as p; + +import '../app_updater_service.dart'; + +/// Safely extracts a zip archive into [destinationDir]. +Future extractZipSecurely({ + required File zipFile, + required Directory destinationDir, +}) async { + if (await destinationDir.exists()) { + await destinationDir.delete(recursive: true); + } + await destinationDir.create(recursive: true); + + final bytes = await zipFile.readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + final root = p.normalize(destinationDir.path); + + for (final entry in archive) { + final name = entry.name; + if (name.contains('..') || name.startsWith('/') || name.startsWith('\\')) { + throw AppUpdaterException( + 'Security violation: path traversal in archive entry "$name"', + ); + } + + final targetPath = p.normalize(p.join(root, name)); + if (!targetPath.startsWith(root)) { + throw AppUpdaterException( + 'Security violation: extraction path out of bounds "$name"', + ); + } + + if (entry.isFile) { + final out = File(targetPath); + await out.parent.create(recursive: true); + await out.writeAsBytes(entry.content as List); + } else { + await Directory(targetPath).create(recursive: true); + } + } +} + +/// Finds the first `.AppImage` file inside [directory]. +Future findAppImageInDirectory(Directory directory) async { + if (!await directory.exists()) return null; + await for (final entity in directory.list(recursive: true)) { + if (entity is File && entity.path.toLowerCase().endsWith('.appimage')) { + return entity; + } + } + return null; +} + +/// Finds the first `.app` bundle directory inside [directory]. +Future findMacAppBundleInDirectory(Directory directory) async { + if (!await directory.exists()) return null; + await for (final entity in directory.list(recursive: false)) { + if (entity is Directory && entity.path.endsWith('.app')) { + return entity; + } + } + await for (final entity in directory.list(recursive: true)) { + if (entity is Directory && p.basename(entity.path).endsWith('.app')) { + return entity; + } + } + return null; +} + +/// Launches [scriptPath] detached from the current process tree. +Future launchDetachedScript(String scriptPath, List args) async { + if (Platform.isWindows) { + await Process.start( + 'cmd.exe', + ['/c', scriptPath, ...args], + mode: ProcessStartMode.detached, + ); + return; + } + + await Process.run('chmod', ['+x', scriptPath]); + await Process.start( + '/bin/sh', + [scriptPath, ...args], + mode: ProcessStartMode.detached, + ); +} + +/// Shell script that waits for [pid], syncs [sourceDir] into [targetDir], then execs [executable]. +String buildLinuxBundleReplaceScript({ + required int pid, + required String sourceDir, + required String targetDir, + required String executable, +}) { + return ''' +#!/bin/sh +set -e +PID="$pid" +SRC="${_shellQuote(sourceDir)}" +DST="${_shellQuote(targetDir)}" +EXE="${_shellQuote(executable)}" +while kill -0 "\$PID" 2>/dev/null; do sleep 0.2; done +if command -v rsync >/dev/null 2>&1; then + rsync -a --delete "\$SRC"/ "\$DST"/ +else + rm -rf "\$DST"/* + cp -a "\$SRC"/. "\$DST"/ +fi +chmod +x "\$EXE" 2>/dev/null || true +rm -f "\$0" +exec "\$EXE" +'''; +} + +String buildLinuxAppImageReplaceScript({ + required int pid, + required String targetAppImage, + required String stagedAppImage, +}) { + return ''' +#!/bin/sh +set -e +PID="$pid" +TARGET="${_shellQuote(targetAppImage)}" +STAGED="${_shellQuote(stagedAppImage)}" +while kill -0 "\$PID" 2>/dev/null; do sleep 0.2; done +mv "\$TARGET" "\$TARGET.old" 2>/dev/null || true +mv "\$STAGED" "\$TARGET" +chmod +x "\$TARGET" +rm -f "\$0" +exec "\$TARGET" +'''; +} + +String buildWindowsReplaceBatch({ + required int pid, + required String sourceDir, + required String targetDir, + required String executable, +}) { + return ''' +@echo off +set PID=$pid +set SRC=$sourceDir +set DST=$targetDir +set EXE=$targetDir\\$executable +:wait +tasklist /FI "PID eq %PID%" 2>NUL | find "%PID%" >NUL +if %ERRORLEVEL%==0 ( + timeout /t 1 /nobreak >NUL + goto wait +) +xcopy /E /Y /I "%SRC%\\*" "%DST%\\" +start "" "%EXE%" +del "%~f0" +'''; +} + +String buildMacAppReplaceScript({ + required int pid, + required String newAppBundle, + required String targetAppBundle, + required String executable, +}) { + return ''' +#!/bin/sh +set -e +PID="$pid" +NEW="${_shellQuote(newAppBundle)}" +TARGET="${_shellQuote(targetAppBundle)}" +EXE="${_shellQuote(executable)}" +while kill -0 "\$PID" 2>/dev/null; do sleep 0.2; done +rm -rf "\$TARGET.old" 2>/dev/null || true +mv "\$TARGET" "\$TARGET.old" 2>/dev/null || true +cp -R "\$NEW" "\$TARGET" +chmod +x "\$EXE" 2>/dev/null || true +rm -f "\$0" +open "\$TARGET" +'''; +} + +String _shellQuote(String value) => value.replaceAll("'", "'\\''"); diff --git a/lib/core/updater/installers/windows_exe_installer.dart b/lib/core/updater/installers/windows_exe_installer.dart new file mode 100644 index 00000000..32de0b4d --- /dev/null +++ b/lib/core/updater/installers/windows_exe_installer.dart @@ -0,0 +1,70 @@ +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'; + +/// Windows silent installer launch and zip bundle replacement. +class WindowsExeInstaller { + WindowsExeInstaller({required this.context}); + + final UpdateInstallContext context; + + Future install(File package) async { + final lower = package.path.toLowerCase(); + if (lower.endsWith('.exe') && _looksLikeSetupInstaller(package)) { + await Process.start( + package.path, + const ['/SILENT', '/NORESTART', '/CLOSEAPPLICATIONS'], + mode: ProcessStartMode.detached, + ); + exit(0); + } + + if (lower.endsWith('.zip')) { + await _installZipBundle(package); + return; + } + + throw AppUpdaterException( + 'Unsupported Windows update package: ${p.basename(package.path)}', + ); + } + + Future _installZipBundle(File zipFile) async { + final targetDir = context.windowsInstallRoot; + if (targetDir == null || targetDir.isEmpty) { + throw const AppUpdaterException( + 'Could not determine the Windows install directory for in-place update', + ); + } + + final tempRoot = await getTemporaryDirectory(); + final extractDir = Directory( + p.join(tempRoot.path, 'querya-update-${DateTime.now().millisecondsSinceEpoch}'), + ); + await extractZipSecurely(zipFile: zipFile, destinationDir: extractDir); + + final executableName = p.basename(context.resolvedExecutable); + final batchFile = File(p.join(tempRoot.path, 'querya-win-update-$pid.bat')); + await batchFile.writeAsString( + buildWindowsReplaceBatch( + pid: pid, + sourceDir: extractDir.path, + targetDir: targetDir, + executable: executableName, + ), + ); + await launchDetachedScript(batchFile.path, const []); + exit(0); + } + + bool _looksLikeSetupInstaller(File file) { + final name = p.basename(file.path).toLowerCase(); + if (name == 'querya_desktop.exe') return false; + return name.endsWith('.exe'); + } +} diff --git a/lib/core/updater/update_platform_installer.dart b/lib/core/updater/update_platform_installer.dart new file mode 100644 index 00000000..d9acfcaf --- /dev/null +++ b/lib/core/updater/update_platform_installer.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'app_updater_service.dart'; +import 'installers/linux_appimage_installer.dart'; +import 'installers/macos_sparkle_installer.dart'; +import 'installers/update_install_context.dart'; +import 'installers/windows_exe_installer.dart'; + +/// Selects and runs the platform-specific in-place update installer. +class UpdatePlatformInstaller { + const UpdatePlatformInstaller._(this._delegate); + + final Future Function(File verifiedPackage) _delegate; + + Future install(File verifiedPackage) => _delegate(verifiedPackage); + + factory UpdatePlatformInstaller.forCurrentPlatform({ + UpdateInstallContext? context, + }) { + final ctx = context ?? UpdateInstallContext.current(); + + if (ctx.isManagedPackage) { + return UpdatePlatformInstaller._((file) async { + throw PackageManagerUpdateRequiredException( + manager: ctx.isSnap ? 'snap' : 'flatpak', + hint: ctx.isSnap + ? 'Updates for the Snap build must be installed with: snap refresh' + : 'Updates for the Flatpak build must be installed with: flatpak update', + ); + }); + } + + if (Platform.isLinux) { + return UpdatePlatformInstaller._((file) async { + final lower = file.path.toLowerCase(); + if (lower.endsWith('.appimage') || ctx.isLinuxAppImage) { + await LinuxAppImageInstaller(context: ctx).install(file); + return; + } + if (lower.endsWith('.zip')) { + await LinuxAppImageInstaller.installLinuxZipBundle( + context: ctx, + zipFile: file, + ); + return; + } + throw AppUpdaterException( + 'Unsupported Linux update package: ${p.basename(file.path)}', + ); + }); + } + + if (Platform.isWindows) { + return UpdatePlatformInstaller._( + (file) => WindowsExeInstaller(context: ctx).install(file), + ); + } + + if (Platform.isMacOS) { + return UpdatePlatformInstaller._( + (file) => MacosSparkleInstaller(context: ctx).install(file), + ); + } + + return UpdatePlatformInstaller._((_) async { + throw AppUpdaterException( + 'In-app installation is not supported on ${Platform.operatingSystem}', + ); + }); + } +} diff --git a/test/core/updater/update_platform_installer_test.dart b/test/core/updater/update_platform_installer_test.dart new file mode 100644 index 00000000..18d0a2da --- /dev/null +++ b/test/core/updater/update_platform_installer_test.dart @@ -0,0 +1,112 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/updater/app_updater_service.dart'; +import 'package:querya_desktop/core/updater/installers/update_install_context.dart'; +import 'package:querya_desktop/core/updater/installers/update_install_utils.dart'; +import 'package:querya_desktop/core/updater/update_platform_installer.dart'; + +void main() { + group('UpdateInstallContext', () { + test('detects AppImage runtime from APPIMAGE env', () { + const ctx = UpdateInstallContext( + environment: {'APPIMAGE': '/opt/Querya.AppImage'}, + resolvedExecutable: '/tmp/.mount_querya/querya_desktop', + ); + expect(ctx.isLinuxAppImage, isTrue); + expect(ctx.appImagePath, '/opt/Querya.AppImage'); + }); + + test('detects snap and flatpak managed runtimes', () { + const snap = UpdateInstallContext( + environment: {'SNAP': 'querya'}, + resolvedExecutable: '/snap/bin/querya', + ); + expect(snap.isSnap, isTrue); + expect(snap.isManagedPackage, isTrue); + + const flatpak = UpdateInstallContext( + environment: {'FLATPAK_ID': 'com.querya.desktop'}, + resolvedExecutable: '/app/bin/querya_desktop', + ); + expect(flatpak.isFlatpak, isTrue); + expect(flatpak.isManagedPackage, isTrue); + }); + + test('finds macOS .app bundle from executable path', () { + expect( + UpdateInstallContext.macAppBundlePathFromExecutable( + '/Applications/Querya.app/Contents/MacOS/querya_desktop', + ), + '/Applications/Querya.app', + ); + }); + }); + + group('update install scripts', () { + test('linux bundle script waits for pid and execs target', () { + final script = buildLinuxBundleReplaceScript( + pid: 4242, + sourceDir: '/tmp/new', + targetDir: '/opt/querya', + executable: '/opt/querya/querya_desktop', + ); + expect(script, contains('PID="4242"')); + expect(script, contains('EXE="/opt/querya/querya_desktop"')); + expect(script, contains('exec "\$EXE"')); + }); + + test('windows batch script waits for pid', () { + final batch = buildWindowsReplaceBatch( + pid: 99, + sourceDir: 'C:\\tmp\\new', + targetDir: 'C:\\Querya', + executable: 'querya_desktop.exe', + ); + expect(batch, contains('set PID=99')); + expect(batch, contains('querya_desktop.exe')); + }); + }); + + group('extractZipSecurely', () { + test('rejects path traversal entries', () async { + final temp = await Directory.systemTemp.createTemp('querya_zip_test_'); + addTearDown(() async { + if (await temp.exists()) { + await temp.delete(recursive: true); + } + }); + + final zipFile = File(p.join(temp.path, 'evil.zip')); + final archive = Archive(); + archive.addFile(ArchiveFile('../outside.txt', 4, [1, 2, 3, 4])); + await zipFile.writeAsBytes(ZipEncoder().encode(archive)); + + expect( + () => extractZipSecurely( + zipFile: zipFile, + destinationDir: Directory(p.join(temp.path, 'out')), + ), + throwsA(isA()), + ); + }); + }); + + group('UpdatePlatformInstaller', () { + test('blocks install on snap with package manager hint', () async { + final installer = UpdatePlatformInstaller.forCurrentPlatform( + context: const UpdateInstallContext( + environment: {'SNAP': 'querya'}, + resolvedExecutable: '/snap/bin/querya', + ), + ); + + await expectLater( + installer.install(File('/tmp/update.zip')), + throwsA(isA()), + ); + }); + }); +}