dnf-package-store/lib/dnf_service.dart
2026-10-06 09:53:34 -08:00

205 lines
7.7 KiB
Dart

import 'dart:io';
class PackageResult {
final String name;
final String arch;
final String summary;
PackageResult({required this.name, required this.arch, required this.summary});
String get nameArch => '$name.$arch';
@override
String toString() => nameArch;
}
class LaunchTarget {
final String label;
final bool isDesktopEntry;
final String idOrPath;
LaunchTarget({required this.label, required this.isDesktopEntry, required this.idOrPath});
}
class CommandLogger {
final void Function(String line) onLine;
CommandLogger(this.onLine);
}
class RepoInfo {
final String id;
final String name;
RepoInfo({required this.id, required this.name});
}
class DnfService {
/// Lists enabled repos (the "channels" a package can come from).
static Future<List<RepoInfo>> listRepos() async {
final result = await Process.run('dnf', ['repolist']);
if (result.exitCode != 0) {
throw Exception('dnf repolist failed: ${result.stderr}');
}
final repos = <RepoInfo>[];
for (final line in (result.stdout as String).split('\n')) {
if (line.startsWith('repo id')) continue; // header
final trimmed = line.trim();
if (trimmed.isEmpty) continue;
final spaceIdx = trimmed.indexOf(RegExp(r'\s'));
if (spaceIdx == -1) continue;
repos.add(RepoInfo(id: trimmed.substring(0, spaceIdx), name: trimmed.substring(spaceIdx).trim()));
}
return repos;
}
/// Searches package name+summary via `dnf search`. Only stdout is parsed;
/// repo-sync progress goes to stderr on dnf5 and is ignored here.
///
/// [repo] scopes the search to a single enabled repo/"channel". dnf5's
/// `search` subcommand does NOT actually honor `--repo` (confirmed
/// empirically: results from other repos leak through regardless), so
/// when a repo is given this instead lists that repo via `repoquery`
/// (which does filter correctly) and matches name/summary client-side.
static Future<List<PackageResult>> search(String query, {String? repo}) async {
if (repo != null) {
final all = await listAll(repo: repo);
final needle = query.toLowerCase();
return all
.where((p) =>
p.name.toLowerCase().contains(needle) || p.summary.toLowerCase().contains(needle))
.toList();
}
final result = await Process.run('dnf', ['search', query]);
if (result.exitCode != 0 && result.exitCode != 1) {
throw Exception('dnf search failed: ${result.stderr}');
}
final lines = (result.stdout as String).split('\n');
final packages = <PackageResult>[];
for (final rawLine in lines) {
if (!rawLine.startsWith(' ')) continue;
final line = rawLine.trim();
if (line.isEmpty) continue;
final splitIdx = line.indexOf(RegExp(r'\s'));
if (splitIdx == -1) continue;
final nameArch = line.substring(0, splitIdx);
final summary = line.substring(splitIdx).trim();
final dotIdx = nameArch.lastIndexOf('.');
if (dotIdx == -1) continue;
packages.add(PackageResult(
name: nameArch.substring(0, dotIdx),
arch: nameArch.substring(dotIdx + 1),
summary: summary,
));
}
return packages;
}
/// Lists every available package (not filtered by a search term). Uses
/// repoquery with an explicit pipe-delimited, newline-terminated format
/// for clean parsing — unlike `dnf search`'s free-text layout. Pass
/// [repo] to scope to a single enabled repo/"channel" instead of mixing
/// results from all of them.
static Future<List<PackageResult>> listAll({String? repo}) async {
final result = await Process.run(
'dnf',
['repoquery', if (repo != null) '--repo=$repo', '--available', '--qf', '%{name}.%{arch}|%{summary}\n'],
);
if (result.exitCode != 0) {
throw Exception('dnf repoquery failed: ${result.stderr}');
}
final lines = (result.stdout as String).split('\n');
final packages = <PackageResult>[];
for (final line in lines) {
if (line.isEmpty) continue;
final barIdx = line.indexOf('|');
if (barIdx == -1) continue;
final nameArch = line.substring(0, barIdx);
final summary = line.substring(barIdx + 1);
final dotIdx = nameArch.lastIndexOf('.');
if (dotIdx == -1) continue;
packages.add(PackageResult(
name: nameArch.substring(0, dotIdx),
arch: nameArch.substring(dotIdx + 1),
summary: summary,
));
}
return packages;
}
/// Installs a package transiently (overlay reset on reboot) via pkexec,
/// which prompts through the desktop PolicyKit agent rather than needing
/// a terminal TTY for sudo. Streams combined stdout+stderr lines.
static Future<int> installTransient(String packageNameArch, void Function(String line) onLine) async {
final process = await Process.start(
'pkexec',
['dnf', 'install', '--transient', '-y', packageNameArch],
);
process.stdout.transform(const SystemEncoding().decoder).listen((chunk) {
for (final line in chunk.split('\n')) {
if (line.isNotEmpty) onLine(line);
}
});
process.stderr.transform(const SystemEncoding().decoder).listen((chunk) {
for (final line in chunk.split('\n')) {
if (line.isNotEmpty) onLine(line);
}
});
return process.exitCode;
}
/// Removes an installed package via pkexec. Works the same whether the
/// package was installed transiently or persistently — this just frees
/// it up immediately rather than waiting for the transient overlay to
/// reset on reboot. Streams combined stdout+stderr lines.
static Future<int> uninstall(String packageNameArch, void Function(String line) onLine) async {
final process = await Process.start(
'pkexec',
['dnf', 'remove', '-y', packageNameArch],
);
process.stdout.transform(const SystemEncoding().decoder).listen((chunk) {
for (final line in chunk.split('\n')) {
if (line.isNotEmpty) onLine(line);
}
});
process.stderr.transform(const SystemEncoding().decoder).listen((chunk) {
for (final line in chunk.split('\n')) {
if (line.isNotEmpty) onLine(line);
}
});
return process.exitCode;
}
/// Finds runnable launch targets for an installed package: prefers
/// .desktop entries (launched via gtk-launch) and falls back to raw
/// executables under a bin/ directory.
static Future<List<LaunchTarget>> findLaunchTargets(String packageName) async {
final result = await Process.run('rpm', ['-ql', packageName]);
if (result.exitCode != 0) {
throw Exception('rpm -ql failed: ${result.stderr}');
}
final files = (result.stdout as String).split('\n').where((f) => f.isNotEmpty);
final targets = <LaunchTarget>[];
for (final file in files) {
if (file.endsWith('.desktop') && file.contains('/applications/')) {
final appId = file.split('/').last.replaceAll('.desktop', '');
targets.add(LaunchTarget(label: appId, isDesktopEntry: true, idOrPath: appId));
} else if (RegExp(r'/s?bin/').hasMatch(file) && await File(file).exists()) {
final stat = await File(file).stat();
final isExecutable = stat.mode & 0x49 != 0; // any execute bit
if (isExecutable) {
targets.add(LaunchTarget(label: file.split('/').last, isDesktopEntry: false, idOrPath: file));
}
}
}
return targets;
}
/// 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 {
if (target.isDesktopEntry) {
await Process.start('gtk-launch', [target.idOrPath], mode: ProcessStartMode.detached);
} else {
await Process.start(target.idOrPath, [], mode: ProcessStartMode.detached);
}
}
}