Support installing a local .rpm file alongside name search

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>
This commit is contained in:
Philip Peterson 2026-10-10 11:54:59 -08:00
parent d49a486b4b
commit c879ac73f2
4 changed files with 316 additions and 27 deletions

View file

@ -113,11 +113,35 @@ class OstreeService {
return (byProvides.stdout as String).trim().isNotEmpty;
}
/// Permanently layers a package via `rpm-ostree install`. Streams combined
/// stdout+stderr lines. Mirrors pkg_launcher's DnfService.installTransient
/// pattern (pkexec through the desktop PolicyKit agent).
static Future<int> installPackage(String name, void Function(String line) onLine) async {
final process = await Process.start('pkexec', ['rpm-ostree', 'install', '-y', name]);
/// 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);

View file

@ -1,3 +1,6 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'confirmation_marker.dart';
@ -6,6 +9,8 @@ import 'ostree_service.dart';
enum _ApplyState { idle, running, success, failed }
enum _PackageSource { search, file }
class WizardHomePage extends StatefulWidget {
const WizardHomePage({super.key});
@ -17,12 +22,28 @@ class _WizardHomePageState extends State<WizardHomePage> {
int _currentStep = 0;
final _packageController = TextEditingController();
_PackageSource _packageSource = _PackageSource.search;
String? _selectedFilePath;
String? _resolvedFileName;
Future<DeploymentInfo>? _statusFuture;
bool _checking = false;
String? _checkError;
bool _checkErrorIsEngineFailure = false;
bool _packageVerified = false;
/// What actually gets passed to `rpm-ostree install` — a package name or
/// a local filesystem path, depending on [_packageSource].
String get _installTarget =>
_packageSource == _PackageSource.file ? (_selectedFilePath ?? '') : _packageController.text.trim();
/// What gets shown to the user and recorded in history — the resolved
/// NEVRA for a local file (readable even after the file is gone), or the
/// typed name for a search.
String get _displayName => _packageSource == _PackageSource.file
? (_resolvedFileName ?? _selectedFilePath?.split('/').last ?? '')
: _packageController.text.trim();
_ApplyState _applyState = _ApplyState.idle;
final List<String> _log = [];
final ScrollController _logScroll = ScrollController();
@ -35,6 +56,29 @@ class _WizardHomePageState extends State<WizardHomePage> {
}
Future<void> _checkPackage() async {
if (_packageSource == _PackageSource.file) {
final path = _selectedFilePath;
if (path == null) return;
setState(() {
_checking = true;
_checkError = null;
_checkErrorIsEngineFailure = false;
_packageVerified = false;
});
try {
final resolved = await OstreeService.inspectLocalRpm(path);
setState(() {
_resolvedFileName = resolved;
_packageVerified = true;
});
} catch (e) {
setState(() => _checkError = e.toString());
} finally {
setState(() => _checking = false);
}
return;
}
final name = _packageController.text.trim();
if (name.isEmpty) return;
setState(() {
@ -62,15 +106,47 @@ class _WizardHomePageState extends State<WizardHomePage> {
}
}
Future<void> _pickFile() async {
final downloads = '${Platform.environment['HOME']}/Downloads';
final file = await FilePicker.pickFile(
type: FileType.custom,
allowedExtensions: ['rpm'],
initialDirectory: await Directory(downloads).exists() ? downloads : null,
);
final path = file?.path;
if (path == null) return;
setState(() {
_selectedFilePath = path;
_resolvedFileName = null;
_packageVerified = false;
_checkError = null;
});
await _checkPackage();
}
void _setPackageSource(_PackageSource source) {
if (source == _packageSource) return;
setState(() {
_packageSource = source;
_packageVerified = false;
_checkError = null;
_checkErrorIsEngineFailure = false;
_selectedFilePath = null;
_resolvedFileName = null;
_packageController.clear();
});
}
Future<void> _startInstall() async {
final name = _packageController.text.trim();
final target = _installTarget;
final displayName = _displayName;
setState(() {
_applyState = _ApplyState.running;
_log.clear();
});
try {
_beforeInfo = await OstreeService.getStatus();
final exitCode = await OstreeService.installPackage(name, (line) {
final exitCode = await OstreeService.installPackage(target, (line) {
setState(() => _log.add(line));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_logScroll.hasClients) {
@ -79,14 +155,14 @@ class _WizardHomePageState extends State<WizardHomePage> {
});
});
if (exitCode == 0) {
await ConfirmationMarker.write(name, _beforeInfo!);
await ConfirmationMarker.write(displayName, _beforeInfo!);
await ConfirmationMarker.installAutostart();
final deployments = await OstreeService.getAllDeployments();
final staged = deployments.firstWhere(
(d) => d.staged,
orElse: () => deployments.firstWhere((d) => !d.booted, orElse: () => deployments.first),
);
await HistoryService.instance.recordInstall(packageName: name, staged: staged);
await HistoryService.instance.recordInstall(packageName: displayName, staged: staged);
setState(() => _applyState = _ApplyState.success);
} else {
setState(() => _applyState = _ApplyState.failed);
@ -198,6 +274,50 @@ class _WizardHomePageState extends State<WizardHomePage> {
}
Widget _buildPackageStep() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SegmentedButton<_PackageSource>(
segments: const [
ButtonSegment(
value: _PackageSource.search,
label: Text('Search by name'),
icon: Icon(Icons.search),
),
ButtonSegment(
value: _PackageSource.file,
label: Text('Local file (.rpm)'),
icon: Icon(Icons.insert_drive_file_outlined),
),
],
selected: {_packageSource},
onSelectionChanged: (s) => _setPackageSource(s.first),
),
const SizedBox(height: 12),
if (_packageSource == _PackageSource.search) _buildSearchInput() else _buildFileInput(),
if (_checkError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
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)),
),
],
),
),
],
);
}
Widget _buildSearchInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -225,23 +345,31 @@ class _WizardHomePageState extends State<WizardHomePage> {
const Row(children: [Icon(Icons.check_circle, color: Colors.green), SizedBox(width: 4), Text('Found')]),
],
),
if (_checkError != null)
],
);
}
Widget _buildFileInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
OutlinedButton.icon(
onPressed: _checking ? null : _pickFile,
icon: const Icon(Icons.folder_open),
label: const Text('Choose .rpm file…'),
),
const SizedBox(width: 12),
if (_checking) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
if (_packageVerified)
const Row(children: [Icon(Icons.check_circle, color: Colors.green), SizedBox(width: 4), Text('Valid RPM')]),
],
),
if (_selectedFilePath != null)
Padding(
padding: const EdgeInsets.only(top: 8),
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)),
),
],
),
child: Text(_selectedFilePath!, style: Theme.of(context).textTheme.bodySmall),
),
],
);
@ -251,7 +379,15 @@ class _WizardHomePageState extends State<WizardHomePage> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Package: ${_packageController.text.trim()}', style: const TextStyle(fontSize: 16)),
Text(
_packageSource == _PackageSource.file ? 'File: $_displayName' : 'Package: $_displayName',
style: const TextStyle(fontSize: 16),
),
if (_packageSource == _PackageSource.file && _selectedFilePath != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(_selectedFilePath!, style: Theme.of(context).textTheme.bodySmall),
),
const SizedBox(height: 12),
const Text(
'You will be prompted for your password via the system\'s '
@ -284,7 +420,7 @@ class _WizardHomePageState extends State<WizardHomePage> {
Icon(Icons.check_circle, color: Colors.green.shade600),
const SizedBox(width: 8),
Expanded(
child: Text('Restart required to use "${_packageController.text.trim()}".'),
child: Text('Restart required to use "$_displayName".'),
),
],
),
@ -303,6 +439,9 @@ class _WizardHomePageState extends State<WizardHomePage> {
_applyState = _ApplyState.idle;
_packageController.clear();
_packageVerified = false;
_selectedFilePath = null;
_resolvedFileName = null;
_packageSource = _PackageSource.search;
_statusFuture = OstreeService.getStatus();
}),
child: const Text('Reboot Later'),

