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:
parent
d49a486b4b
commit
c879ac73f2
4 changed files with 316 additions and 27 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
127
pubspec.lock
127
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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue