From 388811df5f95284e79df495a888496fea5be8207 Mon Sep 17 00:00:00 2001 From: ElGregor Date: Thu, 24 Sep 2026 19:05:11 +0200 Subject: [PATCH 1/8] fix(launcher): start the XDG default browser instead of the first listed binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launch chain (V0.8.2, #33/#34): preferred_browser (allowlist-validated, WP-B1/#47 — trim, kKnownBrowsers membership, paths rejected at the use site) → XDG default browser (xdg-settings + .desktop existence check over XDG_DATA_HOME/XDG_DATA_DIRS, then detached gtk-launch, xdg-open for URLs) → kKnownBrowsers list as last fallback. Hub snackbars: info only on the list fallback, error on total failure; docs updated to the new chain. TDD: RED compile gate (outputReader) → GREEN 23/23 launcher tests; suite 198/198, analyze 0, python 49/49, check-versions ok. Co-Authored-By: ZCode --- docs/design/admin-hub-followups.md | 2 +- docs/design/feature-spec-admin-hub.md | 6 + docs/handoff/panels/hub-shell.md | 2 +- docs/wiki/Admin-Hub.md | 10 +- lib/layouts/hub/hub_shell.dart | 10 +- lib/services/app_launcher.dart | 151 +++++++++++++--- test/app_launcher_test.dart | 238 ++++++++++++++++++++++---- 7 files changed, 351 insertions(+), 68 deletions(-) diff --git a/docs/design/admin-hub-followups.md b/docs/design/admin-hub-followups.md index 1db0f6e..f97fca3 100644 --- a/docs/design/admin-hub-followups.md +++ b/docs/design/admin-hub-followups.md @@ -20,7 +20,7 @@ flutter test Manuelle Checks: - [ ] WERKZEUGE-Sektion erscheint in der Sidebar (nicht collapsed) -- [ ] Browser-Klick startet Brave/xdg-open, ändert die Section nicht +- [ ] Browser-Klick startet preferred/XDG-Standardbrowser (gtk-launch bzw. xdg-open), ändert die Section nicht - [ ] Quick Notes: anlegen, tippen (Autosave 500 ms), Section wechseln, zurück → Inhalt + Selektion bleiben - [ ] Dateimanager: navigieren, Datei öffnen, Löschen mit Confirm (voller diff --git a/docs/design/feature-spec-admin-hub.md b/docs/design/feature-spec-admin-hub.md index 1d58d65..db8fee6 100644 --- a/docs/design/feature-spec-admin-hub.md +++ b/docs/design/feature-spec-admin-hub.md @@ -41,6 +41,12 @@ visuell abgesetzt (Sektions-Label in `textDim`, 12px, uppercase, wie ## 2. Feature 1: Browser-Verknüpfung (Brave) +> **Überholt (DR) seit V0.8.2:** Der Launch geht jetzt über `preferred_browser` +> (Allowlist-geprüft, WP-B1/#47) → XDG-Standardbrowser (`xdg-settings` + +> `.desktop`-Prüfung, `gtk-launch`/`xdg-open`) → `kKnownBrowsers`-Liste als +> letzter Fallback. Aktueller Stand: `docs/wiki/Admin-Hub.md` (E1). Der +> folgende Abschnitt beschreibt den historischen Erststand. + ### Verhalten - Klick auf „Browser" → öffnet Brave als externen Prozess - Fallback-Kette: `brave` → `brave-browser` → `xdg-open https://` diff --git a/docs/handoff/panels/hub-shell.md b/docs/handoff/panels/hub-shell.md index b928a2c..ee69b08 100644 --- a/docs/handoff/panels/hub-shell.md +++ b/docs/handoff/panels/hub-shell.md @@ -52,7 +52,7 @@ Registrierung erfolgt **statisch über zwei Enums**, nicht über eine Laufzeit-R 3. Routing: `_onToolTap()` (:417-432) verzweigt — `browser` → `_launchBrowser()` (:171-187), sonst → `_selectTool()` (:156-164). 4. Content: `_contentFor()` mappt `HubTool` auf Seite — `QuickNotesPage` (:287-288), `FileManagerPage` (:289-290), `SystemMonitorPage` (:291-292); `browser` wird nie Content (`SizedBox.shrink()`, :293-296). -**E1 Browser-Launcher:** kein eigenes Panel. `AppLauncher.launchBrowser()` (`services/app_launcher.dart:37, :101`, Kandidaten inkl. `brave`/`brave-browser` :21-22) startet detached; Feedback über `BrowserLaunchResult` → Snackbars nur bei Fallback („Brave nicht gefunden – Standard-Browser geöffnet.") und Fehler (:174-186). +**E1 Browser-Launcher:** kein eigenes Panel. `AppLauncher.launchBrowser()` (`services/app_launcher.dart:44, :173`; XDG-Stufe `xdg-settings` + `.desktop`-Prüfung + `gtk-launch`/`xdg-open`, letzter Fallback `kKnownBrowsers` :21-28) startet detached; Feedback über `BrowserLaunchResult` → Snackbars nur bei Listen-Fallback („Standard-Browser nicht ermittelbar – ersatzweise bekannten Browser gestartet.") und Fehler (:174-186). **E2 Quick Notes:** `layouts/tools/quick_notes.dart` (`QuickNotesPage`, :13) — nutzt `MintYText.mono` für das Textfeld (:298, 301). diff --git a/docs/wiki/Admin-Hub.md b/docs/wiki/Admin-Hub.md index 3d0bf6a..b6f3590 100644 --- a/docs/wiki/Admin-Hub.md +++ b/docs/wiki/Admin-Hub.md @@ -17,14 +17,16 @@ Die Sidebar hat seit v0.7.2 eine Sektion **WERKZEUGE** mit vier Einträgen: ## Browser (E1) Klick startet den Browser als **detached Prozess** – die Hub-Section ändert -sich nicht. Fallback-Kette in `lib/services/app_launcher.dart`: +sich nicht. Start-Kette in `lib/services/app_launcher.dart` (seit V0.8.2): ``` -brave → brave-browser → xdg-open https:// +preferred_browser (Allowlist-geprüft) → XDG-Standardbrowser +(xdg-settings + .desktop-Prüfung; gtk-launch bzw. xdg-open für URLs) +→ bekannte Browser-Binaries (brave → … → falkon) ``` -Feedback: still bei Erfolg, Info-Snackbar beim xdg-open-Fallback, -Fehler-Snackbar wenn gar kein Browser gefunden wurde. +Feedback: still bei preferred/XDG-Start, Info-Snackbar beim Binary-Listen- +Fallback, Fehler-Snackbar wenn gar kein Browser gefunden wurde. ## Quick Notes (E2) diff --git a/lib/layouts/hub/hub_shell.dart b/lib/layouts/hub/hub_shell.dart index e87864d..6b386b3 100644 --- a/lib/layouts/hub/hub_shell.dart +++ b/lib/layouts/hub/hub_shell.dart @@ -163,11 +163,11 @@ class _HubShellState extends State SystemStatsService().setSectionActive(false); } - /// Starts the configured (or detected) browser as a detached process. + /// Starts the configured or XDG default browser as a detached process. /// /// Feedback mirrors the [BrowserLaunchResult]: silent on preferred launch, - /// informational snackbar on the xdg-open fallback, error snackbar when no - /// browser could be found or started at all. + /// informational snackbar on the known-browsers list fallback, error + /// snackbar when no browser could be found or started at all. Future _launchBrowser() async { final result = await AppLauncher.launchBrowser(); if (!mounted) return; @@ -176,8 +176,8 @@ class _HubShellState extends State break; // Nothing to report – the browser window is the feedback. case BrowserLaunchResult.launchedFallback: _showSnack(_tr(context, - de: 'Brave nicht gefunden – Standard-Browser geöffnet.', - en: 'Brave not found – opened the default browser.')); + de: 'Standard-Browser nicht ermittelbar – ersatzweise bekannten Browser gestartet.', + en: 'Default browser unavailable – started a known browser instead.')); break; case BrowserLaunchResult.failed: _showSnack(_tr(context, diff --git a/lib/services/app_launcher.dart b/lib/services/app_launcher.dart index 3b8ec17..d50a124 100644 --- a/lib/services/app_launcher.dart +++ b/lib/services/app_launcher.dart @@ -1,22 +1,23 @@ import 'dart:io'; +import '../helpers/command_helper.dart'; import 'config_handler.dart'; /// Ergebnis eines Browser-Launches. enum BrowserLaunchResult { - /// Konfigurierter/Bevorzugter Browser wurde gestartet. + /// Konfigurierter oder XDG-Standardbrowser wurde gestartet. launchedPreferred, - /// Fallback auf Standard-Browser via xdg-open. + /// Fallback auf eine bekannte Browser-Binary aus der Liste. launchedFallback, /// Nichts gefunden – UI sollte Fehlermeldung zeigen. failed, } -/// Bekannte Browser-Binaries in Prioritätsreihenfolge. -/// Der konfigurierte Browser (ConfigHandler-Key `preferred_browser`) -/// wird dieser Liste vorangestellt. +/// Bekannte Browser-Binaries in Prioritätsreihenfolge (letzter Fallback). +/// Der validierte Konfigurationswert (`preferred_browser`) wird dieser +/// Liste vorgestellt. const List kKnownBrowsers = [ 'brave', 'brave-browser', @@ -29,11 +30,17 @@ const List kKnownBrowsers = [ /// Startet externe Anwendungen (Browser etc.) detached vom App-Prozess. /// +/// Browser-Priorität (Release V0.8.2): `preferred_browser` (Allowlist- +/// geprüft) → XDG-Standardbrowser (`xdg-settings` + `.desktop`-Prüfung, +/// dann detached `gtk-launch` beziehungsweise `xdg-open` für URLs) → +/// [kKnownBrowsers] als letzter Fallback. +/// /// CLI-first: nutzt `which` zur Erkennung und startet Prozesse mit /// [ProcessStartMode.detached], damit die App nicht blockiert und der /// Browser die App überlebt. /// -/// Testbar: [whichRunner] und [processStarter] sind injizierbar. +/// Testbar: [whichRunner], [processStarter], [outputReader], +/// [desktopEntryExists] und [configuredBrowser] sind injizierbar. class AppLauncher { /// Signatur für `which`-Aufrufe (injizierbar für Tests). static Future Function(String binary) _which = _defaultWhich; @@ -42,19 +49,39 @@ class AppLauncher { static Future Function(String binary, List args) _starter = _defaultStarter; - /// Überschreibt Prozess-Aufrufe – nur für Tests verwenden! + /// Signatur für Befehle, deren Ausgabe gebraucht wird (xdg-settings). + static Future Function(String cmd, List args) _outputReader = + _defaultOutputReader; + + /// Signatur für die .desktop-Existenzprüfung (injizierbar für Tests). + static bool Function(String desktopId) _desktopEntryExists = + desktopEntryExistsIn; + + /// Signatur für den Config-Zugriff auf `preferred_browser`. + static String? Function() _configuredBrowser = _defaultConfiguredBrowser; + + /// Überschreibt Prozess- und Config-Zugriffe – nur für Tests verwenden! static void debugOverride({ Future Function(String binary)? whichRunner, Future Function(String binary, List args)? processStarter, + Future Function(String cmd, List args)? outputReader, + bool Function(String desktopId)? desktopEntryExists, + String? Function()? configuredBrowser, }) { _which = whichRunner ?? _defaultWhich; _starter = processStarter ?? _defaultStarter; + _outputReader = outputReader ?? _defaultOutputReader; + _desktopEntryExists = desktopEntryExists ?? desktopEntryExistsIn; + _configuredBrowser = configuredBrowser ?? _defaultConfiguredBrowser; } /// Setzt Test-Overrides zurück. static void resetOverrides() { _which = _defaultWhich; _starter = _defaultStarter; + _outputReader = _defaultOutputReader; + _desktopEntryExists = desktopEntryExistsIn; + _configuredBrowser = _defaultConfiguredBrowser; } static Future _defaultWhich(String binary) async { @@ -75,13 +102,21 @@ class AppLauncher { } } + static Future _defaultOutputReader( + String cmd, List args) async { + final result = await CommandHelper.runWithArguments(cmd, args); + if (!result.success) return null; + final output = result.output.trim(); + return output.isEmpty ? null : output; + } + /// Prüft, ob ein Binary im PATH verfügbar ist. static Future isAvailable(String binary) => _which(binary); /// Gibt den ersten verfügbaren Browser aus der Prioritätsliste zurück. - /// Berücksichtigt den konfigurierten Browser (`preferred_browser`). + /// Berücksichtigt den validierten Konfigurationswert (`preferred_browser`). static Future detectBrowser() async { - final configured = _configuredBrowser(); + final configured = sanitizePreferredBrowser(_configuredBrowser()); final candidates = [ if (configured != null && configured.isNotEmpty) configured, ...kKnownBrowsers, @@ -92,22 +127,78 @@ class AppLauncher { return null; } + /// Validiert den rohen `preferred_browser`-Config-Wert (WP-B1, #47): + /// trim, Allowlist-Membership in [kKnownBrowsers], Pfade abgelehnt. + /// Ungültige Werte ergeben `null` → stiller Fallback auf die XDG-Stufe. + static String? sanitizePreferredBrowser(String? raw) { + if (raw == null) return null; + final value = raw.trim(); + if (value.isEmpty || value.contains('/')) return null; + return kKnownBrowsers.contains(value) ? value : null; + } + + /// Liest die Desktop-ID des XDG-Standardbrowsers via `xdg-settings`. + /// Ungültige oder fehlende Antworten ergeben `null`. + static Future defaultBrowserDesktopId() async { + final output = + await _outputReader('xdg-settings', ['get', 'default-web-browser']); + if (output == null) return null; + final desktopId = output.trim(); + return _validDesktopId(desktopId) ? desktopId : null; + } + + /// Prüft, ob eine .desktop-Datei unter `/applications/` + /// existiert. Ohne [xdgDataDirs] werden XDG_DATA_HOME und XDG_DATA_DIRS + /// aus der Umgebung gelesen (Defaults: `~/.local/share` und + /// `/usr/local/share:/usr/share`). + static bool desktopEntryExistsIn(String desktopId, + {List? xdgDataDirs}) { + if (!_validDesktopId(desktopId)) return false; + final dirs = xdgDataDirs ?? _defaultXdgDataDirs(); + for (final dir in dirs) { + if (File('$dir/applications/$desktopId').existsSync()) return true; + } + return false; + } + /// Startet den Browser. [url] ist optional (Default: Startseite). /// - /// Ablauf gemäß Admin-Hub-Spec (E1): - /// 1. Konfigurierter/erster verfügbarer Browser → [launchedPreferred] - /// 2. Fallback `xdg-open` → [launchedFallback] - /// 3. Alles fehlgeschlagen → [failed] + /// Ablauf (Release V0.8.2): + /// 1. Validierter `preferred_browser` → [BrowserLaunchResult.launchedPreferred] + /// 2. XDG-Standardbrowser: `xdg-settings` + `.desktop`-Prüfung, dann + /// `gtk-launch` (ohne URL) beziehungsweise `xdg-open` (mit URL) + /// → [BrowserLaunchResult.launchedPreferred] + /// 3. Erste verfügbare Binary aus [kKnownBrowsers] + /// → [BrowserLaunchResult.launchedFallback] + /// 4. Alles fehlgeschlagen → [BrowserLaunchResult.failed] static Future launchBrowser({String? url}) async { final args = url != null ? [url] : []; - final browser = await detectBrowser(); - if (browser != null && await _starter(browser, args)) { - return BrowserLaunchResult.launchedPreferred; + final preferred = sanitizePreferredBrowser(_configuredBrowser()); + if (preferred != null && await _which(preferred)) { + if (await _starter(preferred, args)) { + return BrowserLaunchResult.launchedPreferred; + } + } + + final desktopId = await defaultBrowserDesktopId(); + if (desktopId != null && _desktopEntryExists(desktopId)) { + if (url != null) { + if (await _starter('xdg-open', [url])) { + return BrowserLaunchResult.launchedPreferred; + } + } else { + final appId = desktopId.endsWith('.desktop') + ? desktopId.substring(0, desktopId.length - '.desktop'.length) + : desktopId; + if (await _starter('gtk-launch', [appId])) { + return BrowserLaunchResult.launchedPreferred; + } + } } - // Fallback: Standard-Browser des Systems. - if (await _starter('xdg-open', [url ?? 'https://'])) { + final fallback = await detectBrowser(); + if (fallback != null && await _starter(fallback, args)) { return BrowserLaunchResult.launchedFallback; } return BrowserLaunchResult.failed; @@ -119,10 +210,28 @@ class AppLauncher { return _starter(binary, args); } - static String? _configuredBrowser() { + /// Desktop-IDs sind Dateinamen ohne Pfadtrenner und ohne `..`-Sprünge. + static bool _validDesktopId(String desktopId) { + if (!desktopId.endsWith('.desktop')) return false; + if (desktopId.contains('/') || desktopId.contains('..')) return false; + return RegExp(r'^[A-Za-z0-9][A-Za-z0-9._-]*\.desktop$').hasMatch(desktopId); + } + + static List _defaultXdgDataDirs() { + final env = Platform.environment; + final home = env['HOME']; + final dataHome = + env['XDG_DATA_HOME'] ?? (home != null ? '$home/.local/share' : null); + final dirsRaw = env['XDG_DATA_DIRS'] ?? '/usr/local/share:/usr/share'; + return [ + if (dataHome != null) dataHome, + ...dirsRaw.split(':').where((dir) => dir.isNotEmpty), + ]; + } + + static String? _defaultConfiguredBrowser() { try { - final value = ConfigHandler().getValueUnsafe('preferred_browser', ''); - return value.isEmpty ? null : value; + return ConfigHandler().getValueUnsafe('preferred_browser', ''); } catch (_) { return null; // ConfigHandler nicht initialisiert (z. B. in Tests) } diff --git a/test/app_launcher_test.dart b/test/app_launcher_test.dart index 99d6352..ed7ddb6 100644 --- a/test/app_launcher_test.dart +++ b/test/app_launcher_test.dart @@ -1,6 +1,54 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:linux_assistant/services/app_launcher.dart'; +/// Hermetische Test-Umgebung: fängt alle injizierbaren Zugriffe des +/// [AppLauncher] ab und zeichnet jeden gestarteten Prozess und jeden +/// ausgelesenen Befehl auf. +class LauncherFake { + /// `'$binary ${args.join(' ')}'` jedes gestarteten Prozesses. + final started = []; + + /// Jeder via [AppLauncher.debugOverride] umgeleitete Lese-Befehl. + final readCommands = []; + + /// Ergebnis von `which `; fehlender Eintrag = `false`. + Map whichResults = {}; + + /// Ergebnis eines Starts; fehlender Eintrag = `true`. + Map startResults = {}; + + /// Ausgabe von `xdg-settings get default-web-browser`. + String? xdgSettingsOutput; + + /// `false` = xdg-settings schlägt fehl (keine Ausgabe). + bool xdgSettingsSucceeds = true; + + /// Existiert die .desktop-Datei des XDG-Standardbrowsers? + bool desktopExists = true; + + /// Roher `preferred_browser`-Config-Wert. + String? configured; + + void install() { + AppLauncher.debugOverride( + whichRunner: (bin) async => whichResults[bin] ?? false, + processStarter: (bin, args) { + started.add('$bin ${args.join(' ')}'.trim()); + return Future.value(startResults[bin] ?? true); + }, + outputReader: (cmd, args) { + readCommands.add('$cmd ${args.join(' ')}'); + if (!xdgSettingsSucceeds) return Future.value(null); + return Future.value(xdgSettingsOutput); + }, + desktopEntryExists: (_) => desktopExists, + configuredBrowser: () => configured, + ); + } +} + void main() { tearDown(AppLauncher.resetOverrides); @@ -30,55 +78,173 @@ void main() { }); }); - group('AppLauncher.launchBrowser', () { - test('launchedPreferred, wenn Brave startet', () async { - final started = []; - AppLauncher.debugOverride( - whichRunner: (bin) async => bin == 'brave', - processStarter: (bin, args) async { - started.add('$bin ${args.join(" ")}'); - return true; - }, + group('AppLauncher.defaultBrowserDesktopId', () { + test('liest die Desktop-ID aus xdg-settings', () async { + final fake = LauncherFake() + ..xdgSettingsOutput = 'brave-origin.desktop' + ..install(); + expect( + await AppLauncher.defaultBrowserDesktopId(), 'brave-origin.desktop'); + expect(fake.readCommands.single, 'xdg-settings get default-web-browser'); + }); + + test('liefert null, wenn xdg-settings fehlschlägt', () async { + LauncherFake() + ..xdgSettingsSucceeds = false + ..install(); + expect(await AppLauncher.defaultBrowserDesktopId(), isNull); + }); + + test('liefert null bei leerer Ausgabe', () async { + LauncherFake() + ..xdgSettingsOutput = '' + ..install(); + expect(await AppLauncher.defaultBrowserDesktopId(), isNull); + }); + + test('liefert null bei ungültiger Desktop-ID (Boundary)', () async { + LauncherFake() + ..xdgSettingsOutput = '../evil.desktop' + ..install(); + expect(await AppLauncher.defaultBrowserDesktopId(), isNull); + }); + }); + + group('AppLauncher.desktopEntryExistsIn', () { + test('true, wenn der Eintrag in applications/ liegt', () async { + final tmp = await Directory.systemTemp.createTemp('mla_xdg_test'); + addTearDown(() => tmp.deleteSync(recursive: true)); + Directory('${tmp.path}/applications').createSync(recursive: true); + File('${tmp.path}/applications/brave-origin.desktop') + .writeAsStringSync('[Desktop Entry]\n'); + expect( + AppLauncher.desktopEntryExistsIn('brave-origin.desktop', + xdgDataDirs: [tmp.path]), + isTrue, ); + }); + + test('false, wenn kein Daten-Verzeichnis den Eintrag enthält', () async { + final tmp = await Directory.systemTemp.createTemp('mla_xdg_test'); + addTearDown(() => tmp.deleteSync(recursive: true)); + Directory('${tmp.path}/applications').createSync(recursive: true); + expect( + AppLauncher.desktopEntryExistsIn('brave-origin.desktop', + xdgDataDirs: [tmp.path]), + isFalse, + ); + }); + }); + + group('AppLauncher.launchBrowser', () { + test('startet den XDG-Standardbrowser statt der ersten Listen-Binary', + () async { + final fake = LauncherFake() + ..whichResults = {'google-chrome': true} + ..xdgSettingsOutput = 'brave-origin.desktop' + ..desktopExists = true + ..install(); + final result = await AppLauncher.launchBrowser(); + expect(result, BrowserLaunchResult.launchedPreferred); + expect(fake.started.single, 'gtk-launch brave-origin'); + expect(fake.started.join(' ').contains('google-chrome'), isFalse); + }); + + test('öffnet URLs über xdg-open, wenn ein XDG-Standard gesetzt ist', + () async { + final fake = LauncherFake() + ..xdgSettingsOutput = 'brave-origin.desktop' + ..install(); final result = await AppLauncher.launchBrowser(url: 'https://example.com'); expect(result, BrowserLaunchResult.launchedPreferred); - expect(started.single, 'brave https://example.com'); + expect(fake.started.single, 'xdg-open https://example.com'); }); - test('launchedFallback via xdg-open, wenn kein Browser gefunden', () async { - final started = []; - AppLauncher.debugOverride( - whichRunner: (_) async => false, - processStarter: (bin, args) async { - started.add(bin); - return bin == 'xdg-open'; - }, - ); + test('preferred_browser gewinnt über den XDG-Standard', () async { + final fake = LauncherFake() + ..configured = 'firefox' + ..whichResults = {'firefox': true} + ..xdgSettingsOutput = 'brave-origin.desktop' + ..install(); + final result = + await AppLauncher.launchBrowser(url: 'https://example.com'); + expect(result, BrowserLaunchResult.launchedPreferred); + expect(fake.started.single, 'firefox https://example.com'); + expect(fake.readCommands, isEmpty); + }); + + test('fällt auf die Binary-Liste zurück, wenn xdg-settings nichts liefert', + () async { + final fake = LauncherFake() + ..xdgSettingsSucceeds = false + ..whichResults = {'brave-browser': true} + ..install(); final result = await AppLauncher.launchBrowser(); expect(result, BrowserLaunchResult.launchedFallback); - expect(started, ['xdg-open']); + expect(fake.started.single, 'brave-browser'); }); - test('failed, wenn weder Browser noch xdg-open starten', () async { - AppLauncher.debugOverride( - whichRunner: (_) async => false, - processStarter: (_, __) async => false, - ); + test('fällt auf die Binary-Liste zurück, wenn die .desktop-Datei fehlt', + () async { + final fake = LauncherFake() + ..xdgSettingsOutput = 'brave-origin.desktop' + ..desktopExists = false + ..whichResults = {'chromium': true} + ..install(); + final result = await AppLauncher.launchBrowser(); + expect(result, BrowserLaunchResult.launchedFallback); + expect(fake.started.single, 'chromium'); + }); + + test('ignoriert einen Pfad als preferred_browser (WP-B1-Boundary)', + () async { + final fake = LauncherFake() + ..configured = '/usr/bin/brave' + ..whichResults = {'/usr/bin/brave': true} + ..xdgSettingsOutput = 'brave-origin.desktop' + ..install(); + final result = await AppLauncher.launchBrowser(); + expect(result, BrowserLaunchResult.launchedPreferred); + expect(fake.started.single, 'gtk-launch brave-origin'); + }); + + test('übergibt die URL auch an den Binary-Listen-Fallback', () async { + final fake = LauncherFake() + ..xdgSettingsSucceeds = false + ..whichResults = {'chromium': true} + ..install(); + final result = + await AppLauncher.launchBrowser(url: 'https://example.com'); + expect(result, BrowserLaunchResult.launchedFallback); + expect(fake.started.single, 'chromium https://example.com'); + }); + + test('failed, wenn keine Stufe zum Ziel führt', () async { + LauncherFake() + ..xdgSettingsSucceeds = false + ..startResults = {'brave': false, 'xdg-open': false} + ..install(); expect(await AppLauncher.launchBrowser(), BrowserLaunchResult.failed); }); + }); - test('startet ohne URL-Argument, wenn keine übergeben', () async { - List? capturedArgs; - AppLauncher.debugOverride( - whichRunner: (bin) async => bin == 'brave', - processStarter: (bin, args) async { - capturedArgs = args; - return true; - }, - ); - await AppLauncher.launchBrowser(); - expect(capturedArgs, isEmpty); + group('AppLauncher.sanitizePreferredBrowser', () { + test('lässt allowlisteten Wert durch', () { + expect(AppLauncher.sanitizePreferredBrowser('brave'), 'brave'); + }); + + test('trimmt umgebende Leerzeichen', () { + expect(AppLauncher.sanitizePreferredBrowser(' brave '), 'brave'); + }); + + test('lehnt nicht-allowlistete Binary-Namen ab (fällt still auf Fallback)', + () { + expect(AppLauncher.sanitizePreferredBrowser('not-a-browser'), isNull); + }); + + test('lehnt Pfade ab (WP-B1: which würde sie sonst auflösen)', () { + expect(AppLauncher.sanitizePreferredBrowser('/usr/bin/brave'), isNull); }); }); From c4047eb111fc3b2cbf9c4137f86064202b598815 Mon Sep 17 00:00:00 2001 From: ElGregor Date: Fri, 25 Sep 2026 00:15:23 +0200 Subject: [PATCH 2/8] build(deb): depend on xdg-utils, libgtk-3-bin and libglib2.0-bin The XDG launcher stage added in V0.8.2 calls xdg-settings, gtk-launch and gsettings; declare the packages that provide them so apt pulls them in with the .deb. README and Getting-Started list the same runtime set. Co-Authored-By: ZCode --- README.md | 7 ++++--- deb/DEBIAN/control | 2 +- docs/wiki/Getting-Started.md | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 60ac83d..821631a 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,8 @@ sudo apt install libkeybinder-3.0-0 libkeybinder-3.0-dev To run an installed package, only the runtime libraries are needed — the `.deb` declares them, so `apt` pulls them in for you. The declared runtime set is `libgtk-3-0, libkeybinder-3.0-0, python3, python3-gi, -gir1.2-gtk-3.0, python3-apt, mesa-utils, pkexec | policykit-1` (see -`deb/DEBIAN/control`). +gir1.2-gtk-3.0, python3-apt, mesa-utils, pkexec | policykit-1, xdg-utils, +libgtk-3-bin, libglib2.0-bin` (see `deb/DEBIAN/control`). If you build with `flutter build linux` and run the bundle directly (Option 1 under [Build](#build)), `apt` does not install those packages for you. The @@ -64,7 +64,8 @@ Python helpers need GObject introspection, so install them by hand first: ```bash sudo apt install libgtk-3-0 libkeybinder-3.0-0 python3 python3-gi \ - gir1.2-gtk-3.0 python3-apt mesa-utils policykit-1 + gir1.2-gtk-3.0 python3-apt mesa-utils policykit-1 \ + xdg-utils libgtk-3-bin libglib2.0-bin ``` ## Build diff --git a/deb/DEBIAN/control b/deb/DEBIAN/control index 43c050a..fb361d6 100644 --- a/deb/DEBIAN/control +++ b/deb/DEBIAN/control @@ -1,5 +1,5 @@ Package: linux-assistant -Depends: libgtk-3-0, libkeybinder-3.0-0, python3, python3-gi, gir1.2-gtk-3.0, python3-apt, mesa-utils, pkexec | policykit-1 +Depends: libgtk-3-0, libkeybinder-3.0-0, python3, python3-gi, gir1.2-gtk-3.0, python3-apt, mesa-utils, pkexec | policykit-1, xdg-utils, libgtk-3-bin, libglib2.0-bin Maintainer: Jean28518 Architecture: amd64 Homepage: https://www.linux-assistant.org/ diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index b2d312b..c4812a1 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -11,7 +11,8 @@ sudo apt install libkeybinder-3.0-0 libkeybinder-3.0-dev Zum Ausführen eines installierten Pakets reichen die Laufzeit-Bibliotheken – das `.deb` deklariert sie (`libgtk-3-0`, `libkeybinder-3.0-0`, `python3`, `python3-gi`, `gir1.2-gtk-3.0`, `python3-apt`, `mesa-utils`, -`pkexec | policykit-1`), `apt` zieht sie automatisch. +`pkexec | policykit-1`, `xdg-utils`, `libgtk-3-bin`, `libglib2.0-bin`), +`apt` zieht sie automatisch. Flutter: `>=3.27.0` (pubspec.yaml). Der Grund: `Color.withValues(alpha:)` wird in den Design-Tokens genutzt. From 7099c68b4fb73762ea6d254e58671f6a602427f7 Mon Sep 17 00:00:00 2001 From: ElGregor Date: Fri, 25 Sep 2026 00:15:30 +0200 Subject: [PATCH 3/8] fix(python): copy timeshift config with shutil instead of a shell command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP-P1 (#43): copy_file spliced both paths into an os.system cp call. The only caller passes constant paths today, but the helper is importable by every root script — shutil.copy2 keeps the copy in-process and preserves the metadata the cp invocation promised. Co-Authored-By: ZCode --- additional/python/jfiles.py | 3 +- additional/python/tests/test_jfiles.py | 44 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 additional/python/tests/test_jfiles.py diff --git a/additional/python/jfiles.py b/additional/python/jfiles.py index 8a5bb54..8041f13 100644 --- a/additional/python/jfiles.py +++ b/additional/python/jfiles.py @@ -4,6 +4,7 @@ import gettext import json import os +import shutil def does_file_exist(file_path): return os.path.exists(file_path) and os.path.isfile(file_path) @@ -178,7 +179,7 @@ def get_string_of_file(file_path): def copy_file(source_path, destination_path): - os.system("cp '" + source_path + "' '" + destination_path + "'") + shutil.copy2(source_path, destination_path) def get_dict_of_json_file(file_path): return json.load(open(file_path, 'r')) diff --git a/additional/python/tests/test_jfiles.py b/additional/python/tests/test_jfiles.py new file mode 100644 index 0000000..d8f0627 --- /dev/null +++ b/additional/python/tests/test_jfiles.py @@ -0,0 +1,44 @@ +"""Tests for the file helpers. + +WP-P1 (#43): `copy_file` backs the Timeshift setup, which copies the default +config to its live location. The copy must not route through `os.system` with +interpolated paths — and `shutil.copy2` additionally keeps the metadata the +old `cp` invocation promised, which matters for a config file an admin may +have just edited. +""" + +import os +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import jfiles # noqa: E402 + + +class CopyFile(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.source = os.path.join(self._tmp.name, "default.json") + self.destination = os.path.join(self._tmp.name, "timeshift.json") + with open(self.source, "w") as handle: + handle.write('{"schedule": "daily"}\n') + # A whole-second stamp: also exact on filesystems with coarse + # timestamp granularity. + os.utime(self.source, (1234567, 1234567)) + + def tearDown(self): + self._tmp.cleanup() + + def test_copies_content_and_mtime_without_a_shell(self): + with mock.patch.object( + jfiles.os, "system", side_effect=AssertionError("os.system must not be used") + ): + jfiles.copy_file(self.source, self.destination) + with open(self.destination) as handle: + self.assertEqual(handle.read(), '{"schedule": "daily"}\n') + self.assertEqual( + os.stat(self.destination).st_mtime, os.stat(self.source).st_mtime + ) From 39847eaa930f67d46095ba1fed2240247127f7b9 Mon Sep 17 00:00:00 2001 From: ElGregor Date: Fri, 25 Sep 2026 00:16:36 +0200 Subject: [PATCH 4/8] fix(python): invoke wget/unzip with argv lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP-P2 (#48): download_file and unzip_file interpolated URLs and paths into command strings that run_command shlex-splits — a space split the value across arguments and a leading-dash URL became wget options (CWE-88). run_command now accepts an argv list unchanged; the wget call puts the link behind an option fence. unzip gets no fence: after it, -d would be read as a member name. Co-Authored-By: ZCode --- additional/python/jessentials.py | 16 ++++-- additional/python/tests/test_jessentials.py | 62 +++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 additional/python/tests/test_jessentials.py diff --git a/additional/python/jessentials.py b/additional/python/jessentials.py index 7c7b915..a361dfb 100644 --- a/additional/python/jessentials.py +++ b/additional/python/jessentials.py @@ -31,13 +31,17 @@ def get_environment_variable(key, default=""): # example for environment={'DEBIAN_FRONTEND': 'noninteractive'} # if return_output==true: function returns a array of strings +# A list is used as argv unchanged; a string is shlex-split. Callers that +# build commands from values (paths, URLs) must pass a list, so a space +# stays inside one argument. def run_command(command, print_output=True, return_output=False, environment = {}, user=None): + argv = list(command) if isinstance(command, list) else shlex.split(command) if sys.version_info < (3, 9): if user == None: user = os.getuid() - process = subprocess.Popen(shlex.split(command), stdout=subprocess.PIPE, env=environment, preexec_fn=_demote(user, user)) + process = subprocess.Popen(argv, stdout=subprocess.PIPE, env=environment, preexec_fn=_demote(user, user)) else: - process = subprocess.Popen(shlex.split(command), stdout=subprocess.PIPE, env=environment, user=user) + process = subprocess.Popen(argv, stdout=subprocess.PIPE, env=environment, user=user) output_lines = [] # In this the output is saved line per line if print_output or return_output: while True: @@ -164,7 +168,8 @@ def get_accessible_table_of_raw_csv_table(csv_raw_table): def download_file(link, destination_folder): - run_command("wget %s -P %s" % (link, destination_folder), False) + # `--` keeps a leading-dash link from becoming wget options. + run_command(["wget", "-P", destination_folder, "--", link], False) def get_filename_of_path(path): @@ -176,8 +181,9 @@ def unzip_file(file_path): file_name_zip = get_filename_of_path(file_path) file_name = os.path.splitext(file_name_zip)[0] path = file_path.replace(file_name_zip, "") - run_command("mkdir %s%s" % (path, file_name), False) - run_command("unzip -o %s -d %s%s" % (file_path, path, file_name), False) + # No `--` fence here: after it, unzip would read `-d` as a member name. + run_command(["mkdir", path + file_name], False) + run_command(["unzip", "-o", file_path, "-d", path + file_name], False) return "%s%s" % (path, file_name) def import_json_string(string): diff --git a/additional/python/tests/test_jessentials.py b/additional/python/tests/test_jessentials.py new file mode 100644 index 0000000..39310c5 --- /dev/null +++ b/additional/python/tests/test_jessentials.py @@ -0,0 +1,62 @@ +"""Tests for the download/unzip helpers. + +WP-P2 (#48): `download_file` and `unzip_file` have no callers in this repo, +but the root scripts import jessentials, so the helpers must stay safe to +call. Both used to interpolate URLs and paths into command strings that +`run_command` hands to shlex: a path with spaces became several arguments +(whitespace split), and a leading-dash URL became wget options (CWE-88). +Passed as argv lists, the values stay values. +""" + +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import jessentials # noqa: E402 + + +class RunCommand(unittest.TestCase): + def test_accepts_an_argv_list_directly(self): + completed = jessentials.run_command( + [sys.executable, "-c", "print('ok')"], print_output=False + ) + self.assertEqual(completed, 0) + + +class DownloadFile(unittest.TestCase): + def test_url_and_folder_stay_single_arguments(self): + with mock.patch.object(jessentials, "run_command") as run: + jessentials.download_file( + "https://example.org/my file.tar.gz", "/opt/my apps" + ) + argv = run.call_args[0][0] + self.assertIsInstance(argv, list) + self.assertIn("https://example.org/my file.tar.gz", argv) + self.assertIn("/opt/my apps", argv) + + def test_leading_dash_url_lands_after_the_option_fence(self): + with mock.patch.object(jessentials, "run_command") as run: + jessentials.download_file("-O/tmp/pwned", "/tmp") + argv = run.call_args[0][0] + self.assertIn("--", argv) + self.assertLess(argv.index("--"), argv.index("-O/tmp/pwned")) + + +class UnzipFile(unittest.TestCase): + def test_path_with_spaces_stays_one_argument(self): + with mock.patch.object(jessentials, "run_command") as run: + returned = jessentials.unzip_file("/opt/my apps/archive v2.zip") + mkdir_argv = run.call_args_list[0][0][0] + unzip_argv = run.call_args_list[1][0][0] + self.assertEqual(mkdir_argv, ["mkdir", "/opt/my apps/archive v2"]) + self.assertEqual( + unzip_argv, + ["unzip", "-o", "/opt/my apps/archive v2.zip", "-d", "/opt/my apps/archive v2"], + ) + self.assertEqual(returned, "/opt/my apps/archive v2") + # No `--` fence for unzip: after it, the `-d` target would be read + # as a member name to extract instead of the destination. + self.assertNotIn("--", unzip_argv) From e5cda25d39b18e07f2fb1180292ad2bca9200310 Mon Sep 17 00:00:00 2001 From: ElGregor Date: Fri, 25 Sep 2026 01:48:49 +0200 Subject: [PATCH 5/8] docs: personal-cockpit scope, Core covers the hub tools, distro matrix frozen Fork note and reference system (Zorin OS 18.1) in MANIFEST; Core now includes the shipped hub tools and never foreign backends; the distro matrix in features.csv is frozen at upstream state. The four hub tools enter the CSV as Core (verified on the reference system only), AGENTS.md points at the frozen columns. Co-Authored-By: ZCode --- AGENTS.md | 5 +++-- MANIFEST.md | 28 ++++++++++++++++++++++------ features.csv | 4 ++++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 865d26d..f23e871 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,9 @@ Deeper instructions override `~/AGENTS.md` for this subtree. **Purpose and stack:** Flutter/Dart Linux desktop app (C++/GTK runner, localized en/de/it/fi) with privileged Python helpers and Debian packaging. -Read `MANIFEST.md` (philosophy) and `features.csv` (distro/desktop support -matrix) before changing distro-conditional behavior. Requires +Read `MANIFEST.md` (philosophy; reference system: Zorin OS 18.1) and +`features.csv` (distro/desktop support matrix; distro columns frozen at +upstream state) before changing distro-conditional behavior. Requires Dart ≥3.4 / Flutter ≥3.27. **Key files:** `version` (single source of truth), `pubspec.yaml`, diff --git a/MANIFEST.md b/MANIFEST.md index c28cb53..db80070 100644 --- a/MANIFEST.md +++ b/MANIFEST.md @@ -8,15 +8,23 @@ as long the free (alternative) is well adapted in the community and has similar The installation of Linux Assistant should be very easy. As few dependencies as possible should be used. +> **Fork note (removable derivation):** this repository is first a personal +> cockpit for Zorin OS 18.1. The upstream mission above stands unchanged; +> the tiers below are measured on the reference system only. + ## Feature tiers To keep the mission focused, features are grouped into three tiers. Every feature in `features.csv` carries its tier in the `Category` column. **Core** — Functions the mission depends on: the daily helper (search, -environment recognition) and administrative tasks (package management, -updates, security and health checks, system setup). These deserve the -broadest distribution support; a broken core function is a release blocker. +environment recognition), administrative tasks (package management, +updates, security and health checks, system setup) and the shipped hub +tools (browser launcher, quick notes, file manager, system monitor). +A broken core function is a release blocker on the reference system. +Integrations with foreign backends (TokenTelemetry, Hermes, Odysseus, +weather APIs) are never Core; system components (apt, systemd, Restic, +Docker) may be. **Quality of Life (QoL)** — Functions that make daily use noticeably more convenient without adding new mission scope. Rules of thumb: @@ -34,11 +42,19 @@ convenient without adding new mission scope. Rules of thumb: - They may be invisible on many distributions without hurting the product. - Removing one is legitimate whenever its maintenance cost exceeds its value. +**Frozen distro matrix:** the distribution and desktop columns of +`features.csv` are frozen at the upstream state — only the reference +system (Zorin OS 18.1, GNOME) is verified by this fork. + Kurzfassung (DE): -- **Core:** Kern der Mission — täglicher Helfer plus Admin-Aufgaben; - breiter Distro-Support ist Pflicht, ein Bruch ist Release-Blocker. +- **Core:** Kern der Mission — täglicher Helfer, Admin-Aufgaben und die + mitgelieferten Hub-Werkzeuge; am Referenzsystem (Zorin OS 18.1) ist ein + Bruch Release-Blocker. Fremd-Backend-Integrationen sind nie Core, + Systemkomponenten (apt, systemd, Restic, Docker) dürfen. - **QoL:** Alltagskomfort ohne neue Missions-Scope — keine neuen Abhängigkeiten, folgt dem Design-System, darf auf Distros fehlen. - **n2h:** Optionale Extras — nur mit kleinem Abhängigkeits-Fußabdruck und - unangetasteten Security-Invarianten; Entfernung ist legitim. \ No newline at end of file + unangetasteten Security-Invarianten; Entfernung ist legitim. +- **Eingefroren:** Die Distro-/Desktop-Spalten der `features.csv` stehen auf + Upstream-Stand; verifiziert wird nur das Referenzsystem. \ No newline at end of file diff --git a/features.csv b/features.csv index def1391..07fb2df 100644 --- a/features.csv +++ b/features.csv @@ -40,3 +40,7 @@ Hub dashboard,Core,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,y Live system tiles (CPU/RAM/disk),QoL,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes, Theme switching without restart,QoL,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes,yes, Read-only security findings,Core,?,yes,yes,yes,yes,yes,yes,yes,yes,yes *,yes *,yes,yes,yes,-,-,-,-,*) checker script does not report an update count on these distributions +Browser launcher,Core,?,?,?,?,?,?,?,yes,?,?,?,?,?,?,yes,?,?,?,fork hub tool; verified on the reference system only +Quick notes,Core,?,?,?,?,?,?,?,yes,?,?,?,?,?,?,yes,?,?,?,fork hub tool; verified on the reference system only +File manager,Core,?,?,?,?,?,?,?,yes,?,?,?,?,?,?,yes,?,?,?,fork hub tool; verified on the reference system only +System monitor,Core,?,?,?,?,?,?,?,yes,?,?,?,?,?,?,yes,?,?,?,fork hub tool; verified on the reference system only From 716ca5310097a8a5f5307a212a10bd663992977f Mon Sep 17 00:00:00 2001 From: ElGregor Date: Fri, 25 Sep 2026 01:49:52 +0200 Subject: [PATCH 6/8] docs(roadmap): fold in the deep-research review DR reference line (report stays outside the repo), V0.8.0 tagged, V0.8.X item 3 done and item 4 scoped, new items 9/10, a 'Nach V0.8.X' section (gsettings hotkey, browser status, agent tile, gate automation spike), Tokentelemetrie replaced by the agent tile, Gateway Manager and Kanban Watcher dropped with a note, tier counts 24/17/4, a parked table for DR 4.1/4.4/4.6/4.8-4.11 and a binding never-list with the upstream tasks marked for Basti. Co-Authored-By: ZCode --- docs/handoff/roadmap.md | 69 +++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/docs/handoff/roadmap.md b/docs/handoff/roadmap.md index b637156..7ed69e3 100644 --- a/docs/handoff/roadmap.md +++ b/docs/handoff/roadmap.md @@ -1,6 +1,7 @@ # Roadmap: Linux Assistant (Toqsick-Fork) — V0.8.0 → V0.9 **Stand:** 2026-09-10 · **Konsolidiert aus:** PR #24 (Implementierungsplan Admin-Hub v0.8.5 — wird hiermit ersetzt), Issues #25–#30, `.claude/plans/den-linux-assistant-weiter-swift-adleman.md` (Härtungs-Plan 0.7.1→0.8.0), 5-Agenten-Recon 2026-09-10, Bastis Zielbild („Admin Linux Hub", All-in-One). +**DR:** Deep-Research-Auswertung 2026-09-11 (Perplexity, 11 Themen) — der Bericht bleibt bewusst außerhalb des Repos; „DR 4.x" verweist auf seine Themen-Abschnitte. ## Status quo (Kurzfassung) @@ -10,7 +11,7 @@ --- -## V0.8.0 — „Alles nutzbar" (Release-Zyklus, läuft) +## V0.8.0 — „Alles nutzbar" (✅ Tag v0.8.0, 2026-09-11) Ziel: Jede angezeigte Funktion funktioniert auf Zorin/Ubuntu 24.04 ohne Fehlertext-Lügen. Release als Tag + GitHub-Release + deb. @@ -28,12 +29,21 @@ Ziel: Jede angezeigte Funktion funktioniert auf Zorin/Ubuntu 24.04 ohne Fehlerte 1. **Zorin-Erkennung & Akzent:** `get_environment.py` prüfen (Zorin als Distro + Desktop GNOME/Xfwm-Varianten), Zorin-Palette in `main.dart`-Distro-Set sicherstellen, `features.csv` Z.18 (Timeshift auf Zorin „?") auflösen. 2. **deb822-Vollabdeckung:** `apt_sources.py` überall verwenden, wo `.list`/`.sources` gelesen werden (Updater, Uninstaller-Quellen, Autoupdates) — Prüfen + vereinheitlichen. -3. **Updater-Stand reparieren:** config kennt `newest-linux-assistant-version: 0.6.2` (Upstream-Feed?) — Fork-eigene Release-Erkennung (GitHub-Releases des Forks) statt Upstream-Verwirrung. -4. **Tool-/Plugin-Registry (Issue #27) als Architekturbasis:** HubTool-Muster aus E1–E4 formalisieren (Interface: id, Titel, Icon, Screen-Builder, Capabilities) — V0.9-Module registrieren sich nur noch. Grundlage für alles Folgende. +3. ✅ **Updater-Stand repariert:** Fork-eigene Release-Erkennung über `releaseRepository` (`lib/services/updater.dart:21` → `Toqsick/master-linux-assistant`) statt Upstream-Verwirrung. +4. **Tool-/Plugin-Registry (Issue #27) als Architekturbasis (ohne Panel-/Dock-Teil):** HubTool-Muster aus E1–E4 formalisieren (Interface: id, Titel, Icon, Screen-Builder, Capabilities) — V0.9-Module registrieren sich nur noch. Grundlage für alles Folgende. 5. **`la-helper` privilegierter Helper (Issue #28):** ein abgesicherter, polkit-gated Helper statt N einzelner pkexec-Scripte; Actions bleiben feingranular (bestehende Policy-Struktur beibehalten). 6. **D-Bus SystemdService (Issue #29):** Systemd-Unit-Status/-Start/-Stop ohne Shell-Umwege; Basis für Docker-Watcher & Backup-Status in V0.9. 7. **l10n-`_tr()`-Pattern (Issue #25) + MintYColors-Dashboard-Migration (Issue #10/#26):** Schulden aus E1–E4 (hardcoded deutsche Strings in tools/*) in ARBs überführen; Dashboard-Widgets auf ThemeExtension umstellen. 8. **Golden-Tests (Issue #30):** visuelle Regression für Kern-Screens (dark+light) — schützt das Design-System. +9. ✅ **Browser-Launcher XDG (V0.8.2):** startet den konfigurierten oder XDG-Standardbrowser (`xdg-settings` + `.desktop`-Prüfung, dann `gtk-launch`/`xdg-open`); die Binär-Liste ist nur noch letzter Fallback. +10. **Release-Gates (V0.8.5):** manuelle Gate-Tabelle für Wayland- und X11-Session im Release-Process-Dokument. + +## Nach V0.8.X — aus der Deep-Research-Auswertung (DR) + +1. **Hotkey über gsettings in beiden Sessions:** `hotkey_manager` + `libkeybinder-3.0-0` raus; Keybinding via gsettings statt In-App-Grab — funktioniert auf X11 wie Wayland. +2. **Browser-Status (Core, read-only):** Kachel zeigt den XDG-Standardbrowser (`xdg-settings`); Wechsel bleibt außerhalb der App — keine Browser-Verwaltung. +3. **Agenten-Tile → V0.9 P2:** ersetzt Tokentelemetrie (siehe V0.9-Tabelle). +4. **Gate-Automatisierung (Spike):** headless gnome-shell nur für die Wayland-Gates; Monitorwechsel und alle X11-Gates bleiben manuell; kein Self-hosted-Runner. ## V0.9 — „Wiring an Use-Cases" (Hub-Modul-Slots über die Registry) @@ -44,25 +54,28 @@ Bastis Ziel: interaktives Admin-Dashboard, in dem seine realen Workflows leben. | P1 | **System Monitor (ausbauen)** | E3 existiert (Prozesstabelle, CPU/RAM/Thermal-Tiles) → zu persistentem Dashboard ausbauen: Warnschwellen, History (Ringpuffer → Datei), Autostart-Option | nur E3-Verfeinerung | | P1 | **Backup-System (Restic-Status)** | Restic-Snapshots/Timers/letzte Fehler lesend anzeigen (Bastis reale Restic-Lücke: 0 erfolgreiche Snapshots!) — Status-Kachel + Detail-Screen; Aktionen (snapshot now) via la-helper | #28, #29 | | P2 | **Docker Watcher** | Container-Liste (docker ps --format json), Status/Badges, Logs-Tail, Start/Stop über la-helper | #28 | -| P2 | **Tokentelemetrie** | Bastis token-calc-Dashboard (20-Workspace) liefert Verbrauchsdaten → Modul liest dessen Export/SQLite und zeigt Tiles/Sparklines (HermesStatTile/Sparkline vorhanden!) | Datenvertrag mit token-calc | -| P3 | **Hermes Gateway Manager** | Status/Start/Stop/Restart des Hermes-Gateways (systemd --user), letzte Logs, API-Key-Rotation-Reminder | #29 | -| P3 | **Kanban Watcher (hermes kanban wrap)** | Watch auf Kanban-Board-Dateien (mtime/queue-Tiefen), Benachrichtigungen bei Blockern, Verlinkung ins Board | FileSystem-Watch | +| P2 | **Agenten-Tile (n2h, DR)** | Status-Kachel für Bastis Agenten-Stack (Hermes, ZCode, Claude Code) inkl. Token-Verbrauch aus dem token-calc-Export — ersetzt die frühere Tokentelemetrie-Zeile (DR 4.2/4.3) | Datenvertrag mit token-calc | + +**Gestrichen (DR):** „Hermes Gateway Manager" und „Kanban Watcher" — +Basti-spezifische n2h-Integrationen ohne laufende Nutzung; Wiedervorlage nur +mit Beleg (siehe „Geparkt"). **Architektur-Regel für V0.9:** jedes Modul = Registry-Eintrag + eigener Service (Dart, isoliert testbar wie `SystemMonitorService`) + optional la-helper-Action. Kein Modul schreibt direkt Shell-Befehle in Widgets. ## Feature-Tiers & QoL/n2h-Kandidaten Das Grundkonzept (`MANIFEST.md`, Stand 2026-09-11) gruppiert Features in drei -Tiers: **Core** (Mission: täglicher Helfer + Admin-Aufgaben, breiter -Distro-Support ist Pflicht, Bruch = Release-Blocker), **QoL** (Alltagskomfort, -keine neuen Abhängigkeiten, darf auf Distros fehlen) und **n2h** (optionale -Extras, kleiner Abhängigkeits-Fußabdruck, Security-Invariante unangetastet). -Die Klassifikation aller 41 bestehenden Features steht in `features.csv` -(Spalte `Category`: 20 Core / 17 QoL / 4 N2H). - -**Einordnung der V0.9-Module:** System Monitor P1 ist Core-nah, Backup-Status -und Docker Watcher sind QoL; Hermes Gateway Manager, Kanban Watcher und -Tokentelemetrie sind n2h (Basti-spezifische Integrationen). +Tiers: **Core** (Mission: täglicher Helfer, Admin-Aufgaben und die +mitgelieferten Hub-Werkzeuge; am Referenzsystem Zorin OS 18.1 ist Bruch = +Release-Blocker), **QoL** (Alltagskomfort, keine neuen Abhängigkeiten, darf +auf Distros fehlen) und **n2h** (optionale Extras, kleiner +Abhängigkeits-Fußabdruck, Security-Invariante unangetastet). Die +Klassifikation aller 45 Features steht in `features.csv` (Spalte `Category`: +24 Core / 17 QoL / 4 N2H). + +**Einordnung der V0.9-Module:** der System Monitor ist ein Core-Hub-Werkzeug; +Backup-Status und Docker Watcher sind QoL; das Agenten-Tile ist n2h +(Basti-spezifische Integration). **Grenzfälle der Klassifikation:** Feedback → N2H (optional, kein Alltags-Workflow) · Multimedia-Codecs → QoL (Installationskomfort, kein Kern) @@ -93,12 +106,34 @@ Admin-Aufgabe). Kandidaten werden erst nach dem V0.8.X-Härtungsblock angetastet — das Prinzip „Verifizieren → Härten → Erweitern“ bleibt. +## Geparkt — Wiedervorlage nur mit Beleg (DR) + +| DR | Thema | Kern | +|---|---|---| +| 4.1 | Hermes-Web-UI einbetten | Webview in Flutter/GTK (Compositing/DPI/IM-Risiken) vs. nativer Dart-Client vs. externer Browser | +| 4.4 | Wetter-Widget | Freie Wetter-API (Open-Meteo, MET Norway) — Datenschutz zuerst; Hub-Tile vor GNOME-Shell-Extension | +| 4.6 | Notizen: Transkription + QoL | whisper.cpp/faster-whisper lokal, Audio-Aufnahme auf PipeWire, Markdown-Editor-Komponenten | +| 4.8 | Resizable Dash-Leiste + rechtes Dock | Stufenlose Sidebar + andockbare Panels (Datei-Vorschau, Terminal) | +| 4.9 | „Terminal+" | Eingebettetes Terminal (xterm.dart + PTY vs. VTE vs. externes Ptyxis mit Deep-Linking) | +| 4.10 | Proton Hub | Mail/VPN/Pass/Drive/Authenticator — nur Launch + Status + Quick-Actions ohne ToS-Verstoß | +| 4.11 | Gmail + Odysseus-Frontend | Mail-Frontend über den eigenen Gateway (OAuth/Tokens im Gateway, nicht in der App) | + +Wiedervorlage nur mit Beleg: Ein Thema kommt zurück auf die Roadmap, wenn +sein DR-Abschnitt (oder eine neue Recherche) den Weg mit Quellen belegt. + ## Was aus PR #24 / offenen Issues aufgeht - **PR #24 (Implementierungsplan v0.8.5):** ersetzt durch dieses Dokument — schließen mit Verweis. Inhaltlich aufgegangen in V0.8.0/V0.8.X. - **Issue #25 (l10n `_tr`)** → V0.8.X.7 · **#26/#10 (MintYColors-Migration)** → V0.8.X.7 · **#27 (Tool-Registry)** → V0.8.X.4 · **#28 (la-helper)** → V0.8.X.5 · **#29 (D-Bus Systemd)** → V0.8.X.6 · **#30 (Goldens)** → V0.8.X.8. -## Nicht-Ziele / bewusst draußen +## Nicht-Ziele / bewusst draußen — verbindliche Nie-Liste - Kein Upstream-PR (44 Commits Distanz, Upstream dormat bei 0.6.2) — fork-only, deb ist der einzige gepflegte Paketweg (rpm/flatpak/arch → `packaging/unmaintained/`). - Keine Neuentwicklung eines Widget-Frameworks: HermesTokens + MintYColors zusammenführen/institutionalisieren stattdessen (Entscheidung in V0.8.X.7 treffen). +- Keine fremden Backends als Core (siehe `MANIFEST.md`): TokenTelemetry, Hermes, Odysseus, Wetter-APIs bleiben QoL/n2h. +- Keine Cloud- oder Telemetrie-Pflicht: alles läuft lokal; Netzwerk nur für Funktionen, die der Nutzer explizit anstößt. +- Kein Self-hosted-CI-Runner und keine Automatisierung der X11-Gates (Monitorwechsel bleibt manuell — siehe „Nach V0.8.X"). + +**Upstream-Punkte (Basti-Tasks, nicht Teil der Releases):** Kommentar in +Upstream-#253 (Hypothese mit Link `eb1dfb5`) · privater Maintainer-Hinweis an +Jean28518 zu den jtools-unix-python-Fixes aus V0.8.3 (WP-P1/P2). From 2ef61d59c3508be0170d81911e0de07f909652af Mon Sep 17 00:00:00 2001 From: ElGregor Date: Fri, 2 Oct 2026 14:20:03 +0200 Subject: [PATCH 7/8] Refactor default XDG data directory retrieval Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/services/app_launcher.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/services/app_launcher.dart b/lib/services/app_launcher.dart index d50a124..20c4a93 100644 --- a/lib/services/app_launcher.dart +++ b/lib/services/app_launcher.dart @@ -220,9 +220,14 @@ class AppLauncher { static List _defaultXdgDataDirs() { final env = Platform.environment; final home = env['HOME']; - final dataHome = - env['XDG_DATA_HOME'] ?? (home != null ? '$home/.local/share' : null); - final dirsRaw = env['XDG_DATA_DIRS'] ?? '/usr/local/share:/usr/share'; + final configuredDataHome = env['XDG_DATA_HOME']; + final dataHome = configuredDataHome != null && configuredDataHome.isNotEmpty + ? configuredDataHome + : (home != null ? '$home/.local/share' : null); + final configuredDataDirs = env['XDG_DATA_DIRS']; + final dirsRaw = configuredDataDirs != null && configuredDataDirs.isNotEmpty + ? configuredDataDirs + : '/usr/local/share:/usr/share'; return [ if (dataHome != null) dataHome, ...dirsRaw.split(':').where((dir) => dir.isNotEmpty), From 2b77f15c16a6af27465a9719207445cc6f9c0a4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:33:08 +0000 Subject: [PATCH 8/8] Keep AppLauncher test overrides hermetic Co-authored-by: Toqsick <178702703+Toqsick@users.noreply.github.com> --- lib/services/app_launcher.dart | 2 +- test/app_launcher_test.dart | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/services/app_launcher.dart b/lib/services/app_launcher.dart index 20c4a93..4af7cec 100644 --- a/lib/services/app_launcher.dart +++ b/lib/services/app_launcher.dart @@ -70,7 +70,7 @@ class AppLauncher { }) { _which = whichRunner ?? _defaultWhich; _starter = processStarter ?? _defaultStarter; - _outputReader = outputReader ?? _defaultOutputReader; + _outputReader = outputReader ?? ((_, __) async => null); _desktopEntryExists = desktopEntryExists ?? desktopEntryExistsIn; _configuredBrowser = configuredBrowser ?? _defaultConfiguredBrowser; } diff --git a/test/app_launcher_test.dart b/test/app_launcher_test.dart index ed7ddb6..67c902a 100644 --- a/test/app_launcher_test.dart +++ b/test/app_launcher_test.dart @@ -185,6 +185,25 @@ void main() { expect(fake.started.single, 'brave-browser'); }); + test('debug override does not run xdg-settings without an output reader', + () async { + final started = []; + AppLauncher.debugOverride( + whichRunner: (binary) async => binary == 'brave', + processStarter: (binary, _) async { + started.add(binary); + return true; + }, + configuredBrowser: () => null, + ); + + expect( + await AppLauncher.launchBrowser(), + BrowserLaunchResult.launchedFallback, + ); + expect(started, ['brave']); + }); + test('fällt auf die Binary-Liste zurück, wenn die .desktop-Datei fehlt', () async { final fake = LauncherFake()