Skip to content

Commit 6efbbb1

Browse files
feat(core): implement platform-specific in-place update installers (#282)
Add Linux AppImage/bundle, Windows silent exe/zip, and macOS signed .app replacement installers with detached helper scripts, snap/flatpak guards, and secure zip extraction wired into AppUpdaterService.installDownloadedUpdate.
1 parent f1be02c commit 6efbbb1

8 files changed

Lines changed: 713 additions & 5 deletions

lib/core/updater/app_updater_service.dart

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import 'package:path_provider/path_provider.dart';
88

99
import '../storage/app_settings.dart';
1010
import 'github_releases_client.dart';
11+
import 'installers/update_install_context.dart';
1112
import 'sha256_checksums.dart';
1213
import 'update_manifest.dart';
14+
import 'update_platform_installer.dart';
1315
import 'update_version.dart';
1416

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

168-
/// Phase 3 (#282) will replace this with native in-place installers.
170+
/// Installs a verified update package using the platform-specific installer.
169171
Future<void> installDownloadedUpdate(File verifiedPackage) async {
170-
throw AppUpdaterException(
171-
'In-app installation is not available yet on this platform. '
172-
'Verified package: ${verifiedPackage.path}',
173-
);
172+
await UpdatePlatformInstaller.forCurrentPlatform().install(verifiedPackage);
173+
}
174+
175+
/// Whether in-app install is blocked by the current packaging (snap/flatpak).
176+
bool get isInstallBlockedByPackageManager {
177+
final context = UpdateInstallContext.current();
178+
return context.isManagedPackage;
174179
}
175180

176181
/// Picks the platform zip for the current OS from [manifest].
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import 'dart:io';
2+
3+
import 'package:path/path.dart' as p;
4+
import 'package:path_provider/path_provider.dart';
5+
6+
import '../app_updater_service.dart';
7+
import 'update_install_context.dart';
8+
import 'update_install_utils.dart';
9+
10+
/// Linux in-place updates for AppImage builds and extracted bundle zips.
11+
class LinuxAppImageInstaller {
12+
LinuxAppImageInstaller({required this.context});
13+
14+
final UpdateInstallContext context;
15+
16+
Future<void> install(File package) async {
17+
final lower = package.path.toLowerCase();
18+
if (lower.endsWith('.zip')) {
19+
await installLinuxZipBundle(context: context, zipFile: package);
20+
return;
21+
}
22+
if (lower.endsWith('.appimage')) {
23+
await _installAppImageFile(package);
24+
return;
25+
}
26+
throw AppUpdaterException(
27+
'Unsupported Linux update package: ${p.basename(package.path)}',
28+
);
29+
}
30+
31+
static Future<void> installLinuxZipBundle({
32+
required UpdateInstallContext context,
33+
required File zipFile,
34+
}) async {
35+
final tempRoot = await getTemporaryDirectory();
36+
final extractDir = Directory(
37+
p.join(tempRoot.path, 'querya-update-${DateTime.now().millisecondsSinceEpoch}'),
38+
);
39+
40+
try {
41+
await extractZipSecurely(zipFile: zipFile, destinationDir: extractDir);
42+
43+
if (context.isLinuxAppImage) {
44+
final appImage = await findAppImageInDirectory(extractDir);
45+
if (appImage != null) {
46+
await LinuxAppImageInstaller(context: context)
47+
._installAppImageFile(appImage);
48+
return;
49+
}
50+
}
51+
52+
final targetDir = context.linuxBundleRoot;
53+
if (targetDir == null || targetDir.isEmpty) {
54+
throw const AppUpdaterException(
55+
'Could not determine the Linux install directory for in-place update',
56+
);
57+
}
58+
59+
final executable = context.resolvedExecutable;
60+
final scriptFile = File(
61+
p.join(tempRoot.path, 'querya-linux-update-$pid.sh'),
62+
);
63+
await scriptFile.writeAsString(
64+
buildLinuxBundleReplaceScript(
65+
pid: pid,
66+
sourceDir: extractDir.path,
67+
targetDir: targetDir,
68+
executable: executable,
69+
),
70+
);
71+
await launchDetachedScript(scriptFile.path, const []);
72+
exit(0);
73+
} finally {
74+
// Extract dir is consumed by the detached script; leave cleanup to the script/OS.
75+
}
76+
}
77+
78+
Future<void> _installAppImageFile(File newAppImage) async {
79+
final target = context.appImagePath;
80+
if (target == null || target.isEmpty) {
81+
throw const AppUpdaterException(
82+
'APPIMAGE path is not available; cannot perform in-place AppImage update',
83+
);
84+
}
85+
86+
final targetFile = File(target);
87+
final staged = File('$target.new');
88+
if (await staged.exists()) {
89+
await staged.delete();
90+
}
91+
await newAppImage.copy(staged.path);
92+
await Process.run('chmod', ['+x', staged.path]);
93+
94+
final tempRoot = await getTemporaryDirectory();
95+
final scriptFile = File(p.join(tempRoot.path, 'querya-appimage-update-$pid.sh'));
96+
await scriptFile.writeAsString(
97+
buildLinuxAppImageReplaceScript(
98+
pid: pid,
99+
targetAppImage: targetFile.path,
100+
stagedAppImage: staged.path,
101+
),
102+
);
103+
await launchDetachedScript(scriptFile.path, const []);
104+
exit(0);
105+
}
106+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import 'dart:io';
2+
3+
import 'package:path/path.dart' as p;
4+
import 'package:path_provider/path_provider.dart';
5+
6+
import '../app_updater_service.dart';
7+
import 'update_install_context.dart';
8+
import 'update_install_utils.dart';
9+
10+
/// macOS `.app` bundle replacement with Gatekeeper codesign verification.
11+
///
12+
/// Sparkle integration can wrap this path later; for now we verify `codesign`
13+
/// on the downloaded bundle before swapping it in place.
14+
class MacosSparkleInstaller {
15+
MacosSparkleInstaller({required this.context});
16+
17+
final UpdateInstallContext context;
18+
19+
Future<void> install(File package) async {
20+
final lower = package.path.toLowerCase();
21+
if (!lower.endsWith('.zip')) {
22+
throw AppUpdaterException(
23+
'Unsupported macOS update package: ${p.basename(package.path)}',
24+
);
25+
}
26+
27+
final targetApp = context.macAppBundlePath;
28+
if (targetApp == null || targetApp.isEmpty) {
29+
throw const AppUpdaterException(
30+
'Could not locate the running .app bundle for in-place update',
31+
);
32+
}
33+
34+
final tempRoot = await getTemporaryDirectory();
35+
final extractDir = Directory(
36+
p.join(tempRoot.path, 'querya-update-${DateTime.now().millisecondsSinceEpoch}'),
37+
);
38+
await extractZipSecurely(zipFile: package, destinationDir: extractDir);
39+
40+
final newApp = await findMacAppBundleInDirectory(extractDir);
41+
if (newApp == null) {
42+
throw const AppUpdaterException(
43+
'Downloaded macOS update zip does not contain a .app bundle',
44+
);
45+
}
46+
47+
await _verifyCodesign(newApp);
48+
49+
final scriptFile = File(p.join(tempRoot.path, 'querya-macos-update-$pid.sh'));
50+
await scriptFile.writeAsString(
51+
buildMacAppReplaceScript(
52+
pid: pid,
53+
newAppBundle: newApp.path,
54+
targetAppBundle: targetApp,
55+
executable: context.resolvedExecutable,
56+
),
57+
);
58+
await launchDetachedScript(scriptFile.path, const []);
59+
exit(0);
60+
}
61+
62+
Future<void> _verifyCodesign(Directory appBundle) async {
63+
final result = await Process.run(
64+
'codesign',
65+
['--verify', '--deep', '--strict', appBundle.path],
66+
);
67+
if (result.exitCode != 0) {
68+
final detail = (result.stderr as String?)?.trim();
69+
throw AppUpdaterException(
70+
detail == null || detail.isEmpty
71+
? 'Gatekeeper verification failed for downloaded app bundle'
72+
: 'Gatekeeper verification failed: $detail',
73+
);
74+
}
75+
}
76+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import 'dart:io';
2+
3+
import 'package:path/path.dart' as p;
4+
5+
import '../app_updater_service.dart';
6+
7+
/// Runtime packaging context for in-place update installation.
8+
class UpdateInstallContext {
9+
const UpdateInstallContext({
10+
required this.environment,
11+
required this.resolvedExecutable,
12+
});
13+
14+
final Map<String, String> environment;
15+
final String resolvedExecutable;
16+
17+
factory UpdateInstallContext.current() {
18+
return UpdateInstallContext(
19+
environment: Map.unmodifiable(Platform.environment),
20+
resolvedExecutable: Platform.resolvedExecutable,
21+
);
22+
}
23+
24+
String? get appImagePath {
25+
final fromEnv = environment['APPIMAGE'];
26+
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
27+
return null;
28+
}
29+
30+
bool get isLinuxAppImage => Platform.isLinux && appImagePath != null;
31+
32+
bool get isSnap =>
33+
Platform.isLinux && environment.containsKey('SNAP');
34+
35+
bool get isFlatpak =>
36+
Platform.isLinux &&
37+
(environment.containsKey('FLATPAK_ID') ||
38+
environment.containsKey('container'));
39+
40+
bool get isManagedPackage => isSnap || isFlatpak;
41+
42+
String? get linuxBundleRoot {
43+
if (!Platform.isLinux) return null;
44+
return p.dirname(resolvedExecutable);
45+
}
46+
47+
String? get windowsInstallRoot {
48+
if (!Platform.isWindows) return null;
49+
return p.dirname(resolvedExecutable);
50+
}
51+
52+
String? get macAppBundlePath {
53+
if (!Platform.isMacOS) return null;
54+
return macAppBundlePathFromExecutable(resolvedExecutable);
55+
}
56+
57+
/// Locates the enclosing `.app` bundle for a macOS executable path.
58+
static String? macAppBundlePathFromExecutable(String executable) {
59+
var dir = p.dirname(executable);
60+
while (dir.length > 1 && dir != '/') {
61+
if (p.basename(dir).endsWith('.app')) return dir;
62+
final parent = p.dirname(dir);
63+
if (parent == dir) break;
64+
dir = parent;
65+
}
66+
return null;
67+
}
68+
}
69+
70+
/// Thrown when updates must be applied through the system package manager.
71+
class PackageManagerUpdateRequiredException extends AppUpdaterException {
72+
PackageManagerUpdateRequiredException({
73+
required this.manager,
74+
required this.hint,
75+
}) : super(hint);
76+
77+
final String manager;
78+
final String hint;
79+
}

0 commit comments

Comments
 (0)