Add package history window and run.sh build/launch script
Shows applied dnf transactions newest-first, flagging the current state and the original release, each expandable into dnf history info. Opens as a separate OS process/window via --history rather than a page in the main app. run.sh builds and runs through a toolbox since the host's build toolchain (cmake/ninja/gtk3-devel) isn't available directly on this immutable OS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0788b01ff9
commit
f7b384506b
4 changed files with 297 additions and 2 deletions
|
|
@ -32,6 +32,22 @@ class RepoInfo {
|
||||||
RepoInfo({required this.id, required this.name});
|
RepoInfo({required this.id, required this.name});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class HistoryEntry {
|
||||||
|
final int id;
|
||||||
|
final String commandLine;
|
||||||
|
final String dateTime;
|
||||||
|
final String action;
|
||||||
|
final int altered;
|
||||||
|
|
||||||
|
HistoryEntry({
|
||||||
|
required this.id,
|
||||||
|
required this.commandLine,
|
||||||
|
required this.dateTime,
|
||||||
|
required this.action,
|
||||||
|
required this.altered,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class DnfService {
|
class DnfService {
|
||||||
/// Lists enabled repos (the "channels" a package can come from).
|
/// Lists enabled repos (the "channels" a package can come from).
|
||||||
static Future<List<RepoInfo>> listRepos() async {
|
static Future<List<RepoInfo>> listRepos() async {
|
||||||
|
|
@ -193,6 +209,44 @@ class DnfService {
|
||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lists the system's applied-transaction history (`dnf history list`),
|
||||||
|
/// newest first. Transactions are strictly sequential, so the highest id
|
||||||
|
/// is always the current state and the lowest id is the original release
|
||||||
|
/// the system was first installed from.
|
||||||
|
static Future<List<HistoryEntry>> historyList() async {
|
||||||
|
final result = await Process.run('dnf', ['history', 'list']);
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
throw Exception('dnf history list failed: ${result.stderr}');
|
||||||
|
}
|
||||||
|
final entries = <HistoryEntry>[];
|
||||||
|
for (final line in (result.stdout as String).split('\n')) {
|
||||||
|
if (!line.contains('|')) continue; // skips the dashed separator row
|
||||||
|
final parts = line.split('|').map((p) => p.trim()).toList();
|
||||||
|
if (parts.length < 5) continue;
|
||||||
|
final id = int.tryParse(parts[0]);
|
||||||
|
if (id == null) continue; // skips the header row ("ID | ...")
|
||||||
|
entries.add(HistoryEntry(
|
||||||
|
id: id,
|
||||||
|
commandLine: parts[1],
|
||||||
|
dateTime: parts[2],
|
||||||
|
action: parts[3],
|
||||||
|
altered: int.tryParse(RegExp(r'\d+').firstMatch(parts[4])?.group(0) ?? '') ?? 0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
entries.sort((a, b) => b.id.compareTo(a.id));
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full detail for one transaction (`dnf history info <id>`), i.e. the
|
||||||
|
/// individual package changes it applied.
|
||||||
|
static Future<String> historyInfo(int id) async {
|
||||||
|
final result = await Process.run('dnf', ['history', 'info', '$id']);
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
throw Exception('dnf history info failed: ${result.stderr}');
|
||||||
|
}
|
||||||
|
return result.stdout as String;
|
||||||
|
}
|
||||||
|
|
||||||
/// Launches a desktop entry (via gtk-launch) or a raw binary, detached
|
/// Launches a desktop entry (via gtk-launch) or a raw binary, detached
|
||||||
/// from this process so closing the app doesn't kill the launched one.
|
/// from this process so closing the app doesn't kill the launched one.
|
||||||
static Future<void> launch(LaunchTarget target) async {
|
static Future<void> launch(LaunchTarget target) async {
|
||||||
|
|
|
||||||
180
lib/history_page.dart
Normal file
180
lib/history_page.dart
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dnf_service.dart';
|
||||||
|
|
||||||
|
/// Standalone app (run as its own OS process/window, see main.dart's
|
||||||
|
/// `--history` flag) showing the system's dnf transaction history: every
|
||||||
|
/// applied change, which one is current, and which was the original
|
||||||
|
/// release the system was installed from.
|
||||||
|
class HistoryApp extends StatelessWidget {
|
||||||
|
const HistoryApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Package History',
|
||||||
|
theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
|
||||||
|
home: const HistoryPage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HistoryPage extends StatefulWidget {
|
||||||
|
const HistoryPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HistoryPage> createState() => _HistoryPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HistoryPageState extends State<HistoryPage> {
|
||||||
|
List<HistoryEntry>? _entries;
|
||||||
|
String? _error;
|
||||||
|
final Map<int, String> _infoCache = {};
|
||||||
|
final Set<int> _loadingInfo = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_entries = null;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final entries = await DnfService.historyList();
|
||||||
|
if (mounted) setState(() => _entries = entries);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _error = e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInfo(int id) async {
|
||||||
|
if (_infoCache.containsKey(id) || _loadingInfo.contains(id)) return;
|
||||||
|
setState(() => _loadingInfo.add(id));
|
||||||
|
try {
|
||||||
|
final info = await DnfService.historyInfo(id);
|
||||||
|
if (mounted) setState(() => _infoCache[id] = info);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _infoCache[id] = 'Failed to load: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loadingInfo.remove(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Package History'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
onPressed: _entries == null ? null : _load,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _buildBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error, color: Theme.of(context).colorScheme.error, size: 32),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(_error!, textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(onPressed: _load, child: const Text('Retry')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final entries = _entries;
|
||||||
|
if (entries == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (entries.isEmpty) {
|
||||||
|
return const Center(child: Text('No transaction history found.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentId = entries.first.id; // newest-first list
|
||||||
|
final originalId = entries.last.id;
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: entries.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final entry = entries[i];
|
||||||
|
final isCurrent = entry.id == currentId;
|
||||||
|
final isOriginal = entry.id == originalId;
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
color: isCurrent ? Theme.of(context).colorScheme.secondaryContainer : null,
|
||||||
|
child: ExpansionTile(
|
||||||
|
onExpansionChanged: (expanded) {
|
||||||
|
if (expanded) _loadInfo(entry.id);
|
||||||
|
},
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Text('#${entry.id} ${entry.action}',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (isCurrent) const _Badge(label: 'Current', color: Colors.green),
|
||||||
|
if (isOriginal) const _Badge(label: 'Original release', color: Colors.blueGrey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${entry.dateTime} · ${entry.commandLine.isEmpty ? '(no command line)' : entry.commandLine} · ${entry.altered} altered',
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: _loadingInfo.contains(entry.id)
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
)
|
||||||
|
: SelectableText(
|
||||||
|
_infoCache[entry.id] ?? '',
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Badge extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
const _Badge({required this.label, required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: color),
|
||||||
|
),
|
||||||
|
child: Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w600)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dnf_service.dart';
|
import 'dnf_service.dart';
|
||||||
import 'category_service.dart';
|
import 'category_service.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
|
import 'history_page.dart';
|
||||||
|
|
||||||
void main() {
|
void main(List<String> args) {
|
||||||
runApp(const DnfPackageStoreApp());
|
// The history browser runs as its own OS process/window rather than a
|
||||||
|
// page within this app, launched by re-invoking this same executable
|
||||||
|
// with --history (see _openHistory below).
|
||||||
|
if (args.contains('--history')) {
|
||||||
|
runApp(const HistoryApp());
|
||||||
|
} else {
|
||||||
|
runApp(const DnfPackageStoreApp());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DnfPackageStoreApp extends StatelessWidget {
|
class DnfPackageStoreApp extends StatelessWidget {
|
||||||
|
|
@ -81,6 +91,21 @@ class _SearchPageState extends State<SearchPage> {
|
||||||
if (mounted) setState(() => _categoryIndex = index);
|
if (mounted) setState(() => _categoryIndex = index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openHistory() async {
|
||||||
|
try {
|
||||||
|
await Process.start(
|
||||||
|
Platform.resolvedExecutable,
|
||||||
|
[...Platform.executableArguments, '--history'],
|
||||||
|
mode: ProcessStartMode.detached,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Failed to open history window: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _openSettings() async {
|
Future<void> _openSettings() async {
|
||||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsPage()));
|
await Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsPage()));
|
||||||
// PackageKit availability may have changed (e.g. uninstalled from Settings).
|
// PackageKit availability may have changed (e.g. uninstalled from Settings).
|
||||||
|
|
@ -279,6 +304,11 @@ class _SearchPageState extends State<SearchPage> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('DNF Package Store'),
|
title: const Text('DNF Package Store'),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.history),
|
||||||
|
tooltip: 'Package history',
|
||||||
|
onPressed: _openHistory,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
tooltip: 'Settings',
|
tooltip: 'Settings',
|
||||||
|
|
|
||||||
31
run.sh
Executable file
31
run.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds and runs the DNF Package Store Linux desktop app.
|
||||||
|
#
|
||||||
|
# The host OS is immutable (Fedora Aurora/Kinoite), so the native build
|
||||||
|
# toolchain (cmake, ninja, gtk3 headers) that `flutter build linux` needs
|
||||||
|
# lives in a toolbox instead of on the host. This script creates a default
|
||||||
|
# toolbox if none exists, installs those packages into it once, then builds
|
||||||
|
# and runs through it. The toolbox shares $HOME with the host, so the
|
||||||
|
# Flutter SDK and this project are visible from inside it without copying
|
||||||
|
# anything.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
|
||||||
|
FLUTTER=/var/home/ironmagma/Code/.flutter-sdk/bin/flutter
|
||||||
|
|
||||||
|
if ! toolbox list -c 2>/dev/null | tail -n +2 | grep -q .; then
|
||||||
|
echo "No toolbox found — creating the default one..."
|
||||||
|
toolbox create -y
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! toolbox run rpm -q cmake ninja-build gtk3-devel >/dev/null 2>&1; then
|
||||||
|
echo "Installing Linux build dependencies into the toolbox..."
|
||||||
|
toolbox run sudo dnf install -y cmake ninja-build gtk3-devel
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building..."
|
||||||
|
toolbox run "$FLUTTER" build linux --debug
|
||||||
|
|
||||||
|
BUNDLE="build/linux/x64/debug/bundle"
|
||||||
|
echo "Running $BUNDLE/dnf_package_store ${*}"
|
||||||
|
exec toolbox run "$BUNDLE/dnf_package_store" "$@"
|
||||||
Loading…
Reference in a new issue