Try to add history

This commit is contained in:
Philip Peterson 2026-10-06 09:53:34 -08:00
parent f7b384506b
commit 50669e79c5
6 changed files with 173 additions and 251 deletions

View file

@ -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.

View file

@ -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<List<RepoInfo>> 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<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
/// from this process so closing the app doesn't kill the launched one.
static Future<void> launch(LaunchTarget target) async {

View file

@ -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<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)),
);
}
}

View file

@ -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<String> 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 {
@ -91,18 +83,39 @@ class _SearchPageState extends State<SearchPage> {
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<void> _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.')));
}
}

80
pkg-apply Executable file
View file

@ -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 <install|uninstall> <package.arch> <acting-user>
"""
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 <install|uninstall> <package.arch> <acting-user>", 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()

47
pkg-history Executable file
View file

@ -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()