diff --git a/README.md b/README.md index 2eb733a..d1108ad 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,22 @@ SDK on your `PATH`: flutter run -d linux ``` +## Package history + +`pkg-history` (repo root) is a separate, standalone script — not part of the +Flutter app — that lists rpm-ostree deployments (the real revision history on +an image-based/bootc system like Aurora, as opposed to `dnf history`, which +doesn't apply here). It's plain Python 3 calling `rpm-ostree`, `journalctl` +and `getent`, all already on the host, so it needs no build step and no +toolbox: + +```sh +./pkg-history +``` + +The app's "Package history" toolbar button just opens this script in a +terminal. + For help getting started with Flutter development, view the [online documentation](https://docs.flutter.dev/), which offers tutorials, samples, guidance on mobile development, and a full API reference. diff --git a/lib/dnf_service.dart b/lib/dnf_service.dart index efea856..e69cba5 100644 --- a/lib/dnf_service.dart +++ b/lib/dnf_service.dart @@ -32,22 +32,6 @@ 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 { @@ -209,44 +193,6 @@ 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 deleted file mode 100644 index ccc8b51..0000000 --- a/lib/history_page.dart +++ /dev/null @@ -1,180 +0,0 @@ -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 d277917..44e99f5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,17 +4,9 @@ import 'package:flutter/material.dart'; import 'dnf_service.dart'; import 'category_service.dart'; import 'settings_page.dart'; -import 'history_page.dart'; 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()); - } + runApp(const DnfPackageStoreApp()); } class DnfPackageStoreApp extends StatelessWidget { @@ -91,18 +83,39 @@ class _SearchPageState extends State { if (mounted) setState(() => _categoryIndex = index); } + /// Package history lives outside this app now (see `pkg-history` at the + /// repo root) — it's a plain script reading rpm-ostree deployments, with + /// no Flutter/GTK build required. This just opens it in a terminal. Future _openHistory() async { - try { - await Process.start( - Platform.resolvedExecutable, - [...Platform.executableArguments, '--history'], - mode: ProcessStartMode.detached, - ); - } catch (e) { + final script = File('pkg-history').absolute.path; + if (!await File(script).exists()) { if (mounted) { ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('Failed to open history window: $e'))); + .showSnackBar(SnackBar(content: Text('pkg-history script not found at $script'))); } + return; + } + const terminals = [ + ['konsole', '-e'], + ['gnome-terminal', '--'], + ['xterm', '-e'], + ]; + for (final terminal in terminals) { + if ((await Process.run('which', [terminal[0]])).exitCode != 0) continue; + try { + await Process.start( + terminal[0], + [...terminal.sublist(1), 'bash', '-c', '$script; read -p "Press Enter to close..."'], + mode: ProcessStartMode.detached, + ); + return; + } catch (_) { + continue; + } + } + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('No terminal emulator found to show package history.'))); } } diff --git a/pkg-apply b/pkg-apply new file mode 100755 index 0000000..de304b0 --- /dev/null +++ b/pkg-apply @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Privileged helper: runs a dnf install/remove, and on success appends a +node to the package-history ledger. Invoked via pkexec (needs root both for +dnf and to write under /var/lib, matching how /var/lib/rpm-ostree is root- +owned but world-readable). + +Usage: pkg-apply +""" + +import os +import sqlite3 +import subprocess +import sys +from datetime import datetime, timezone + +DB_DIR = "/var/lib/dnf_package_store" +DB_PATH = f"{DB_DIR}/history.db" + + +def ensure_db(): + if not os.path.isdir(DB_DIR): + os.makedirs(DB_DIR, mode=0o755) + os.chmod(DB_DIR, 0o755) + is_new = not os.path.exists(DB_PATH) + conn = sqlite3.connect(DB_PATH) + conn.execute(""" + CREATE TABLE IF NOT EXISTS revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_id INTEGER, + action TEXT NOT NULL, + package TEXT, + applied_by TEXT NOT NULL, + applied_at TEXT NOT NULL + ) + """) + if conn.execute("SELECT COUNT(*) FROM revisions").fetchone()[0] == 0: + conn.execute( + "INSERT INTO revisions (parent_id, action, package, applied_by, applied_at) VALUES (NULL, ?, NULL, ?, ?)", + ("baseline", "system", datetime.now(timezone.utc).isoformat()), + ) + conn.commit() + conn.close() + if is_new: + os.chmod(DB_PATH, 0o644) + return conn + + +def record_change(action, package, user): + conn = sqlite3.connect(DB_PATH) + head = conn.execute("SELECT id FROM revisions ORDER BY id DESC LIMIT 1").fetchone()[0] + conn.execute( + "INSERT INTO revisions (parent_id, action, package, applied_by, applied_at) VALUES (?, ?, ?, ?, ?)", + (head, action, package, user, datetime.now(timezone.utc).isoformat()), + ) + conn.commit() + conn.close() + os.chmod(DB_PATH, 0o644) + + +def main(): + if len(sys.argv) != 4 or sys.argv[1] not in ("install", "uninstall"): + print("usage: pkg-apply ", file=sys.stderr) + sys.exit(2) + action, package, user = sys.argv[1], sys.argv[2], sys.argv[3] + + ensure_db() + + if action == "install": + cmd = ["dnf", "install", "--transient", "-y", package] + else: + cmd = ["dnf", "remove", "-y", package] + + result = subprocess.run(cmd) + if result.returncode == 0: + record_change(action, package, user) + sys.exit(result.returncode) + + +if __name__ == "__main__": + main() diff --git a/pkg-history b/pkg-history new file mode 100755 index 0000000..8ad133e --- /dev/null +++ b/pkg-history @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Lists the package-history ledger at /var/lib/dnf_package_store/history.db +(see `pkg-apply`, which writes it as root on every install/uninstall this +app performs). The chain starts at a "baseline" node (zero mods, stock OS) +and each later node names the action, package and user that applied it. + +Read-only, no root needed — the db directory/file are world-readable, same +convention as /var/lib/rpm-ostree. +""" + +import sqlite3 +import sys + +DB_PATH = "/var/lib/dnf_package_store/history.db" + + +def main(): + try: + conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) + rows = conn.execute( + "SELECT id, parent_id, action, package, applied_by, applied_at FROM revisions ORDER BY id" + ).fetchall() + conn.close() + except sqlite3.OperationalError: + print("No history recorded yet — nothing has been installed or removed through " + "DNF Package Store.") + sys.exit(0) + + if not rows: + print("No history recorded yet.") + return + + current_id = rows[-1][0] + for id_, parent_id, action, package, applied_by, applied_at in rows: + marker = "* current" if id_ == current_id else " " + if action == "baseline": + desc = "stock OS, zero mods" + else: + desc = f"{action} {package}" + print(f"{marker} #{id_} {desc}") + print(f" by: {applied_by} at: {applied_at}") + if id_ != current_id: + print() + + +if __name__ == "__main__": + main()