Adds a package-source toggle so the wizard can take a path to a downloaded .rpm (verified via `rpm -qp` and resolved to its NEVRA) in addition to the existing repo-name search flow, since both are valid targets for `rpm-ostree install`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
181 lines
7.2 KiB
Dart
181 lines
7.2 KiB
Dart
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<String> 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<String, dynamic> toJson() => {
|
|
'version': version,
|
|
'checksum': checksum,
|
|
'containerImageRef': containerImageRef,
|
|
'baseChecksum': baseChecksum,
|
|
'requestedPackages': requestedPackages,
|
|
'booted': booted,
|
|
'staged': staged,
|
|
};
|
|
|
|
factory DeploymentInfo.fromJson(Map<String, dynamic> 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<String>() ?? const [],
|
|
booted: json['booted'] as bool? ?? false,
|
|
staged: json['staged'] as bool? ?? false,
|
|
);
|
|
|
|
static DeploymentInfo _fromStatusJson(Map<String, dynamic> 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<String>() ?? 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<DeploymentInfo> 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<List<DeploymentInfo>> 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<String, dynamic>;
|
|
final deployments = data['deployments'] as List;
|
|
return deployments
|
|
.cast<Map<String, dynamic>>()
|
|
.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<bool> 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<String> 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<int> 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<int> 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<void> rebootNow() async {
|
|
await Process.start('pkexec', ['systemctl', 'reboot'], mode: ProcessStartMode.detached);
|
|
}
|
|
}
|