Try to add history
This commit is contained in:
parent
f7b384506b
commit
50669e79c5
6 changed files with 173 additions and 251 deletions
16
README.md
16
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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
80
pkg-apply
Executable 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
47
pkg-history
Executable 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()
|
||||
Loading…
Reference in a new issue