View file

@ -1,6 +1,22 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
android_file_picker:
dependency: transitive
description:
name: android_file_picker
sha256: "537bce9a43bc6fc08670cb68498251acc6eb9134b86a391b43e1f7c863588388"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
@ -41,6 +57,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.dev"
source: hosted
version: "0.3.5+5"
cupertino_icons:
dependency: "direct main"
description:
@ -49,6 +73,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612
url: "https://pub.dev"
source: hosted
version: "0.8.0"
fake_async:
dependency: transitive
description:
@ -65,6 +97,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea"
url: "https://pub.dev"
source: hosted
version: "13.1.0"
file_picker_darwin:
dependency: transitive
description:
name: file_picker_darwin
sha256: "3df1dc8e881de9ffd3953c9e482ac5d6e1ffe1e7f7b231b5695543e251ab7918"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
file_picker_linux:
dependency: transitive
description:
name: file_picker_linux
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
url: "https://pub.dev"
source: hosted
version: "2.0.2"
file_picker_platform_interface:
dependency: transitive
description:
name: file_picker_platform_interface
sha256: bbdc085a6f168e63f147e9ce8988f79fa0800a58e7f25a48f762c60837107ba5
url: "https://pub.dev"
source: hosted
version: "4.0.0"
file_picker_web:
dependency: transitive
description:
name: file_picker_web
sha256: "32af8262aca907b02b8970b32328b0312db0b42ba6f930be6d27fde179d59b7d"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
flutter:
dependency: "direct main"
description: flutter
@ -83,6 +163,11 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
@ -147,6 +232,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
sky_engine:
dependency: transitive
description: flutter
@ -240,6 +341,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
url: "https://pub.dev"
source: hosted
version: "6.4.0"
windows_file_picker:
dependency: transitive
description:
name: windows_file_picker
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
sdks:
dart: ">=3.13.5 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.38.0"

View file

@ -37,6 +37,7 @@ dependencies:
# FFI bindings to libsqlite3, used for the local layer-history database.
sqlite3: ^2.4.6
file_picker: ^13.1.0
dev_dependencies:
flutter_test: