import 'dart:convert'; import 'dart:io'; /// Thrown when `dnf` itself fails to run (e.g. a plugin that's enabled in /// config but missing on disk) rather than genuinely finding no match. /// Distinct from "package not found" so the UI can tell a broken search /// engine apart from a bad package name. class PackageEngineException implements Exception { final String stderr; PackageEngineException(this.stderr); @override String toString() => stderr.isEmpty ? 'dnf failed to run' : stderr; } class DeploymentInfo { final String version; final String checksum; final String containerImageRef; final String? baseChecksum; final List requestedPackages; final bool booted; final bool staged; DeploymentInfo({ required this.version, required this.checksum, required this.containerImageRef, this.baseChecksum, this.requestedPackages = const [], this.booted = false, this.staged = false, }); Map toJson() => { 'version': version, 'checksum': checksum, 'containerImageRef': containerImageRef, 'baseChecksum': baseChecksum, 'requestedPackages': requestedPackages, 'booted': booted, 'staged': staged, }; factory DeploymentInfo.fromJson(Map json) => DeploymentInfo( version: json['version'] as String, checksum: json['checksum'] as String, containerImageRef: json['containerImageRef'] as String, baseChecksum: json['baseChecksum'] as String?, requestedPackages: (json['requestedPackages'] as List?)?.cast() ?? const [], booted: json['booted'] as bool? ?? false, staged: json['staged'] as bool? ?? false, ); static DeploymentInfo _fromStatusJson(Map d) => DeploymentInfo( version: d['version'] as String? ?? 'unknown', checksum: d['checksum'] as String? ?? 'unknown', containerImageRef: d['container-image-reference'] as String? ?? 'unknown', baseChecksum: d['base-checksum'] as String?, requestedPackages: (d['requested-packages'] as List?)?.cast() ?? const [], booted: d['booted'] as bool? ?? false, staged: d['staged'] as bool? ?? false, ); } class OstreeService { /// Reads the currently booted deployment via `rpm-ostree status --json` /// (unprivileged, read-only). static Future getStatus() async { final deployments = await getAllDeployments(); return deployments.firstWhere( (d) => d.booted, orElse: () => deployments.first, ); } /// Reads every deployment rpm-ostree currently knows about (booted, /// staged, rollback), not just the booted one. Used to reconcile our own /// history against reality and to pick up the freshly-staged checksum /// right after an install, before any reboot has happened. static Future> getAllDeployments() async { final result = await Process.run('rpm-ostree', ['status', '--json']); if (result.exitCode != 0) { throw Exception('rpm-ostree status failed: ${result.stderr}'); } final data = jsonDecode(result.stdout as String) as Map; final deployments = data['deployments'] as List; return deployments .cast>() .map(DeploymentInfo._fromStatusJson) .toList(); } /// Unprivileged existence check so typos surface before escalating to /// pkexec. Matches on exact name or name.arch, then falls back to /// `--whatprovides` so virtual names like "vim" (satisfied by /// vim-enhanced etc., never a package themselves) check out the same way /// `rpm-ostree install` would actually resolve them. A nonzero exit means /// dnf itself failed to run (e.g. a broken plugin) rather than "not /// found", so that's thrown instead of silently reported as false. static Future checkPackageExists(String name) async { final byName = await Process.run('dnf', ['repoquery', name]); if (byName.exitCode != 0) { throw PackageEngineException((byName.stderr as String).trim()); } if ((byName.stdout as String).trim().isNotEmpty) return true; final byProvides = await Process.run('dnf', ['repoquery', '--whatprovides', name]); if (byProvides.exitCode != 0) { throw PackageEngineException((byProvides.stderr as String).trim()); } return (byProvides.stdout as String).trim().isNotEmpty; } /// Verifies a local RPM file and resolves it to the NEVRA name /// `rpm-ostree install` will actually layer. Unlike [checkPackageExists], /// a query failure here really does mean "not a usable package" (a /// corrupt download, wrong file type, etc.) rather than a broken search /// engine, so this throws a plain [Exception] instead of /// [PackageEngineException] — the caller doesn't need to distinguish an /// engine failure for file-based installs. static Future inspectLocalRpm(String path) async { if (!await File(path).exists()) { throw Exception('No file found at "$path".'); } final result = await Process.run( 'rpm', ['-qp', '--queryformat', '%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}', path], ); if (result.exitCode != 0) { final stderr = (result.stderr as String).trim(); throw Exception(stderr.isEmpty ? 'Not a valid RPM package.' : stderr); } return (result.stdout as String).trim(); } /// Permanently layers a package via `rpm-ostree install`. [target] is /// either a repo package name or a local filesystem path to an RPM — /// `rpm-ostree install` accepts both. Streams combined stdout+stderr /// lines. Mirrors pkg_launcher's DnfService.installTransient pattern /// (pkexec through the desktop PolicyKit agent). static Future installPackage(String target, void Function(String line) onLine) async { final process = await Process.start('pkexec', ['rpm-ostree', 'install', '-y', target]); process.stdout.transform(const SystemEncoding().decoder).listen((chunk) { for (final line in chunk.split('\n')) { if (line.isNotEmpty) onLine(line); } }); process.stderr.transform(const SystemEncoding().decoder).listen((chunk) { for (final line in chunk.split('\n')) { if (line.isNotEmpty) onLine(line); } }); return process.exitCode; } /// Rolls back to the previously booted deployment and reboots into it /// immediately. Callers must warn the user before calling this: the /// screen will go black and the machine will restart as part of this /// call returning. static Future rollback(void Function(String line) onLine) async { final process = await Process.start('pkexec', ['bootc', 'rollback', '--apply']); process.stdout.transform(const SystemEncoding().decoder).listen((chunk) { for (final line in chunk.split('\n')) { if (line.isNotEmpty) onLine(line); } }); process.stderr.transform(const SystemEncoding().decoder).listen((chunk) { for (final line in chunk.split('\n')) { if (line.isNotEmpty) onLine(line); } }); return process.exitCode; } /// Reboots immediately via the desktop PolicyKit agent. static Future rebootNow() async { await Process.start('pkexec', ['systemctl', 'reboot'], mode: ProcessStartMode.detached); } }