Resolve virtual provides and surface engine failures in package check

dnf repoquery only matches exact package names, so virtual provides
like "vim" (satisfied by vim-enhanced etc.) falsely failed the Check
step even though rpm-ostree install would resolve them fine. It also
silently reported a broken dnf (e.g. a plugin enabled in config but
missing on disk) as "package not found" instead of flagging it as its
own distinct failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-10-10 10:46:35 -08:00
parent 82c03c46ac
commit d49a486b4b
2 changed files with 51 additions and 4 deletions

View file

@ -1,6 +1,18 @@
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;
@ -81,10 +93,24 @@ class OstreeService {
}
/// Unprivileged existence check so typos surface before escalating to
/// pkexec. Matches on exact name or name.arch.
/// 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 result = await Process.run('dnf', ['repoquery', name]);
return result.exitCode == 0 && (result.stdout as String).trim().isNotEmpty;
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;
}
/// Permanently layers a package via `rpm-ostree install`. Streams combined

View file

@ -20,6 +20,7 @@ class _WizardHomePageState extends State<WizardHomePage> {
Future<DeploymentInfo>? _statusFuture;
bool _checking = false;
String? _checkError;
bool _checkErrorIsEngineFailure = false;
bool _packageVerified = false;
_ApplyState _applyState = _ApplyState.idle;
@ -39,6 +40,7 @@ class _WizardHomePageState extends State<WizardHomePage> {
setState(() {
_checking = true;
_checkError = null;
_checkErrorIsEngineFailure = false;
_packageVerified = false;
});
try {
@ -47,6 +49,12 @@ class _WizardHomePageState extends State<WizardHomePage> {
_packageVerified = exists;
_checkError = exists ? null : 'No package named "$name" was found.';
});
} on PackageEngineException catch (e) {
setState(() {
_checkErrorIsEngineFailure = true;
_checkError = 'Package search isn\'t working right now, so this '
'can\'t be verified: $e';
});
} catch (e) {
setState(() => _checkError = e.toString());
} finally {
@ -220,7 +228,20 @@ class _WizardHomePageState extends State<WizardHomePage> {
if (_checkError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_checkError!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
_checkErrorIsEngineFailure ? Icons.warning_amber : Icons.info_outline,
color: Theme.of(context).colorScheme.error,
size: 18,
),
const SizedBox(width: 6),
Expanded(
child: Text(_checkError!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
],
),
),
],
);