layer-wizard/lib/wizard_page.dart
Philip Peterson c879ac73f2 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>
2026-10-10 11:54:59 -08:00

492 lines
16 KiB
Dart

import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'confirmation_marker.dart';
import 'history/history_service.dart';
import 'ostree_service.dart';
enum _ApplyState { idle, running, success, failed }
enum _PackageSource { search, file }
class WizardHomePage extends StatefulWidget {
const WizardHomePage({super.key});
@override
State<WizardHomePage> createState() => _WizardHomePageState();
}
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();
DeploymentInfo? _beforeInfo;
@override
void initState() {
super.initState();
_statusFuture = OstreeService.getStatus();
}
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(() {
_checking = true;
_checkError = null;
_checkErrorIsEngineFailure = false;
_packageVerified = false;
});
try {
final exists = await OstreeService.checkPackageExists(name);
setState(() {
_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 {
setState(() => _checking = false);
}
}
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 target = _installTarget;
final displayName = _displayName;
setState(() {
_applyState = _ApplyState.running;
_log.clear();
});
try {
_beforeInfo = await OstreeService.getStatus();
final exitCode = await OstreeService.installPackage(target, (line) {
setState(() => _log.add(line));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_logScroll.hasClients) {
_logScroll.jumpTo(_logScroll.position.maxScrollExtent);
}
});
});
if (exitCode == 0) {
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: displayName, staged: staged);
setState(() => _applyState = _ApplyState.success);
} else {
setState(() => _applyState = _ApplyState.failed);
}
} catch (e) {
setState(() {
_log.add('Error: $e');
_applyState = _ApplyState.failed;
});
}
}
void _onStepContinue() {
if (_currentStep == 1) {
if (!_packageVerified) return;
}
if (_currentStep == 2) {
setState(() => _currentStep = 3);
_startInstall();
return;
}
if (_currentStep < 2) {
setState(() => _currentStep += 1);
}
}
void _onStepCancel() {
if (_currentStep > 0 && _applyState == _ApplyState.idle) {
setState(() => _currentStep -= 1);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Layer Wizard')),
body: Stepper(
type: StepperType.horizontal,
currentStep: _currentStep,
onStepContinue: _applyState == _ApplyState.idle ? _onStepContinue : null,
onStepCancel: _applyState == _ApplyState.idle ? _onStepCancel : null,
controlsBuilder: (context, details) {
if (_currentStep == 3) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
children: [
FilledButton(
onPressed: _currentStep == 1 && !_packageVerified ? null : details.onStepContinue,
child: Text(_currentStep == 2 ? 'Install' : 'Continue'),
),
const SizedBox(width: 8),
if (_currentStep > 0)
TextButton(onPressed: details.onStepCancel, child: const Text('Back')),
],
),
);
},
steps: [
Step(
title: const Text('Welcome'),
isActive: _currentStep >= 0,
content: _buildWelcome(),
),
Step(
title: const Text('Package'),
isActive: _currentStep >= 1,
content: _buildPackageStep(),
),
Step(
title: const Text('Confirm'),
isActive: _currentStep >= 2,
content: _buildConfirmStep(),
),
Step(
title: const Text('Apply'),
isActive: _currentStep >= 3,
content: _buildApplyStep(),
),
],
),
);
}
Widget _buildWelcome() {
return FutureBuilder<DeploymentInfo>(
future: _statusFuture,
builder: (context, snapshot) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'This permanently adds a package to your system image via '
'"rpm-ostree install". Unlike a transient install, it survives '
'a reboot — but it only takes effect after you restart.',
),
const SizedBox(height: 12),
if (snapshot.hasData)
Text('Currently running: ${snapshot.data!.version}\n'
'${snapshot.data!.containerImageRef}')
else if (snapshot.hasError)
Text('Could not read system status: ${snapshot.error}')
else
const CircularProgressIndicator(),
],
);
},
);
}
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: [
TextField(
controller: _packageController,
decoration: const InputDecoration(
labelText: 'Package name',
hintText: 'e.g. calligra-words',
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() => _packageVerified = false),
onSubmitted: (_) => _checkPackage(),
),
const SizedBox(height: 8),
Row(
children: [
OutlinedButton(
onPressed: _checking ? null : _checkPackage,
child: _checking
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Check'),
),
const SizedBox(width: 12),
if (_packageVerified)
const Row(children: [Icon(Icons.check_circle, color: Colors.green), SizedBox(width: 4), Text('Found')]),
],
),
],
);
}
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: Text(_selectedFilePath!, style: Theme.of(context).textTheme.bodySmall),
),
],
);
}
Widget _buildConfirmStep() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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 '
'PolicyKit dialog. Afterward, a restart will be required before '
'the package becomes usable.',
),
],
);
}
Widget _buildApplyStep() {
switch (_applyState) {
case _ApplyState.idle:
return const Text('Waiting to start…');
case _ApplyState.running:
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const LinearProgressIndicator(),
const SizedBox(height: 12),
SizedBox(height: 240, child: _buildLog()),
],
);
case _ApplyState.success:
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.check_circle, color: Colors.green.shade600),
const SizedBox(width: 8),
Expanded(
child: Text('Restart required to use "$_displayName".'),
),
],
),
const SizedBox(height: 12),
Row(
children: [
FilledButton.icon(
onPressed: () => OstreeService.rebootNow(),
icon: const Icon(Icons.restart_alt),
label: const Text('Reboot Now'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => setState(() {
_currentStep = 0;
_applyState = _ApplyState.idle;
_packageController.clear();
_packageVerified = false;
_selectedFilePath = null;
_resolvedFileName = null;
_packageSource = _PackageSource.search;
_statusFuture = OstreeService.getStatus();
}),
child: const Text('Reboot Later'),
),
],
),
const SizedBox(height: 12),
SizedBox(height: 180, child: _buildLog()),
],
);
case _ApplyState.failed:
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.error, color: Theme.of(context).colorScheme.error),
const SizedBox(width: 8),
const Text('Install failed. Nothing was changed — no restart needed.'),
],
),
const SizedBox(height: 12),
SizedBox(height: 180, child: _buildLog()),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => setState(() => _applyState = _ApplyState.idle),
child: const Text('Try Again'),
),
],
);
}
}
Widget _buildLog() {
return Container(
color: Colors.black87,
padding: const EdgeInsets.all(8),
child: ListView.builder(
controller: _logScroll,
itemCount: _log.length,
itemBuilder: (context, i) => Text(
_log[i],
style: const TextStyle(color: Colors.white, fontFamily: 'monospace', fontSize: 12),
),
),
);
}
}