From c879ac73f29bc3c1fdc8953cfb4c5391ee3b5fd7 Mon Sep 17 00:00:00 2001 From: Philip Peterson <1326208+philip-peterson@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:54:59 -0800 Subject: [PATCH] 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 --- lib/ostree_service.dart | 34 ++++++-- lib/wizard_page.dart | 181 +++++++++++++++++++++++++++++++++++----- pubspec.lock | 127 +++++++++++++++++++++++++++- pubspec.yaml | 1 + 4 files changed, 316 insertions(+), 27 deletions(-) diff --git a/lib/ostree_service.dart b/lib/ostree_service.dart index d34e06a..000b268 100644 --- a/lib/ostree_service.dart +++ b/lib/ostree_service.dart @@ -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 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 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); diff --git a/lib/wizard_page.dart b/lib/wizard_page.dart index 3f9384f..ccd7c74 100644 --- a/lib/wizard_page.dart +++ b/lib/wizard_page.dart @@ -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 { int _currentStep = 0; final _packageController = TextEditingController(); + _PackageSource _packageSource = _PackageSource.search; + String? _selectedFilePath; + String? _resolvedFileName; + Future? _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 _log = []; final ScrollController _logScroll = ScrollController(); @@ -35,6 +56,29 @@ class _WizardHomePageState extends State { } Future _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 { } } + Future _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 _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 { }); }); 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 { } 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 { 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 { 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 { 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 { _applyState = _ApplyState.idle; _packageController.clear(); _packageVerified = false; + _selectedFilePath = null; + _resolvedFileName = null; + _packageSource = _PackageSource.search; _statusFuture = OstreeService.getStatus(); }), child: const Text('Reboot Later'), diff --git a/pubspec.lock b/pubspec.lock index d6aee6f..480ac3e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index 9992125..87e7808 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: