diff --git a/lib/dnf_service.dart b/lib/dnf_service.dart index e69cba5..efea856 100644 --- a/lib/dnf_service.dart +++ b/lib/dnf_service.dart @@ -32,6 +32,22 @@ class RepoInfo { 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 { /// Lists enabled repos (the "channels" a package can come from). static Future> listRepos() async { @@ -193,6 +209,44 @@ class DnfService { 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> historyList() async { + final result = await Process.run('dnf', ['history', 'list']); + if (result.exitCode != 0) { + throw Exception('dnf history list failed: ${result.stderr}'); + } + final entries = []; + 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 `), i.e. the + /// individual package changes it applied. + static Future 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 /// from this process so closing the app doesn't kill the launched one. static Future launch(LaunchTarget target) async { diff --git a/lib/history_page.dart b/lib/history_page.dart new file mode 100644 index 0000000..ccc8b51 --- /dev/null +++ b/lib/history_page.dart @@ -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 createState() => _HistoryPageState(); +} + +class _HistoryPageState extends State { + List? _entries; + String? _error; + final Map _infoCache = {}; + final Set _loadingInfo = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _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)), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 1dad0fe..d277917 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,20 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'dnf_service.dart'; import 'category_service.dart'; import 'settings_page.dart'; +import 'history_page.dart'; -void main() { - runApp(const DnfPackageStoreApp()); +void main(List args) { + // 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 { @@ -81,6 +91,21 @@ class _SearchPageState extends State { if (mounted) setState(() => _categoryIndex = index); } + Future _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 _openSettings() async { await Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsPage())); // PackageKit availability may have changed (e.g. uninstalled from Settings). @@ -279,6 +304,11 @@ class _SearchPageState extends State { appBar: AppBar( title: const Text('DNF Package Store'), actions: [ + IconButton( + icon: const Icon(Icons.history), + tooltip: 'Package history', + onPressed: _openHistory, + ), IconButton( icon: const Icon(Icons.settings), tooltip: 'Settings', diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..9928c18 --- /dev/null +++ b/run.sh @@ -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" "$@"