From 44b3381836327ec53bf7c5495b29b35ca03e118a Mon Sep 17 00:00:00 2001 From: ElGregor Date: Sat, 24 Oct 2026 01:58:10 +0200 Subject: [PATCH 1/8] =?UTF-8?q?feat(mla):=20WP-S3=20+=20V0.8.6=20Welle=201?= =?UTF-8?q?=20(Q1,=20Q2,=20Q3,=20Q5,=20Q6)=20=E2=80=94=20zusammengef=C3=BC?= =?UTF-8?q?hrt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/handoff/v0.8.2-wp-s3-security-hygiene.md | 159 ++++++++++++++++++ docs/handoff/v0.8.6-integration-abschluss.md | 100 +++++++++++ docs/handoff/v0.8.6-q1-security-scan.md | 111 ++++++++++++ docs/handoff/v0.8.6-q2-q6-dashboard.md | 135 +++++++++++++++ 4 files changed, 505 insertions(+) create mode 100644 docs/handoff/v0.8.2-wp-s3-security-hygiene.md create mode 100644 docs/handoff/v0.8.6-integration-abschluss.md create mode 100644 docs/handoff/v0.8.6-q1-security-scan.md create mode 100644 docs/handoff/v0.8.6-q2-q6-dashboard.md diff --git a/docs/handoff/v0.8.2-wp-s3-security-hygiene.md b/docs/handoff/v0.8.2-wp-s3-security-hygiene.md new file mode 100644 index 00000000..42d94375 --- /dev/null +++ b/docs/handoff/v0.8.2-wp-s3-security-hygiene.md @@ -0,0 +1,159 @@ +# deliverable.md — WP-S3 Hygiene-Bundle (#56, #46, #50) + +> **Stand:** 2026-10-23 · **Basis:** `main` @ `08dad94` · **Branch:** `feature/56-wp-s3-hygiene` +> **Umfang:** Work-Package WP-S3 aus `security-fixplan-42-50.md` plus die beiden +> zugehörigen Findings #46 (folder_recursion_depth) und #50 (Disk-Analyzer). + +## Was geändert wurde + +| Datei | Änderung | +|---|---| +| `lib/services/linux.dart:98` | `runCommandWithCustomArguments`: Default `runInShell` von `true` auf **`false`** | +| `lib/services/linux.dart:133` | `runProcess`: Default `runInShell` von `true` auf **`false`** | +| `lib/services/linux.dart:95-105` | Doc-Kommentar: Voraussetzung (absoluter Pfad via `expandCommand`) und Kosten des Shells präzisiert | +| `lib/services/linux.dart:1524-1536` | **Falschen Kommentar korrigiert** (siehe unten) | +| `lib/services/linux.dart:933-944` | Neue Methode `folderRecursionDepth()` — Config-Wert lesen und klemmen | +| `lib/services/linux.dart:959` | Aufrufstelle nutzt jetzt `folderRecursionDepth()` statt des rohen `final int`-Casts | +| `lib/services/linux.dart:2784-2821` | `openDiskSpaceAnalyzer`: Installationsprüfung zuerst (awaited), Start genau einmal, `runInShell: false`, `context.mounted`-Check nach dem Await | +| `lib/layouts/disk_cleaner/clean_disk.dart:61` | Aufrufer in `unawaited(...)` gewrappt (Lint `unawaited_futures`) | +| `lib/services/folder_recursion_depth.dart` (neu) | Klemmung auf 1..10, Fallback 3, über `BigInt` geklemmt (kein Überlauf) | +| `test/wp_s3_hygiene_test.dart` (neu) | 15 Tests in vier Gruppen | + +## Bestandsaufnahme vor dem Default-Flip + +Der Flip betrifft **jeden** Prozessstart der App. Geprüft und protokolliert: + +| Prüfung | Ergebnis | +|---|---| +| `grep -rnE 'runCommandWithCustomArguments\(\s*"[^"]* [^"]*"' lib/` | **0 Treffer** — kein Aufruf übergibt ein Mehrwort-Kommando als exec-String | +| `grep -rnE 'runProcess\(\s*"[^"]* [^"]*"' lib/` | **0 Treffer** | +| `grep -rn 'runInShell: true' lib/` | **0 Produktionsaufrufe** (1 Kommentar, 2 Documentation-Tests im Testfile) | +| `runCommand(String)` (`linux.dart:83-92`) | splittet bereits Dart-seitig (`command.split(' ')`) und reicht eine **Liste** weiter → vom Flip nicht betroffen | +| `expandCommand` (`linux.dart:169`) | macht aus Bare-Namen absolute Pfade (`/usr/bin/`) — die dokumentierte Voraussetzung für den Shell-Abdrehen | + +**Aufrufstellen mit variablem exec** — alle Einzelwörter, alle geprüft: + +| Stelle | Wert | Bewertung | +|---|---|---| +| `lib/services/action_handler.dart:63` | `exec` („xdg-open" u. a.) | Einzelwort ✓ | +| `lib/services/linux.dart:87` | `exec` aus `runCommand` | bereits Dart-seitig gesplittet ✓ | +| `lib/services/linux.dart:2674, 2677` | `term` (gnome-terminal, xterm …) | Einzelwort ✓ | +| `lib/services/linux.dart:3030` | `executable` (kgx, xterm, xfce4-terminal …) | Einzelwort ✓ | + +**Ergebnis:** keine Stelle musste auf `runInShell: true` festgenagelt werden. + +## Der korrigierte Kommentar + +Der alte Kommentar behauptete, mit `runInShell` werde „argv space-joined unquoted", ein HOME wie +`/home/john doe` werde mitten im Pfad abgeschnitten, und ein Shell-Metazeichen würde laufen. + +**Das ist falsch.** Dart quotet jedes argv-Element einzeln, wenn es den `sh -c`-String baut +(`_getShellArguments()` in `sdk/lib/_internal/vm/bin/process_patch.dart`, POSIX-Zweig: jedes +Argument wird als `' '` geschrieben, eingebettete Quotes als `'"'"'`). Empirisch gemessen am +2026-10-23 mit einer Sonde, die die **Argumentanzahl** zählt und nicht nur die Ausgabe vergleicht +(`/workspace/tools/rv1_probe_strict.py`, Exitcode 0 = Prämisse bestätigt): + +| Payload | argv-Elemente | Ergebnis | +|---|---|---| +| `a b c` | 1 | bleibt ein Argument | +| `x'; echo INJECTED > /tmp/pwned42; #'` | 1 | inert, kein Artefakt erzeugt | +| `$(id)` / `` `id` `` / `*` / `a\|b` | 1 | inert | + +Die Shell war also **nie** das Split-Risiko, das der Kommentar behauptete. Sie kostete einen +zusätzlichen Fork pro Aufruf und ein zweites Parsen von Argumenten, die das Programm ohnehin +verbatim bekommen hat. Der neue Kommentar sagt genau das — und der Test +„Dart haelt das Argument auch dann zusammen, wenn es eine Shell baut" (mit echtem +`Process.run(..., runInShell: true)`) sichert die Aussage im Code ab, damit sie nicht wieder zur +Behauptung wird. + +## Der Nebenbefund im Disk-Analyzer + +Der alte Ablauf war: + +```dart +runCommandWithCustomArguments("k4dirstat", [path]); // feuert UNBEDINGT +ensureApplicationInstallation(["k4dirstat"]); // danach erst der Check +if (commandQueue.isEmpty) { + runCommandWithCustomArguments("k4dirstat", [path]); // und nochmal +} +``` + +Ein nicht installiertes Programm wurde also **zweimal** gestartet (beim ersten Mal erfolglos), +und `ensureApplicationInstallation` lief nicht awaited — der Queue-Stand konnte daher noch gar +nicht gefüllt sein, wenn er gelesen wurde. Neu: Prüfung zuerst und awaited, Start genau einmal, +`context.mounted` nach dem Await, der Queue-Eintrag bleibt argv-form mit absolutem Pfad. + +## TDD-Beleg + +**RED (vor der Implementierung)** — zwei Stufen, beide protokolliert: + +*Stufe A — Shell-Defaults* (`red-stageA-shell-defaults.txt`): Die Tests compile-ten gegen den +Altstand und schlugen mit Assertion-Fehlern fehl — der Default war `true`, erwartet wurde `false`. + +*Stufe B — Tiefe und Analyzer* (`red-stageB-depth-and-analyzer.txt`): Die Klemme existierte noch +nicht, der Test referencing sie schlug mit Compile-Fehler fehl — genau die erwartete rote Phase +für ein noch nicht existierendes Symbol. + +**GREEN:** `flutter test test/wp_s3_hygiene_test.dart` → alle Tests grün; die Testdatei enthält +15 Tests in vier Gruppen: + +| Gruppe | Testfälle | +|---|---| +| Der Default der Kommandoebene ist: keine Shell | `runCommandWithCustomArguments` ohne Shell · `runProcess` reicht `false` durch · `runCommand` (String-Form) ohne Shell | +| Ein Pfad mit Leerzeichen bleibt ein Argument | Pfad kommt als ein argv-Element an · Dart haelt das Argument auch mit Shell zusammen (Dokumentationstest) · Shell-Metazeichen bleiben inert (Dokumentationstest) | +| Die Rekursionstiefe ist geklemmt | zu klein → 1 · zu gross → 10 · plausibel → unverändert · Textwert → Fallback, löst nichts aus · fehlender Config-Wert → Default · Textzahl wird angenommen · Nicht-Zahlen werden verworfen | +| Der Disk-Analyzer startet nach der Installationsprüfung | Start erfolgt erst nach der Prüfung; kein Start, wenn das Programm fehlt | + +## Gate-Protokoll + +`bash /workspace/tools/run-mla-gates.sh` — echte Exitcodes, Log: `gates-full.log`. + +| Gate | Ergebnis | +|---|---| +| `flutter pub get` | OK | +| `bash tool/check-versions.sh` | OK | +| `dart format --output=none --set-exit-if-changed lib test` | OK — 124 Dateien, 0 geändert | +| `flutter analyze` | OK — *No issues found!* | +| `flutter test` | OK — 244 Tests (230 Baseline + 14 neue) | +| Python-Unit-Tests (`additional/python`) | OK — 62 Tests | +| GTK-Token-Gate | OK — 96 Tests (2 skipped) | +| `packages/la_core` (analyze / format / test) | OK — 0 Findings, 25 Dateien, 68 Tests | + +**Summenzeile: `ALLE GATES GRUEN`, `GATES_EXIT=0`.** + +## Konventionen geprüft + +- Conventional Commit, deutschsprachiger Body, Issue-Referenz ✓ +- `dart format` vor dem Commit ✓ (Format-Gate grün) +- Logging über `lib/services/logger.dart`, kein `print` ✓ +- `unawaited_futures` beachtet (Aufrufer in `unawaited(...)`) ✓ +- Keine Secrets ✓ +- **polkit-Trinität unberührt**: `org.linux-assistant.operations.policy`, + `lib/services/linux.dart` `_privilegedEntryPoints` und der `build-deb.sh`-chmod sind in + diesem Commit nicht geändert ✓ +- Kein Versions-Bump, kein Tag, kein Release, kein Merge nach `main` ✓ + +## Bewusste Entscheidungen + +1. **Die Klemme rechnet auf `BigInt`.** Ein Wert wie `"99999999999999999999"` wird auf 10 + gesenkt und nicht auf eine Zufallszahl gekürzt. `BigInt.tryParse` akzeptiert nur Ziffern mit + optionalem Vorzeichen — kein `"3.5"`, kein `"0x10"`, kein Text mit Metazeichen. +2. **`openDiskSpaceAnalyzer` ist jetzt `Future`** und wird vom Aufrufer in + `unawaited(...)` gewrappt. Das ist die einzige Signaturänderung; es gibt genau einen Aufrufer. +3. **Die Queue-Einträge wurden nicht angefasst.** Sie waren bereits argv-form mit absolutem + Pfad (`/usr/bin/k4dirstat`) — der Fixplan verlangt ausdrücklich, sie unverändert zu lassen. +4. **`runInShell: false` steht an den drei direkten Analyzer-Aufrufen explizit**, auch obwohl + der Default jetzt schon `false` ist. Damit ist der Aufruf unabhängig vom Default und + überlebt eine künftige Default-Änderung. + +## Offene Punkte + +- **Kein `runInShell: true` mehr in `lib/`** — die App startet keinen Prozess mehr über eine + Shell. Für die beiden `runCommandWithCustomArguments`-Aufrufe in `lib/layouts/hub/` und + `lib/services/action_handler.dart` ist das Verhalten unverändert (Dart quotet ohnehin + elementweise), der zusätzliche Fork pro Aufruf entfällt. +- **Der manuelle Abnahmeschritt** (Issue #51, RV-1 Probe 1 und 2) bleibt offen: die + GUI-basierten Nachweise brauchen ein echtes Desktop-System. +- **Kein CI-Eingriff.** `.github/workflows/build.yml` wurde nicht angefasst; die CI läuft + `flutter build linux` erst **nach** den Tests, daher ist der vergiftete-`build/`-Fall dort + nicht vorhanden. diff --git a/docs/handoff/v0.8.6-integration-abschluss.md b/docs/handoff/v0.8.6-integration-abschluss.md new file mode 100644 index 00000000..bc16e315 --- /dev/null +++ b/docs/handoff/v0.8.6-integration-abschluss.md @@ -0,0 +1,100 @@ +# Abnahme-Nachweis: Integration der drei Spuren (WP-S3 + Q1 + Q2/Q3/Q5/Q6) + +> **Stand:** 2026-10-23 · **Basis:** `main` @ `08dad94` · **Branch:** `integration/all-tracks` +> **Zweck:** Nachweis, dass die drei parallel entstandenen Spuren **gemeinsam** sauber +> funktionieren — nicht nur einzeln. + +## Was hier geprüft wurde + +Drei Spuren wurden unabhängig voneinander entwickelt, jede in ihrem eigenen Git-Worktree, +jede mit eigener Testdatei und eigenem Nachweis-Doc: + +| Spur | Branch | Nachweis-Doc | Tests | +|---|---|---|---| +| WP-S3 Security-Hygiene | `feature/56-wp-s3-hygiene` | `docs/handoff/v0.8.2-wp-s3-security-hygiene.md` | 14 | +| Q1 Security-Scan auf Knopfdruck | `feature/58-q1-security-scan` | `docs/handoff/v0.8.6-q1-security-scan.md` | 29 | +| Q2+Q3+Q5+Q6 Dashboard-Kacheln | `feature/58-q2-q6-dashboard` | `docs/handoff/v0.8.6-q2-q6-dashboard.md` | 24 | + +## Merge-Ergebnis + +Die drei Branches wurden hier in einen Stand zusammengeführt. **Ein echter Konflikt** +trat auf — und zwar in genau den Dateien, die parallele Spuren zwangsläufig gemeinsam +anfassen: + +``` +CONFLICT (content): Merge conflict in lib/l10n/app_localizations_it.dart +Auto-merging lib/l10n/linuxassistant_fi.arb +CONFLICT (content): Merge conflict in lib/l10n/linuxassistant_fi.arb +``` + +**27 Konflikte in 9 Dateien**, alle in `lib/l10n/`. Grund: Q1 und der Dashboard-Track haben +beide neue l10n-Strings eingefügt und beide die generierten Lokalisierungen mit +`flutter gen-l10n` neu erzeugt. Beide hängen ihre Keys ans Ende der ARB-Dateien — dieselbe +Stelle, also kollidieren die Dateien. + +**Auflösung (ohne Handarbeit an generierten Dateien):** + +1. Die vier **ARB-Dateien** sind die Quelle. Sie wurden als JSON aus beiden Seiten + vereinigt: Key-Vereinigung, **null Wertwidersprüche** (die Spuren haben verschiedene + Keys ergänzt, keine gemeinsamen geändert). Ergebnis: 30 zusätzliche Keys je Datei + (15 aus Q1 + 15 aus dem Dashboard-Track). +2. Die fünf **generierten** Dateien wurden verworfen und mit `flutter gen-l10n` neu + erzeugt. Abgeleitete Dateien werden nie per Hand zusammengeführt. + +Ergebnis: **0 unaufgelöste Konflikte**, 399 Dateien im gemergten Stand. + +## Gate-Protokoll auf dem gemergten Stand + +`bash /workspace/tools/run-mla-gates.sh` — echte Exitcodes, Ausgabe nennt den geprüften Baum. + +| Gate | Ergebnis | +|---|---| +| `flutter pub get` | OK | +| `bash tool/check-versions.sh` | OK | +| `dart format --output=none --set-exit-if-changed lib test` | OK — 133 Dateien, 0 geändert | +| `flutter analyze` | OK — *No issues found!* (15,9 s) | +| `flutter test` | OK — **297 Tests** (230 Baseline + 67 neue) | +| Python-Unit-Tests | OK — 62 Tests | +| GTK-Token-Gate | OK — 96 Tests (2 skipped) | +| `packages/la_core` (analyze / format / test) | OK — 0 Findings, 25 Dateien, 68 Tests | + +**Summenzeile: `ALLE GATES GRUEN`.** + +## Wechselwirkungen der Spuren + +| Wechselwirkung | Befund | +|---|---| +| `runInShell`-Default (WP-S3) gegen die neuen Kommandoaufrufe (Dashboard) | Die neuen Aufrufe setzen `runInShell: false` **explizit** — unabhängig vom Default, der in einem parallelen Track geflippt wurde. Kein Zusammenspiel. | +| Q1 (`security_check/overview.dart`) gegen der Dashboard-Track | Verschiedene Dateien. Der Hub ruft die Security-Seite nicht auf. | +| l10n | Einziger Konfliktpunkt, oben aufgelöst. Beide Spuren haben **verschiedene** Keys ergänzt. | +| `system_stats_service.dart` (Dashboard) gegen `linux.dart` (WP-S3) | Der Dashboard-Track liest `SystemStatsService`, WP-S3 ändert `Linux.runProcess`. Der Default-Flip betrifft auch die Stats-Aufrufe — sie nutzen argv-Listen, das Verhalten bleibt identisch. Die 297 Tests decken das ab. | +| `features.csv` | Nur vom Dashboard-Track geändert. | +| `hub_navigation_test.dart` | Die beiden neuen Poller starten im Test sonst einen echten `systemctl`-Prozess; der Test wurde hermetisch gemacht. | + +## Empfehlung für den Merge + +Die drei Einzel-PRs lassen sich **nicht konfliktfrei nacheinander mergen** — beim zweiten +Merge kollidieren die l10n-Dateien erneut. + +**Deshalb ist dieser kombinierte PR der empfohlene Weg:** ein Merge, kein Konflikt, alle +Gates grün, 297 Tests. + +Die einzelnen Branches bleiben als Review-Einheiten bestehen: wer die Spuren einzeln +prüfen will, liest `docs/handoff/v0.8.2-wp-s3-security-hygiene.md`, +`docs/handoff/v0.8.6-q1-security-scan.md` und +`docs/handoff/v0.8.6-q2-q6-dashboard.md`. + +Falls die drei Einzel-PRs stattdessen einzeln gemergt werden sollen, ist bei jedem +l10n-Konflikt dasselbe Verfahren anzuwenden: ARB-Dateien vereinigen, generierte Dateien +neu erzeugen. + +## Offene Punkte + +- **Der manuelle Abnahmeschritt** bleibt offen: der Security-Scan braucht root, der + Brave-Origin-Vergleich braucht ein echtes Desktop. Der Merge beweist die Logik, nicht + das Verhalten auf Bastis Rechner. +- **Q4** (Hotkey auf gsettings) und **Q7** (Feedback abklemmen) sind nicht Teil dieses + Pakets. +- **Die Issues** #46, #50, #56 und #58 bleiben offen, bis der Fix in `main` ist — nach + derselben Regel wie beim vorigen Audit: geschlossen wird nur, was nachweislich in + `main` steht. diff --git a/docs/handoff/v0.8.6-q1-security-scan.md b/docs/handoff/v0.8.6-q1-security-scan.md new file mode 100644 index 00000000..16c8d521 --- /dev/null +++ b/docs/handoff/v0.8.6-q1-security-scan.md @@ -0,0 +1,111 @@ +# deliverable.md — V0.8.6 Welle 1: Q1 Security-Scan auf Knopfdruck (#58) + +> **Stand:** 2026-10-23 · **Basis:** `main` @ `08dad94` · **Branch:** `feature/58-q1-security-scan` +> **Umfang:** Q1 aus Issue #58 — der Security-Check startet nicht mehr beim Betreten der +> Sektion, sondern auf Knopfdruck, und sein Ergebnis überlebt einen App-Neustart. + +## Was geändert wurde + +| Datei | Änderung | +|---|---| +| `lib/services/security_scan_cache.dart` (neu) | Cache des letzten Scan-Ergebnisses unter `$XDG_CACHE_HOME/linux-assistant/security-last.json` | +| `lib/layouts/security_check/overview.dart` | Scan startet nur auf Klick; Cache wird gelesen und geschrieben; Alter des gecachten Ergebnisses wird angezeigt | +| `lib/l10n/*.arb` + generierte Dateien | 7 neue Strings in **allen vier** Sprachen (en/de/it/fi) | +| `test/q1_security_scan_test.dart` (neu) | 29 Tests: Cache-Ort, Snapshot-Format, Altersberechnung, Cache-Verhalten, Seiten-Verhalten | + +## Die Abnahmekriterien aus #58 + +| Kriterium | Umsetzung | Nachweis | +|---|---|---| +| Der Scan startet **nicht** beim ersten Build | `_checkerOutput` ist nullable und bleibt null, bis der Knopf gedrückt wird | `the page starts no scan when the page is opened` — `runner.calls` ist leer | +| Kein pkexec-Dialog beim Betreten | dieselbe Ursache: es wird kein Befehl gestartet | derselbe Test | +| Ergebnis in `$XDG_CACHE_HOME/linux-assistant/security-last.json` | `SecurityScanCache` — `XDG_CACHE_HOME` mit Fallback `~/.cache` | `cache location`-Gruppe (3 Tests) + `the result file is security-last.json in that directory` | +| Befund-Tokens + Zeitstempel | `SecurityScanSnapshot` mit `tokens` (die Zeilen des Checkers) und `timestamp` (UTC, ISO 8601) | `the JSON shape is stable` + `cache the written file is the documented JSON` | +| Nach Neustart mit „vor X" | Die Seite liest den Cache beim Öffnen und zeigt das Alter | `shows the cached result with its age after a restart` | +| l10n in allen vier Sprachen | 7 Keys × (en, de, it, fi) | `flutter gen-l10n` erzeugt, l10n-Ratchet grün | + +### Das Cache-Format + +```json +{ + "version": 1, + "timestamp": "2026-10-23T12:30:00.000Z", + "tokens": ["firewallinactive", "upgradeablepackage: linux-assistant-1.0"] +} +``` + +Der Cache ist ein **Cache**, keine Konfiguration: er darf jederzeit gelöscht werden, und die App +muss sein Fehlen überstehen. Deshalb: + +- **Format-Version.** Eine Datei mit anderer Version wird ignoriert, nicht missverstanden. +- **Defekte Datei.** Eine kaputte Datei liefert `null` statt einer Exception — die Seite + zeigt „noch kein Check gelaufen", nicht einen Fehlerdialog. +- **Atomares Schreiben.** Die Datei wird als Nachbar geschrieben und danach umbenannt. Eine + App, die mitten im Schreiben stirbt, hinterlässt keine halbe Datei, die niemand mehr lesen kann. +- **Tokens statt Sätzen.** Gespeichert werden die Zeilen des Checkers, nicht die Sätze der UI. + Die Sätze werden bei jedem Start neu abgeleitet — ein auf Deutsch gecachter Befund liest sich + nach einem Wechsel auf Finnisch trotzdem richtig. +- **Erfolgsschild.** Die Erfolgsmarke des Checkers ist kein Befund und wird nicht gespeichert. + +## TDD-Beleg + +`test/q1_security_scan_test.dart` — 29 Tests in fünf Gruppen: + +| Gruppe | Testfälle | +|---|---| +| `cache location` | `XDG_CACHE_HOME` wird beachtet · Fallback `~/.cache` · leeres `XDG_CACHE_HOME` · Datei heißt `security-last.json` | +| `snapshot` | Befundzeilen bleiben, Erfolgsschild fällt weg · der Inhalt bleibt erhalten · JSON-Roundtrip · stabiles Format · fremde Version abgelehnt · unbrauchbarer Zeitstempel abgelehnt · Nicht-Text-Tokens abgelehnt | +| `age` | unter einer Minute = „gerade eben" · Minuten · Stunden · Tage · Zeitstempel in der Zukunft = „gerade eben" · exakt eine Minute ist eine Minute | +| `cache` | vor dem ersten Scan kein Ergebnis · schreiben und lesen · Verzeichnis wird angelegt · kaputte Datei überlebt · leere Datei überlebt · keine temporäre Datei bleibt liegen · geschriebene Datei ist das dokumentierte JSON | +| `the page` | **kein Scan beim Öffnen** · der Knopf ist da · genau ein Scan pro Druck · das Ergebnis landet im Cache · nach „Neustart" zeigt die Seite das gecachte Ergebnis mit Alter — und startet nichts | + +## Gate-Protokoll + +`bash /workspace/tools/run-mla-gates.sh` — echte Exitcodes. + +| Gate | Ergebnis | +|---|---| +| `flutter pub get` | OK | +| `bash tool/check-versions.sh` | OK | +| `dart format --output=none --set-exit-if-changed lib test` | OK — 126 Dateien, 0 geändert | +| `flutter analyze` | OK — *No issues found!* | +| `flutter test` | OK — **259 Tests** (230 Baseline + 29 neue) | +| Python-Unit-Tests | OK — 62 Tests | +| GTK-Token-Gate | OK — 96 Tests (2 skipped) | +| `packages/la_core` | OK — 0 Findings, 25 Dateien, 68 Tests | + +**Summenzeile: `ALLE GATES GRUEN`.** + +## Konventionen geprüft + +- Conventional Commit, deutschsprachiger Body, `Refs #58` ✓ +- `dart format` vor dem Commit ✓ +- Logging über `lib/services/logger.dart`, kein `print` ✓ +- l10n: 7 Keys in allen vier ARB-Dateien, generiert mit `flutter gen-l10n` ✓ +- Keine Secrets, kein Versions-Bump, kein Release ✓ +- **Die Security-Check-Logik selbst wurde nicht angefasst** — der privilegierte Runner und die + polkit-Aktionen bleiben unangetastet. Der Cache speichert nur, was der Checker ohnehin + ausgibt. + +## Zwei Korrekturen an der Vorarbeit + +1. **`setState` mit Future.** `setState(() => _checkerOutput = _runAndStore())` übergibt dem + Callback ein Future — Flutter lehnt das ab („setState() callback argument returned a + Future"). Der Callback braucht einen Block-Body. +2. **Echte Datei-IO im Widget-Test.** `await cache.read()` in der Fake-Async-Zone eines + `testWidgets` bekommt sein IO-Ereignis nie und der Test hängt bis zum Zeitüberlauf. Echte + IO muss über `tester.runAsync(...)` laufen. Betroffen waren zwei Stellen im Test. + +Beides war in der Vorarbeit so nicht vorhanden und ist hier behoben. + +## Offene Punkte + +- **Der manuelle Abnahmeschritt** bleibt offen: ein echter Scan braucht root und damit ein + Passwort-Dialog. In der Sandbox läuft der Check über einen Fake-Runner, der das + Checker-Format liefert. Der Button und der Cache-Pfad sind damit geprüft, das echte + `pkexec` nicht. +- **Was passiert, wenn der Check fehlschlägt?** `_rootRequired` und `_scriptFailed` bleiben + unverändert — sie zeigen den Fehler und bieten den erneuten Versuch. In den Cache landet + nur ein erfolgreicher Lauf; ein fehlgeschlagener verwischt das letzte gecachte Ergebnis + nicht. +- **Q4 und Q7** (Hotkey bzw. Feedback-Entfernung) sind nicht Teil dieses Pakets. diff --git a/docs/handoff/v0.8.6-q2-q6-dashboard.md b/docs/handoff/v0.8.6-q2-q6-dashboard.md new file mode 100644 index 00000000..b780230a --- /dev/null +++ b/docs/handoff/v0.8.6-q2-q6-dashboard.md @@ -0,0 +1,135 @@ +# deliverable.md — V0.8.6 Welle 1: Q2, Q3, Q5, Q6 (#58) + +> **Stand:** 2026-10-23 · **Basis:** `main` @ `08dad94` · **Branch:** `feature/58-q2-q6-dashboard` +> **Umfang:** die vier Dashboard-Punkte Q2, Q3, Q5 und Q6 aus Issue #58. + +## Was geändert wurde + +| Datei | Änderung | +|---|---| +| `lib/linux/linux_memory.dart` (neu) | `MemoryPressure` (PSI aus `/proc/pressure/memory`) und `ZramInfo` (zram aus `/sys/block/zram*/mm_stat` + `disksize`) mit reinen Parse-Funktionen | +| `lib/services/reboot_service.dart` (neu) | Liest `/var/run/reboot-required(.pkgs)`, parst die Paketliste | +| `lib/services/failed_units_service.dart` (neu) | Liest `systemctl list-units --failed -o json` in **beiden** Scopes (System und `--user`) | +| `lib/services/browser_status_service.dart` (neu) | Read-only-Status des Standardbrowsers aus `AppLauncher.defaultBrowserDesktopId()`, Abgleich mit `gio mime x-scheme-handler/https` | +| `lib/layouts/hub/dashboard_section.dart` | Neustart-Badge, Kachel „Fehlgeschlagene Dienste", Browser-Fakt, ehrliche Speicher-Kachel (PSI + getrennte zram-/Disk-Swap-Zeile) | +| `lib/layouts/hub/hub_shell.dart` | Die neuen Poller laufen nur, wenn ihre Kachel wirklich sichtbar ist; Fenster-Sichtbarkeit an alle drei Services | +| `lib/layouts/linux_health/overview.dart` | Zeile in der Health-Sektion mit den auslösenden Paketen | +| `lib/services/system_stats_service.dart` | hängt die neuen Werte an (zram, PSI) an | +| `lib/l10n/*.arb` + generierte Dateien | 15 neue Strings in **allen vier** Sprachen (en/de/it/fi), generiert mit `flutter gen-l10n` | +| `features.csv` | eine neue Zeile „Browser status" (Core) | +| `test/q_dashboard_kacheln_test.dart` (neu) | 24 Tests für die vier Parser | +| `test/hub_navigation_test.dart` | Hermetizität: die zwei neuen Poller schlafen genauso wie der Stats-Poller | + +## Die vier Abnahmekriterien + +### Q2 — Neustart-Badge + +`RebootService` liest `/var/run/reboot-required` (Marker) und `/var/run/reboot-required.pkgs` +(Paketliste). Das Dashboard zeigt ein Badge („Neustart nötig") und die Health-Sektion listet +die auslösenden Pakete. Fehlt die Paketliste, ist der Neustart trotzdem fällig — die Ursache +lässt sich dann nur nicht benennen. Das Badge verschwindet, sobald die Marker-Datei nach dem +Neustart weg ist. + +### Q3 — Kachel „Fehlgeschlagene Dienste" + +`FailedUnitsService` fragt **beide** Scopes ab: `systemctl list-units --failed -o json` und +`systemctl --user list-units --failed -o json`. Die Kachel zeigt die Zahl und die Liste mit +Unit, Beschreibung und Scope. Kaputtes JSON, ein leerer Wert oder ein Unit-Eintrag ohne Namen +werden übersprungen, statt die Kachel zu sprengen. Eigener Poller mit 30-Sekunden-Takt — der +Zustand ändert sich viel langsamer als die CPU-Zahlen. + +### Q5 — Browser-Status + +Read-only. Der Standardbrowser kommt aus `AppLauncher.defaultBrowserDesktopId()` (aus V0.8.2), +der Vergleich gegen `gio mime x-scheme-handler/https` liefert den Handler, den HTTPS-Links +tatsächlich öffnen. Weichen die beiden ab, steht eine Warnung daneben. Die App stellt nichts +um. + +**Das Ausgabeformat von `gio` wurde gemessen, nicht geraten** (gio 2.74): + +``` +Default application for ?x-scheme-handler/https?: brave-browser.desktop +No registered applications +No recommended applications +``` + +Der Parser liest die erste Zeile; die beiden anderen Abschnitte listen nur Kandidaten und +werden ignoriert. + +### Q6 — Speicher-Kachel ehrlich + +Die Warnstufe kommt jetzt aus **PSI** (`/proc/pressure/memory`, `avg60`) statt aus der +RAM-Quote. Die Swap-Zeile ist aufgeteilt: zram (`/sys/block/zram*/mm_stat`, komprimierter +RAM-Speicher) und Disk-Swap stehen in zwei eigenen Zeilen, weil sie verschiedene Dinge sind. +zram zeigt den Weg „7,2 → 2,1 GB". + +## TDD-Beleg + +`test/q_dashboard_kacheln_test.dart` — 24 Tests in vier Gruppen: + +| Gruppe | Testfälle | +|---|---| +| Q2 — der Reboot-Marker | Paketliste · leere Zeilen und Kommentare werden übersprungen · leere Datei · ohne Marker kein Reboot · Marker ohne Paketliste · Marker mit Paketliste | +| Q3 — die fehlgeschlagenen Units | Unit, Beschreibung und Scope · der Scope kommt vom gefragten Manager · Unit ohne Beschreibung · Eintrag ohne Namen · kaputtes JSON · leere Antwort | +| Q5 — der Standardbrowser | Handler wird gelesen · Kandidaten-Sektionen werden ignoriert · Ausgabe ohne Default-Zeile · leerer Wert | +| Q6 — die Speicher-Kachel | `avg60` aus der PSI-Datei · normal / elevated / high · unbrauchbare PSI-Datei · zram vor/nach der Kompression · kaputte zram-Statistik · zram-Kapazität | + +Die Tests laufen ohne Dateisystem und ohne Prozess: die Parser sind reine Funktionen, der +Dateisystem-Teil ist in `RebootService.readStatus` getrennt (dort mit Temp-Verzeichnis getestet). + +## Gate-Protokoll + +`bash /workspace/tools/run-mla-gates.sh` — echte Exitcodes, Ausgabe zeigt den geprüften Baum. + +| Gate | Ergebnis | +|---|---| +| `flutter pub get` | OK | +| `bash tool/check-versions.sh` | OK | +| `dart format --output=none --set-exit-if-changed lib test` | OK — 129 Dateien, 0 geändert | +| `flutter analyze` | OK — *No issues found!* | +| `flutter test` | OK — **254 Tests** (230 Baseline + 24 neue) | +| Python-Unit-Tests | OK — 62 Tests | +| GTK-Token-Gate | OK — 96 Tests (2 skipped) | +| `packages/la_core` | OK — 0 Findings, 25 Dateien, 68 Tests | + +**Summenzeile: `ALLE GATES GRUEN`.** + +**CSV-Check:** `{21} [25, 17, 4]` — 21 Felder, 46 Zeilen. Core steigt von 24 auf 25 durch die +neue Zeile „Browser status" (Core, gleiche Spaltenbelegung wie der Browser-Launcher). + +## Konventionen geprüft + +- Conventional Commit, deutschsprachiger Body, `Refs #58` ✓ +- `dart format` vor dem Commit ✓ +- Logging über `lib/services/logger.dart`, kein `print` ✓ +- **l10n-Ratchet:** alle vier ARB-Dateien (en/de/it/fi) mit denselben 15 neuen Keys; die + generierten Dateien mit `flutter gen-l10n` neu erzeugt ✓ +- features.csv: 21 Felder, Header unverändert ✓ +- Keine Secrets, kein Versions-Bump, kein Release ✓ +- **Kein Prozessstart beim Betreten des Dashboards ohne Nutzerwunsch:** die neuen Poller + starten `systemctl` bzw. `gio` erst, wenn ihre Kachel sichtbar ist — im Test nachgewiesen + (der Hub-Navigation-Test startet keinen Prozess) + +## Zwei Korrekturen an der Vorarbeit + +1. **Das `gio`-Format.** Die ursprüngliche Parser-Version erwartete die Zeile + `x-scheme-handler/https: `. Diese Zeile gibt `gio` **nie** aus — der Parser hätte in + Produktion immer `null` geliefert und die Kachel hätte nie den Browser gezeigt. Das + tatsächliche Format steht oben; Parser und Test sind darauf umgestellt. + +2. **Die Hermetizität des Hub-Tests.** Die beiden neuen Poller starten beim Mount des + Dashboards einen echten Prozess. Im Widget-Test hinterlässt das einen hängenden Timer und + der Test schlägt fehl. Die Poller werden jetzt im `setUp` genauso schlafen gelegt wie der + Stats-Poller — die Reihenfolge (erst `resetForTesting`, dann `setWindowVisible(false)`) + ist dokumentiert. + +## Offene Punkte + +- **Q7 bleibt offen** — die Empfehlung „Feedback an Upstream abklemmen" braucht Bastis + Entscheidung; das Issue ist nicht Teil dieses Pakets. +- **Q4 bleibt offen** — der Wechsel des Hotkeys auf gsettings ist ein M-Thema, greift in den + C++/GTK-Runner ein und braucht ein Desktop-System zum Abnehmen. +- **Die Kacheln zeigen Systemzustand, keine Aktion.** Weder Reboot noch Dienste noch Browser + werden von der App aus verändert — der Safety-Default des Projekts bleibt. +- **Die visuellen Details** (Farbe des Badges, Zeilenumbrüche in der Health-Sektion) sind + nicht per Screenshot geprüft; die Logik ist es. From 10f2f3551f8dec1dbea1e40611d9a1a17b6223a8 Mon Sep 17 00:00:00 2001 From: ElGregor Date: Sat, 24 Oct 2026 01:58:13 +0200 Subject: [PATCH 2/8] =?UTF-8?q?feat(mla):=20WP-S3=20+=20V0.8.6=20Welle=201?= =?UTF-8?q?=20(Q1,=20Q2,=20Q3,=20Q5,=20Q6)=20=E2=80=94=20zusammengef=C3=BC?= =?UTF-8?q?hrt=20(Teil=202/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- features.csv | 1 + lib/l10n/app_de.arb | 154 ++++++++++++++++++++++++++++++++++++++++++++ lib/l10n/app_en.arb | 154 ++++++++++++++++++++++++++++++++++++++++++++ lib/l10n/app_it.arb | 154 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 463 insertions(+) diff --git a/features.csv b/features.csv index 07fb2df4..cd62393d 100644 --- a/features.csv +++ b/features.csv @@ -41,6 +41,7 @@ Live system tiles (CPU/RAM/disk),QoL,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 +Browser status,Core,?,?,?,?,?,?,?,yes,?,?,?,?,?,?,yes,?,?,?,fork hub tool; read-only browser status in the dashboard; 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 diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1eb4b56a..149a70f8 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1332,5 +1332,159 @@ "placeholders": {}, "description": "Explains what confirming the dialog above does", "type": "text" + }, + "securityScanRunCheck": "Jetzt prüfen", + "@securityScanRunCheck": { + "description": "Button that starts the security scan on demand", + "type": "text" + }, + "securityScanNotRunYet": "Es wurde noch nicht geprüft.", + "@securityScanNotRunYet": { + "description": "Shown on the security page before the first scan", + "type": "text" + }, + "securityScanLastRun": "Letzte Prüfung: {age}", + "@securityScanLastRun": { + "placeholders": { + "age": { + "type": "String" + } + }, + "description": "When the last security check ran, with its age", + "type": "text" + }, + "securityScanAgeJustNow": "gerade eben", + "@securityScanAgeJustNow": { + "description": "Age of a security check that just ran", + "type": "text" + }, + "securityScanAgeMinutes": "vor {count} Min.", + "@securityScanAgeMinutes": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in minutes", + "type": "text" + }, + "securityScanAgeHours": "vor {count} Std.", + "@securityScanAgeHours": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in hours", + "type": "text" + }, + "securityScanAgeDays": "vor {count} T.", + "@securityScanAgeDays": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in days", + "type": "text" + }, + "securityScanRootHint": "Die Prüfung braucht Root-Rechte und fragt deshalb nach deinem Passwort.", + "@securityScanRootHint": { + "description": "Explains why pressing the check button asks for a password", + "type": "text" + }, + "browserDefaultIs": "Standardbrowser: {standard}", + "@browserDefaultIs": { + "placeholders": { + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "browserDefaultUnknown": "Standardbrowser unbekannt", + "@browserDefaultUnknown": { + "placeholders": {}, + "type": "text" + }, + "browserHandlerMismatch": "HTTPS-Links oeffnen in {handler}, nicht in {standard}", + "@browserHandlerMismatch": { + "placeholders": { + "handler": { + "type": "String" + }, + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "diskSwapLabel": "Swap auf der Festplatte", + "@diskSwapLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesLabel": "Fehlgeschlagene Dienste", + "@failedServicesLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesMore": "und {rest} weitere", + "@failedServicesMore": { + "placeholders": { + "rest": { + "type": "int" + } + }, + "type": "text" + }, + "failedServicesNone": "keine", + "@failedServicesNone": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeSystem": "System", + "@failedServicesScopeSystem": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeUser": "Benutzer", + "@failedServicesScopeUser": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureLabel": "Speicherdruck", + "@memoryPressureLabel": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureUnavailable": "nicht verfügbar", + "@memoryPressureUnavailable": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredBadge": "Neustart nötig", + "@rebootRequiredBadge": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredNoPackages": "Neustart nötig (kein Paket genannt)", + "@rebootRequiredNoPackages": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredPackages": "Neustart nötig nach dem Update von: {packages}", + "@rebootRequiredPackages": { + "placeholders": { + "packages": { + "type": "String" + } + }, + "type": "text" + }, + "zramLabel": "zram (komprimiert)", + "@zramLabel": { + "placeholders": {}, + "type": "text" } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 16174cae..e73225ef 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1298,5 +1298,159 @@ "placeholders": {}, "description": "Explains what confirming the dialog above does", "type": "text" + }, + "securityScanRunCheck": "Check now", + "@securityScanRunCheck": { + "description": "Button that starts the security scan on demand", + "type": "text" + }, + "securityScanNotRunYet": "No security check has been run yet.", + "@securityScanNotRunYet": { + "description": "Shown on the security page before the first scan", + "type": "text" + }, + "securityScanLastRun": "Last check: {age}", + "@securityScanLastRun": { + "placeholders": { + "age": { + "type": "String" + } + }, + "description": "When the last security check ran, with its age", + "type": "text" + }, + "securityScanAgeJustNow": "just now", + "@securityScanAgeJustNow": { + "description": "Age of a security check that just ran", + "type": "text" + }, + "securityScanAgeMinutes": "{count} min ago", + "@securityScanAgeMinutes": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in minutes", + "type": "text" + }, + "securityScanAgeHours": "{count} h ago", + "@securityScanAgeHours": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in hours", + "type": "text" + }, + "securityScanAgeDays": "{count} d ago", + "@securityScanAgeDays": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in days", + "type": "text" + }, + "securityScanRootHint": "The check needs root rights, so it will ask for your password.", + "@securityScanRootHint": { + "description": "Explains why pressing the check button asks for a password", + "type": "text" + }, + "browserDefaultIs": "Default browser: {standard}", + "@browserDefaultIs": { + "placeholders": { + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "browserDefaultUnknown": "Default browser unknown", + "@browserDefaultUnknown": { + "placeholders": {}, + "type": "text" + }, + "browserHandlerMismatch": "HTTPS links open in {handler}, not in {standard}", + "@browserHandlerMismatch": { + "placeholders": { + "handler": { + "type": "String" + }, + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "diskSwapLabel": "Disk swap", + "@diskSwapLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesLabel": "Failed services", + "@failedServicesLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesMore": "and {rest} more", + "@failedServicesMore": { + "placeholders": { + "rest": { + "type": "int" + } + }, + "type": "text" + }, + "failedServicesNone": "none", + "@failedServicesNone": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeSystem": "System", + "@failedServicesScopeSystem": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeUser": "User", + "@failedServicesScopeUser": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureLabel": "Memory pressure", + "@memoryPressureLabel": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureUnavailable": "not available", + "@memoryPressureUnavailable": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredBadge": "Reboot required", + "@rebootRequiredBadge": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredNoPackages": "Reboot required (no package named)", + "@rebootRequiredNoPackages": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredPackages": "Reboot required after updating: {packages}", + "@rebootRequiredPackages": { + "placeholders": { + "packages": { + "type": "String" + } + }, + "type": "text" + }, + "zramLabel": "zram (compressed)", + "@zramLabel": { + "placeholders": {}, + "type": "text" } } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 7ac3bc1d..ac0cb584 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1318,5 +1318,159 @@ "placeholders": {}, "description": "Explains what confirming the dialog above does", "type": "text" + }, + "securityScanRunCheck": "Controlla ora", + "@securityScanRunCheck": { + "description": "Button that starts the security scan on demand", + "type": "text" + }, + "securityScanNotRunYet": "Non è ancora stato eseguito alcun controllo di sicurezza.", + "@securityScanNotRunYet": { + "description": "Shown on the security page before the first scan", + "type": "text" + }, + "securityScanLastRun": "Ultimo controllo: {age}", + "@securityScanLastRun": { + "placeholders": { + "age": { + "type": "String" + } + }, + "description": "When the last security check ran, with its age", + "type": "text" + }, + "securityScanAgeJustNow": "appena", + "@securityScanAgeJustNow": { + "description": "Age of a security check that just ran", + "type": "text" + }, + "securityScanAgeMinutes": "{count} min fa", + "@securityScanAgeMinutes": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in minutes", + "type": "text" + }, + "securityScanAgeHours": "{count} h fa", + "@securityScanAgeHours": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in hours", + "type": "text" + }, + "securityScanAgeDays": "{count} g fa", + "@securityScanAgeDays": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in days", + "type": "text" + }, + "securityScanRootHint": "Il controllo richiede i privilegi root, quindi ti chiederà la password.", + "@securityScanRootHint": { + "description": "Explains why pressing the check button asks for a password", + "type": "text" + }, + "browserDefaultIs": "Browser predefinito: {standard}", + "@browserDefaultIs": { + "placeholders": { + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "browserDefaultUnknown": "Browser predefinito sconosciuto", + "@browserDefaultUnknown": { + "placeholders": {}, + "type": "text" + }, + "browserHandlerMismatch": "I link HTTPS si aprono in {handler}, non in {standard}", + "@browserHandlerMismatch": { + "placeholders": { + "handler": { + "type": "String" + }, + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "diskSwapLabel": "Swap su disco", + "@diskSwapLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesLabel": "Servizi falliti", + "@failedServicesLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesMore": "e altri {rest}", + "@failedServicesMore": { + "placeholders": { + "rest": { + "type": "int" + } + }, + "type": "text" + }, + "failedServicesNone": "nessuno", + "@failedServicesNone": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeSystem": "Sistema", + "@failedServicesScopeSystem": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeUser": "Utente", + "@failedServicesScopeUser": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureLabel": "Pressione sulla memoria", + "@memoryPressureLabel": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureUnavailable": "non disponibile", + "@memoryPressureUnavailable": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredBadge": "Riavvio necessario", + "@rebootRequiredBadge": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredNoPackages": "Riavvio necessario (nessun pacchetto indicato)", + "@rebootRequiredNoPackages": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredPackages": "Riavvio necessario dopo l'aggiornamento di: {packages}", + "@rebootRequiredPackages": { + "placeholders": { + "packages": { + "type": "String" + } + }, + "type": "text" + }, + "zramLabel": "zram (compresso)", + "@zramLabel": { + "placeholders": {}, + "type": "text" } } From 2fadc788b12bf2634a04bd149108a1fbb23ae4f7 Mon Sep 17 00:00:00 2001 From: ElGregor Date: Sat, 24 Oct 2026 01:58:16 +0200 Subject: [PATCH 3/8] =?UTF-8?q?feat(mla):=20WP-S3=20+=20V0.8.6=20Welle=201?= =?UTF-8?q?=20(Q1,=20Q2,=20Q3,=20Q5,=20Q6)=20=E2=80=94=20zusammengef=C3=BC?= =?UTF-8?q?hrt=20(Teil=203/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_localizations.dart | 138 +++++++++++++++++++++++++++++ lib/l10n/app_localizations_de.dart | 86 ++++++++++++++++++ lib/l10n/app_localizations_en.dart | 86 ++++++++++++++++++ lib/l10n/app_localizations_fi.dart | 88 ++++++++++++++++++ 4 files changed, 398 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 938bd118..26d8175e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2147,6 +2147,144 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'The file will be executed as a program with your user rights. Only continue if you know what it does.'** String get runFileWarning; + + /// Button that starts the security scan on demand + /// + /// In en, this message translates to: + /// **'Check now'** + String get securityScanRunCheck; + + /// Shown on the security page before the first scan + /// + /// In en, this message translates to: + /// **'No security check has been run yet.'** + String get securityScanNotRunYet; + + /// When the last security check ran, with its age + /// + /// In en, this message translates to: + /// **'Last check: {age}'** + String securityScanLastRun(String age); + + /// Age of a security check that just ran + /// + /// In en, this message translates to: + /// **'just now'** + String get securityScanAgeJustNow; + + /// Age of a security check in minutes + /// + /// In en, this message translates to: + /// **'{count} min ago'** + String securityScanAgeMinutes(int count); + + /// Age of a security check in hours + /// + /// In en, this message translates to: + /// **'{count} h ago'** + String securityScanAgeHours(int count); + + /// Age of a security check in days + /// + /// In en, this message translates to: + /// **'{count} d ago'** + String securityScanAgeDays(int count); + + /// Explains why pressing the check button asks for a password + /// + /// In en, this message translates to: + /// **'The check needs root rights, so it will ask for your password.'** + String get securityScanRootHint; + + /// No description provided for @browserDefaultIs. + /// + /// In en, this message translates to: + /// **'Default browser: {standard}'** + String browserDefaultIs(String standard); + + /// No description provided for @browserDefaultUnknown. + /// + /// In en, this message translates to: + /// **'Default browser unknown'** + String get browserDefaultUnknown; + + /// No description provided for @browserHandlerMismatch. + /// + /// In en, this message translates to: + /// **'HTTPS links open in {handler}, not in {standard}'** + String browserHandlerMismatch(String handler, String standard); + + /// No description provided for @diskSwapLabel. + /// + /// In en, this message translates to: + /// **'Disk swap'** + String get diskSwapLabel; + + /// No description provided for @failedServicesLabel. + /// + /// In en, this message translates to: + /// **'Failed services'** + String get failedServicesLabel; + + /// No description provided for @failedServicesMore. + /// + /// In en, this message translates to: + /// **'and {rest} more'** + String failedServicesMore(int rest); + + /// No description provided for @failedServicesNone. + /// + /// In en, this message translates to: + /// **'none'** + String get failedServicesNone; + + /// No description provided for @failedServicesScopeSystem. + /// + /// In en, this message translates to: + /// **'System'** + String get failedServicesScopeSystem; + + /// No description provided for @failedServicesScopeUser. + /// + /// In en, this message translates to: + /// **'User'** + String get failedServicesScopeUser; + + /// No description provided for @memoryPressureLabel. + /// + /// In en, this message translates to: + /// **'Memory pressure'** + String get memoryPressureLabel; + + /// No description provided for @memoryPressureUnavailable. + /// + /// In en, this message translates to: + /// **'not available'** + String get memoryPressureUnavailable; + + /// No description provided for @rebootRequiredBadge. + /// + /// In en, this message translates to: + /// **'Reboot required'** + String get rebootRequiredBadge; + + /// No description provided for @rebootRequiredNoPackages. + /// + /// In en, this message translates to: + /// **'Reboot required (no package named)'** + String get rebootRequiredNoPackages; + + /// No description provided for @rebootRequiredPackages. + /// + /// In en, this message translates to: + /// **'Reboot required after updating: {packages}'** + String rebootRequiredPackages(String packages); + + /// No description provided for @zramLabel. + /// + /// In en, this message translates to: + /// **'zram (compressed)'** + String get zramLabel; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 5baa7a6c..ab18104e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1182,4 +1182,90 @@ class AppLocalizationsDe extends AppLocalizations { @override String get runFileWarning => 'Die Datei wird als Programm mit deinen Benutzerrechten ausgeführt. Fahre nur fort, wenn du weißt, was sie tut.'; + + @override + String get securityScanRunCheck => 'Jetzt prüfen'; + + @override + String get securityScanNotRunYet => 'Es wurde noch nicht geprüft.'; + + @override + String securityScanLastRun(String age) { + return 'Letzte Prüfung: $age'; + } + + @override + String get securityScanAgeJustNow => 'gerade eben'; + + @override + String securityScanAgeMinutes(int count) { + return 'vor $count Min.'; + } + + @override + String securityScanAgeHours(int count) { + return 'vor $count Std.'; + } + + @override + String securityScanAgeDays(int count) { + return 'vor $count T.'; + } + + @override + String get securityScanRootHint => + 'Die Prüfung braucht Root-Rechte und fragt deshalb nach deinem Passwort.'; + + @override + String browserDefaultIs(String standard) { + return 'Standardbrowser: $standard'; + } + + @override + String get browserDefaultUnknown => 'Standardbrowser unbekannt'; + + @override + String browserHandlerMismatch(String handler, String standard) { + return 'HTTPS-Links oeffnen in $handler, nicht in $standard'; + } + + @override + String get diskSwapLabel => 'Swap auf der Festplatte'; + + @override + String get failedServicesLabel => 'Fehlgeschlagene Dienste'; + + @override + String failedServicesMore(int rest) { + return 'und $rest weitere'; + } + + @override + String get failedServicesNone => 'keine'; + + @override + String get failedServicesScopeSystem => 'System'; + + @override + String get failedServicesScopeUser => 'Benutzer'; + + @override + String get memoryPressureLabel => 'Speicherdruck'; + + @override + String get memoryPressureUnavailable => 'nicht verfügbar'; + + @override + String get rebootRequiredBadge => 'Neustart nötig'; + + @override + String get rebootRequiredNoPackages => 'Neustart nötig (kein Paket genannt)'; + + @override + String rebootRequiredPackages(String packages) { + return 'Neustart nötig nach dem Update von: $packages'; + } + + @override + String get zramLabel => 'zram (komprimiert)'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0239d6eb..4ba7d179 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1173,4 +1173,90 @@ class AppLocalizationsEn extends AppLocalizations { @override String get runFileWarning => 'The file will be executed as a program with your user rights. Only continue if you know what it does.'; + + @override + String get securityScanRunCheck => 'Check now'; + + @override + String get securityScanNotRunYet => 'No security check has been run yet.'; + + @override + String securityScanLastRun(String age) { + return 'Last check: $age'; + } + + @override + String get securityScanAgeJustNow => 'just now'; + + @override + String securityScanAgeMinutes(int count) { + return '$count min ago'; + } + + @override + String securityScanAgeHours(int count) { + return '$count h ago'; + } + + @override + String securityScanAgeDays(int count) { + return '$count d ago'; + } + + @override + String get securityScanRootHint => + 'The check needs root rights, so it will ask for your password.'; + + @override + String browserDefaultIs(String standard) { + return 'Default browser: $standard'; + } + + @override + String get browserDefaultUnknown => 'Default browser unknown'; + + @override + String browserHandlerMismatch(String handler, String standard) { + return 'HTTPS links open in $handler, not in $standard'; + } + + @override + String get diskSwapLabel => 'Disk swap'; + + @override + String get failedServicesLabel => 'Failed services'; + + @override + String failedServicesMore(int rest) { + return 'and $rest more'; + } + + @override + String get failedServicesNone => 'none'; + + @override + String get failedServicesScopeSystem => 'System'; + + @override + String get failedServicesScopeUser => 'User'; + + @override + String get memoryPressureLabel => 'Memory pressure'; + + @override + String get memoryPressureUnavailable => 'not available'; + + @override + String get rebootRequiredBadge => 'Reboot required'; + + @override + String get rebootRequiredNoPackages => 'Reboot required (no package named)'; + + @override + String rebootRequiredPackages(String packages) { + return 'Reboot required after updating: $packages'; + } + + @override + String get zramLabel => 'zram (compressed)'; } diff --git a/lib/l10n/app_localizations_fi.dart b/lib/l10n/app_localizations_fi.dart index b355cb0b..6b2acf8f 100644 --- a/lib/l10n/app_localizations_fi.dart +++ b/lib/l10n/app_localizations_fi.dart @@ -1183,4 +1183,92 @@ class AppLocalizationsFi extends AppLocalizations { @override String get runFileWarning => 'Tiedosto suoritetaan ohjelmana käyttöoikeuksillasi. Jatka vain, jos tiedät, mitä se tekee.'; + + @override + String get securityScanRunCheck => 'Tarkista nyt'; + + @override + String get securityScanNotRunYet => + 'Turvallisuustarkistusta ei ole vielä tehty.'; + + @override + String securityScanLastRun(String age) { + return 'Viimeisin tarkistus: $age'; + } + + @override + String get securityScanAgeJustNow => 'juuri nyt'; + + @override + String securityScanAgeMinutes(int count) { + return '$count min sitten'; + } + + @override + String securityScanAgeHours(int count) { + return '$count t sitten'; + } + + @override + String securityScanAgeDays(int count) { + return '$count pv sitten'; + } + + @override + String get securityScanRootHint => + 'Tarkistus tarvitsee pääkäyttäjän oikeudet, joten se kysyy salasanaasi.'; + + @override + String browserDefaultIs(String standard) { + return 'Oletusselaime: $standard'; + } + + @override + String get browserDefaultUnknown => 'Oletusselaime tuntematon'; + + @override + String browserHandlerMismatch(String handler, String standard) { + return 'HTTPS-linkit avautuvat ohjelmassa $handler, ei $standard'; + } + + @override + String get diskSwapLabel => 'Levyn swap'; + + @override + String get failedServicesLabel => 'Epäonnistuneet palvelut'; + + @override + String failedServicesMore(int rest) { + return 'ja $rest muuta'; + } + + @override + String get failedServicesNone => 'ei mitään'; + + @override + String get failedServicesScopeSystem => 'Järjestelmä'; + + @override + String get failedServicesScopeUser => 'Käyttäjä'; + + @override + String get memoryPressureLabel => 'Muistipaine'; + + @override + String get memoryPressureUnavailable => 'ei saatavilla'; + + @override + String get rebootRequiredBadge => 'Uudelleenkäynnistys tarvitaan'; + + @override + String get rebootRequiredNoPackages => + 'Uudelleenkäynnistys tarvitaan (pakettia ei nimetty)'; + + @override + String rebootRequiredPackages(String packages) { + return 'Uudelleenkäynnistys tarvitaan päivityksen jälkeen: $packages'; + } + + @override + String get zramLabel => 'zram (pakattu)'; } From 29dcb212e10583a273dd591eb47b64683967f0cc Mon Sep 17 00:00:00 2001 From: ElGregor Date: Sat, 24 Oct 2026 01:58:20 +0200 Subject: [PATCH 4/8] =?UTF-8?q?feat(mla):=20WP-S3=20+=20V0.8.6=20Welle=201?= =?UTF-8?q?=20(Q1,=20Q2,=20Q3,=20Q5,=20Q6)=20=E2=80=94=20zusammengef=C3=BC?= =?UTF-8?q?hrt=20(Teil=204/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_localizations_it.dart | 88 +++++++++ lib/l10n/linuxassistant_fi.arb | 154 +++++++++++++++ lib/layouts/disk_cleaner/clean_disk.dart | 3 +- lib/layouts/hub/dashboard_section.dart | 240 ++++++++++++++++++++++- 4 files changed, 477 insertions(+), 8 deletions(-) diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index edff0d0c..5270c49b 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1191,4 +1191,92 @@ class AppLocalizationsIt extends AppLocalizations { @override String get runFileWarning => 'Il file verrà eseguito come programma con i tuoi privilegi utente. Continua solo se sai cosa fa.'; + + @override + String get securityScanRunCheck => 'Controlla ora'; + + @override + String get securityScanNotRunYet => + 'Non è ancora stato eseguito alcun controllo di sicurezza.'; + + @override + String securityScanLastRun(String age) { + return 'Ultimo controllo: $age'; + } + + @override + String get securityScanAgeJustNow => 'appena'; + + @override + String securityScanAgeMinutes(int count) { + return '$count min fa'; + } + + @override + String securityScanAgeHours(int count) { + return '$count h fa'; + } + + @override + String securityScanAgeDays(int count) { + return '$count g fa'; + } + + @override + String get securityScanRootHint => + 'Il controllo richiede i privilegi root, quindi ti chiederà la password.'; + + @override + String browserDefaultIs(String standard) { + return 'Browser predefinito: $standard'; + } + + @override + String get browserDefaultUnknown => 'Browser predefinito sconosciuto'; + + @override + String browserHandlerMismatch(String handler, String standard) { + return 'I link HTTPS si aprono in $handler, non in $standard'; + } + + @override + String get diskSwapLabel => 'Swap su disco'; + + @override + String get failedServicesLabel => 'Servizi falliti'; + + @override + String failedServicesMore(int rest) { + return 'e altri $rest'; + } + + @override + String get failedServicesNone => 'nessuno'; + + @override + String get failedServicesScopeSystem => 'Sistema'; + + @override + String get failedServicesScopeUser => 'Utente'; + + @override + String get memoryPressureLabel => 'Pressione sulla memoria'; + + @override + String get memoryPressureUnavailable => 'non disponibile'; + + @override + String get rebootRequiredBadge => 'Riavvio necessario'; + + @override + String get rebootRequiredNoPackages => + 'Riavvio necessario (nessun pacchetto indicato)'; + + @override + String rebootRequiredPackages(String packages) { + return 'Riavvio necessario dopo l\'aggiornamento di: $packages'; + } + + @override + String get zramLabel => 'zram (compresso)'; } diff --git a/lib/l10n/linuxassistant_fi.arb b/lib/l10n/linuxassistant_fi.arb index cc691305..3744e93f 100644 --- a/lib/l10n/linuxassistant_fi.arb +++ b/lib/l10n/linuxassistant_fi.arb @@ -903,5 +903,159 @@ "placeholders": {}, "description": "Explains what confirming the dialog above does", "type": "text" + }, + "securityScanRunCheck": "Tarkista nyt", + "@securityScanRunCheck": { + "description": "Button that starts the security scan on demand", + "type": "text" + }, + "securityScanNotRunYet": "Turvallisuustarkistusta ei ole vielä tehty.", + "@securityScanNotRunYet": { + "description": "Shown on the security page before the first scan", + "type": "text" + }, + "securityScanLastRun": "Viimeisin tarkistus: {age}", + "@securityScanLastRun": { + "placeholders": { + "age": { + "type": "String" + } + }, + "description": "When the last security check ran, with its age", + "type": "text" + }, + "securityScanAgeJustNow": "juuri nyt", + "@securityScanAgeJustNow": { + "description": "Age of a security check that just ran", + "type": "text" + }, + "securityScanAgeMinutes": "{count} min sitten", + "@securityScanAgeMinutes": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in minutes", + "type": "text" + }, + "securityScanAgeHours": "{count} t sitten", + "@securityScanAgeHours": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in hours", + "type": "text" + }, + "securityScanAgeDays": "{count} pv sitten", + "@securityScanAgeDays": { + "placeholders": { + "count": { + "type": "int" + } + }, + "description": "Age of a security check in days", + "type": "text" + }, + "securityScanRootHint": "Tarkistus tarvitsee pääkäyttäjän oikeudet, joten se kysyy salasanaasi.", + "@securityScanRootHint": { + "description": "Explains why pressing the check button asks for a password", + "type": "text" + }, + "browserDefaultIs": "Oletusselaime: {standard}", + "@browserDefaultIs": { + "placeholders": { + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "browserDefaultUnknown": "Oletusselaime tuntematon", + "@browserDefaultUnknown": { + "placeholders": {}, + "type": "text" + }, + "browserHandlerMismatch": "HTTPS-linkit avautuvat ohjelmassa {handler}, ei {standard}", + "@browserHandlerMismatch": { + "placeholders": { + "handler": { + "type": "String" + }, + "standard": { + "type": "String" + } + }, + "type": "text" + }, + "diskSwapLabel": "Levyn swap", + "@diskSwapLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesLabel": "Epäonnistuneet palvelut", + "@failedServicesLabel": { + "placeholders": {}, + "type": "text" + }, + "failedServicesMore": "ja {rest} muuta", + "@failedServicesMore": { + "placeholders": { + "rest": { + "type": "int" + } + }, + "type": "text" + }, + "failedServicesNone": "ei mitään", + "@failedServicesNone": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeSystem": "Järjestelmä", + "@failedServicesScopeSystem": { + "placeholders": {}, + "type": "text" + }, + "failedServicesScopeUser": "Käyttäjä", + "@failedServicesScopeUser": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureLabel": "Muistipaine", + "@memoryPressureLabel": { + "placeholders": {}, + "type": "text" + }, + "memoryPressureUnavailable": "ei saatavilla", + "@memoryPressureUnavailable": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredBadge": "Uudelleenkäynnistys tarvitaan", + "@rebootRequiredBadge": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredNoPackages": "Uudelleenkäynnistys tarvitaan (pakettia ei nimetty)", + "@rebootRequiredNoPackages": { + "placeholders": {}, + "type": "text" + }, + "rebootRequiredPackages": "Uudelleenkäynnistys tarvitaan päivityksen jälkeen: {packages}", + "@rebootRequiredPackages": { + "placeholders": { + "packages": { + "type": "String" + } + }, + "type": "text" + }, + "zramLabel": "zram (pakattu)", + "@zramLabel": { + "placeholders": {}, + "type": "text" } } diff --git a/lib/layouts/disk_cleaner/clean_disk.dart b/lib/layouts/disk_cleaner/clean_disk.dart index c459ad58..9cc77074 100644 --- a/lib/layouts/disk_cleaner/clean_disk.dart +++ b/lib/layouts/disk_cleaner/clean_disk.dart @@ -7,6 +7,7 @@ import 'package:linux_assistant/layouts/mint_y.dart'; import 'package:linux_assistant/linux/linux_filesystem.dart'; import 'package:linux_assistant/services/linux.dart'; import 'package:linux_assistant/l10n/app_localizations.dart'; +import 'dart:async'; import 'dart:io'; class CleanDiskPage extends StatelessWidget { @@ -57,7 +58,7 @@ class CleanDiskPage extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: MintYButton( onPressed: () => - Linux.openDiskSpaceAnalyzer(context, mountpoint), + unawaited(Linux.openDiskSpaceAnalyzer(context, mountpoint)), text: Text(AppLocalizations.of(context)!.analyseDiskspace, style: MintY.heading4White), color: MintY.currentColor, diff --git a/lib/layouts/hub/dashboard_section.dart b/lib/layouts/hub/dashboard_section.dart index b72034eb..cb8af48f 100644 --- a/lib/layouts/hub/dashboard_section.dart +++ b/lib/layouts/hub/dashboard_section.dart @@ -3,9 +3,12 @@ import 'package:linux_assistant/l10n/app_localizations.dart'; import 'package:linux_assistant/layouts/hermes_tokens.dart'; import 'package:linux_assistant/layouts/hub/hub_grid.dart'; import 'package:linux_assistant/linux/linux_filesystem.dart'; +import 'package:linux_assistant/linux/linux_memory.dart'; import 'package:linux_assistant/models/action_entry.dart'; import 'package:linux_assistant/services/action_entry_list_service.dart'; import 'package:linux_assistant/services/action_handler.dart'; +import 'package:linux_assistant/services/browser_status_service.dart'; +import 'package:linux_assistant/services/failed_units_service.dart'; import 'package:linux_assistant/services/linux.dart'; import 'package:linux_assistant/services/system_stats_service.dart'; import 'package:linux_assistant/widgets/hermes/hermes_badge.dart'; @@ -39,19 +42,28 @@ class _DashboardSectionState extends State { "hard_info", ]; + /// How many failed units the tile lists before it summarizes the rest. + static const int _maxListedFailedUnits = 3; + final SystemStatsService _service = SystemStatsService(); + final FailedUnitsService _failedUnits = FailedUnitsService(); + final BrowserStatusService _browserStatus = BrowserStatusService(); List _quickActions = []; @override void initState() { super.initState(); _service.acquire(); + _failedUnits.acquire(); + _browserStatus.acquire(); _loadQuickActions(); } @override void dispose() { _service.release(); + _failedUnits.release(); + _browserStatus.release(); super.dispose(); } @@ -113,6 +125,7 @@ class _DashboardSectionState extends State { _memoryTile(context, stats), _processesTile(context, stats), _storageTile(context, stats), + _failedServicesTile(context), ], ), const SizedBox(height: HermesTokens.space4), @@ -129,6 +142,7 @@ class _DashboardSectionState extends State { final t = HermesTokens.of(context); final env = Linux.currentenvironment; final uptime = stats.uptime; + final rebootRequired = stats.reboot?.required ?? false; return HermesCard( spineColor: t.accent, @@ -155,6 +169,16 @@ class _DashboardSectionState extends State { text: "${l10n.uptimeLabel} " "${uptime.value}${_uptimeUnit(context, uptime.unit)}", ), + // The badge disappears with the marker file, i.e. after the + // reboot that was asked for. + if (rebootRequired) ...[ + const SizedBox(width: HermesTokens.space2), + HermesBadge( + icon: Icons.restart_alt, + tone: HermesTone.warning, + text: l10n.rebootRequiredBadge, + ), + ], ], ), const SizedBox(height: HermesTokens.space3), @@ -169,6 +193,7 @@ class _DashboardSectionState extends State { "${l10n.desktopLabel} ${env.desktop.name}"), _fact(context, Icons.memory, env.cpuModel), _fact(context, Icons.monitor, env.gpuModel), + _browserFact(context), ], ), ], @@ -195,6 +220,53 @@ class _DashboardSectionState extends State { ); } + /// Which browser the desktop calls the standard one, and a warning when + /// HTTPS links open in a different one. + /// + /// Read-only: the dashboard never changes the browser settings. + Widget _browserFact(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + + return ValueListenableBuilder( + valueListenable: _browserStatus.status, + builder: (context, status, _) { + if (status == null) { + // Not read yet — no claim about the browser until there is one. + return const SizedBox.shrink(); + } + + final standard = status.defaultDesktopId; + if (standard == null) { + return _fact(context, Icons.public, l10n.browserDefaultUnknown); + } + + if (status.mismatch) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.warning_amber, size: 14, color: t.warning), + const SizedBox(width: HermesTokens.space1), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Text( + l10n.browserHandlerMismatch( + status.httpsHandlerDesktopId ?? "", + standard, + ), + overflow: TextOverflow.ellipsis, + style: TextStyle(color: t.warning, fontSize: 12), + ), + ), + ], + ); + } + + return _fact(context, Icons.public, l10n.browserDefaultIs(standard)); + }, + ); + } + Widget _cpuTile(BuildContext context, SystemStats stats) { final l10n = AppLocalizations.of(context)!; final t = HermesTokens.of(context); @@ -228,13 +300,18 @@ class _DashboardSectionState extends State { final t = HermesTokens.of(context); final memory = stats.memory; final ratio = memory?.usedRatio ?? 0; + final pressure = stats.memoryPressure; + final zram = stats.zram; + final tone = _memoryTone(pressure, ratio); return HermesStatTile( label: l10n.memoryUsage, icon: Icons.developer_board, value: memory != null ? (ratio * 100).toStringAsFixed(0) : null, unit: "%", - tone: ratio >= 0.9 ? HermesTone.warning : HermesTone.accent, + // The pressure decides the tone, not the RAM quote: 40 % RAM with + // memory stalls is a problem, 95 % RAM without stalls is not. + tone: tone, badge: memory != null ? HermesBadge( dense: true, @@ -243,17 +320,74 @@ class _DashboardSectionState extends State { : null, visual: HermesSparkline( values: stats.memoryHistory, - color: ratio >= 0.9 ? t.warning : t.accent, + color: tone == HermesTone.error + ? t.error + : tone == HermesTone.warning + ? t.warning + : t.accent, ), - footer: memory != null && memory.hasSwap - ? HermesMetaRow( - label: l10n.swapLabel, + footer: Column( + mainAxisSize: MainAxisSize.min, + children: [ + HermesMetaRow( + label: l10n.memoryPressureLabel, + value: pressure == null + ? l10n.memoryPressureUnavailable + : "${pressure.someAvg60.toStringAsFixed(1)} %", + valueTone: _pressureTone(context, pressure), + ), + // zram and the disk swap are two different things and get two + // rows: the compressed RAM disk and the swap on the disk. + if (zram != null) + HermesMetaRow( + label: l10n.zramLabel, + value: "${_gbFromBytes(zram.originalBytes)}" + " → ${_gbFromBytes(zram.compressedBytes)}", + ), + if (memory != null && memory.hasSwap) + HermesMetaRow( + label: l10n.diskSwapLabel, value: "${_gb(memory.swapUsedMb)} / ${_gb(memory.swapTotalMb)}", - ) - : null, + ), + ], + ), ); } + /// The tone of the memory tile. + /// + /// PSI decides it. Only when the kernel does not report the pressure does + /// the RAM quote step in as a fallback. + static HermesTone _memoryTone(MemoryPressure? pressure, double ratio) { + switch (pressure?.level) { + case MemoryPressureLevel.high: + return HermesTone.error; + case MemoryPressureLevel.elevated: + return HermesTone.warning; + case MemoryPressureLevel.normal: + return HermesTone.accent; + case MemoryPressureLevel.unknown: + case null: + return ratio >= 0.9 ? HermesTone.warning : HermesTone.accent; + } + } + + Color? _pressureTone(BuildContext context, MemoryPressure? pressure) { + if (pressure == null) { + return null; + } + final t = HermesTokens.of(context); + switch (pressure.level) { + case MemoryPressureLevel.high: + return t.error; + case MemoryPressureLevel.elevated: + return t.warning; + case MemoryPressureLevel.normal: + case MemoryPressureLevel.unknown: + return null; + } + } + Widget _processesTile(BuildContext context, SystemStats stats) { final l10n = AppLocalizations.of(context)!; @@ -352,6 +486,94 @@ class _DashboardSectionState extends State { ); } + /// The failed systemd units of both scopes. + /// + /// The number is the headline; the list below it names the units, their + /// description and the scope they come from — a failed user unit is a + /// different problem than a failed system unit. + Widget _failedServicesTile(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return ValueListenableBuilder?>( + valueListenable: _failedUnits.units, + builder: (context, units, _) { + final count = units?.length ?? 0; + final listed = units?.take(_maxListedFailedUnits) ?? const []; + final rest = count - listed.length; + + return HermesStatTile( + label: l10n.failedServicesLabel, + icon: Icons.report_gmailerrorred_outlined, + // Null while the first read is still running — a dash is honest, + // a zero would be a claim nobody made yet. + value: units == null ? null : "$count", + tone: count > 0 ? HermesTone.error : HermesTone.accent, + footer: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (units == null) + const SizedBox.shrink() + else if (units.isEmpty) + HermesMetaRow( + label: l10n.failedServicesLabel, + value: l10n.failedServicesNone, + ) + else ...[ + for (final unit in listed) _failedUnitRow(context, unit), + if (rest > 0) + HermesMetaRow( + label: l10n.failedServicesLabel, + value: l10n.failedServicesMore(rest), + ), + ], + ], + ), + ); + }, + ); + } + + Widget _failedUnitRow(BuildContext context, FailedUnit unit) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + final scope = unit.scope == FailedUnitScope.user + ? l10n.failedServicesScopeUser + : l10n.failedServicesScopeSystem; + + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + unit.unit, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: t.text, + fontSize: 12, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(width: HermesTokens.space2), + HermesBadge(dense: true, text: scope), + ], + ), + if (unit.description.isNotEmpty) + Text( + unit.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: t.muted, fontSize: 11), + ), + ], + ), + ); + } + Widget _quickActionsGrid(BuildContext context) { final l10n = AppLocalizations.of(context)!; final t = HermesTokens.of(context); @@ -421,6 +643,10 @@ class _DashboardSectionState extends State { static String _gb(int megabytes) => "${(megabytes / 1024).toStringAsFixed(1)}G"; + /// A size in gigabytes, for the values that come in bytes (zram). + static String _gbFromBytes(int bytes) => + "${(bytes / 1024 / 1024 / 1024).toStringAsFixed(1)} GB"; + String _uptimeUnit(BuildContext context, String unit) { final l10n = AppLocalizations.of(context)!; switch (unit) { From 29accd38a3e18f1f1e466ac833788337e5f440aa Mon Sep 17 00:00:00 2001 From: ElGregor Date: Sat, 24 Oct 2026 01:58:22 +0200 Subject: [PATCH 5/8] =?UTF-8?q?feat(mla):=20WP-S3=20+=20V0.8.6=20Welle=201?= =?UTF-8?q?=20(Q1,=20Q2,=20Q3,=20Q5,=20Q6)=20=E2=80=94=20zusammengef=C3=BC?= =?UTF-8?q?hrt=20(Teil=205/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/layouts/hub/hub_shell.dart | 47 +++- lib/layouts/linux_health/overview.dart | 9 + lib/layouts/security_check/overview.dart | 123 +++++++++- lib/linux/linux_memory.dart | 291 +++++++++++++++++++++++ 4 files changed, 459 insertions(+), 11 deletions(-) create mode 100644 lib/linux/linux_memory.dart diff --git a/lib/layouts/hub/hub_shell.dart b/lib/layouts/hub/hub_shell.dart index 61a2b8f4..8ae1d7f8 100644 --- a/lib/layouts/hub/hub_shell.dart +++ b/lib/layouts/hub/hub_shell.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:linux_assistant/l10n/app_localizations.dart'; import 'package:linux_assistant/layouts/hermes_tokens.dart'; @@ -6,6 +8,8 @@ import 'package:linux_assistant/layouts/main_screen/main_search.dart'; import 'package:linux_assistant/layouts/settings/settings_start.dart'; import 'package:linux_assistant/main.dart'; import 'package:linux_assistant/services/app_launcher.dart'; +import 'package:linux_assistant/services/browser_status_service.dart'; +import 'package:linux_assistant/services/failed_units_service.dart'; import 'package:linux_assistant/services/linux.dart'; import 'package:linux_assistant/services/system_stats_service.dart'; import 'package:linux_assistant/services/theme_controller.dart'; @@ -71,7 +75,7 @@ class _HubShellState extends State // manager that reports the state change, and the Flutter lifecycle state // is not delivered by every desktop either. WidgetsBinding.instance.addObserver(this); - SystemStatsService().setSectionActive(hubModuleOf(_section).usesStats); + _syncPolling(); } @override @@ -87,20 +91,46 @@ class _HubShellState extends State // Leave the service in the state a non-hub screen expects. SystemStatsService().setSectionActive(true); SystemStatsService().setWindowVisible(true); + // The dashboard's own pollers stop with the hub. + FailedUnitsService().setSectionActive(false); + FailedUnitsService().setWindowVisible(true); + BrowserStatusService().setSectionActive(false); + BrowserStatusService().setWindowVisible(true); super.dispose(); } + /// Keeps the pollers in sync with what is on screen. + /// + /// The stats are shown by several sections, the failed units and the + /// browser status only by the dashboard. A poller for something nobody + /// sees is pure waste — and two `systemctl` calls every 30 seconds for a + /// hidden tile is exactly the kind of cost that only shows up as battery + /// drain. + void _syncPolling() { + SystemStatsService().setSectionActive(hubModuleOf(_section).usesStats); + final bool showsDashboard = _section == HubSection.dashboard; + FailedUnitsService().setSectionActive(showsDashboard); + BrowserStatusService().setSectionActive(showsDashboard); + } + + /// Whether the window is on screen, for every poller that depends on it. + void _setWindowVisible(bool visible) { + SystemStatsService().setWindowVisible(visible); + FailedUnitsService().setWindowVisible(visible); + BrowserStatusService().setWindowVisible(visible); + } + // Minimizing is the case that matters: the hub keeps every visited section // alive, so nothing else would ever stop the three-second poll. @override - void onWindowMinimize() => SystemStatsService().setWindowVisible(false); + void onWindowMinimize() => _setWindowVisible(false); @override - void onWindowRestore() => SystemStatsService().setWindowVisible(true); + void onWindowRestore() => _setWindowVisible(true); @override void didChangeAppLifecycleState(AppLifecycleState state) { - SystemStatsService().setWindowVisible( + _setWindowVisible( state == AppLifecycleState.resumed || state == AppLifecycleState.inactive, ); } @@ -117,7 +147,7 @@ class _HubShellState extends State _section = section; _screenTool = null; }); - SystemStatsService().setSectionActive(hubModuleOf(section).usesStats); + _syncPolling(); } /// What a screen may ask the shell to do ([HubNavigator]): the dashboard's @@ -434,7 +464,12 @@ class _HubShellState extends State tooltip: l10n.hubSearch, ), IconButton( - onPressed: () => SystemStatsService().refresh(), + onPressed: () { + // The manual reload covers every number the dashboard shows. + unawaited(SystemStatsService().refresh()); + unawaited(FailedUnitsService().refresh()); + unawaited(BrowserStatusService().refresh()); + }, icon: const Icon(Icons.refresh, size: 18), color: t.muted, tooltip: l10n.reload, diff --git a/lib/layouts/linux_health/overview.dart b/lib/layouts/linux_health/overview.dart index 96569f81..b6ee6676 100644 --- a/lib/layouts/linux_health/overview.dart +++ b/lib/layouts/linux_health/overview.dart @@ -106,6 +106,7 @@ class _LinuxHealthContentState extends State { final uptime = stats.uptime; final hasSwap = stats.memory?.hasSwap ?? false; + final reboot = stats.reboot; return ListView( shrinkWrap: true, @@ -116,6 +117,14 @@ class _LinuxHealthContentState extends State { ), if (uptime != null) _uptimeMessage(context, uptime.unit, uptime.value), + // The packages that asked for the reboot. The marker file is + // gone after the reboot, so this line disappears with it. + if (reboot != null && reboot.required) + WarningMessage( + text: reboot.packages.isEmpty + ? l10n.rebootRequiredNoPackages + : l10n.rebootRequiredPackages(reboot.packages.join(", ")), + ), stats.zombieCount == 0 ? SuccessMessage( text: l10n.processesWithZombiesMessage( diff --git a/lib/layouts/security_check/overview.dart b/lib/layouts/security_check/overview.dart index 566aae6e..4d896ea5 100644 --- a/lib/layouts/security_check/overview.dart +++ b/lib/layouts/security_check/overview.dart @@ -9,6 +9,7 @@ import 'package:linux_assistant/layouts/mint_y.dart'; import 'package:linux_assistant/layouts/security_check/security_check_outcome.dart'; import 'package:linux_assistant/layouts/security_check/security_finding.dart'; import 'package:linux_assistant/services/linux.dart'; +import 'package:linux_assistant/services/security_scan_cache.dart'; import 'package:linux_assistant/services/main_search_loader.dart'; import 'package:linux_assistant/widgets/hermes/hermes_badge.dart'; import 'package:linux_assistant/widgets/hermes/hermes_card.dart'; @@ -44,14 +45,60 @@ class SecurityCheckOverview extends StatelessWidget { /// home directory permissions, it now shows the command and leaves the decision /// — and the execution — with the user. class SecurityCheckContent extends StatefulWidget { - const SecurityCheckContent({super.key}); + const SecurityCheckContent({super.key, this.cache}); + + /// Where the last result is stored. Injectable so a test does not write + /// into the developer's own cache. + final SecurityScanCache? cache; @override State createState() => _SecurityCheckContentState(); } class _SecurityCheckContentState extends State { - late Future _checkerOutput = _runChecker(); + /// The scan reaches root through pkexec. It must not start on its own: a + /// password dialog because someone opened a page is not an option. The + /// future stays null until the user presses the button. + Future? _checkerOutput; + + late final SecurityScanCache _cache = widget.cache ?? SecurityScanCache(); + + /// The result of the last scan, read from the cache when the page opens. + /// Null means: no scan has ever been run on this system. + SecurityScanSnapshot? _lastSnapshot; + + @override + void initState() { + super.initState(); + _loadCachedResult(); + } + + /// Reads the cached result of the last scan. + /// + /// Only the cache is touched here — no scan is started. A page that is + /// opened shows what the last scan found, not a new scan. + Future _loadCachedResult() async { + final SecurityScanSnapshot? snapshot = await _cache.read(); + if (!mounted) return; + setState(() => _lastSnapshot = snapshot); + } + + /// Runs the scan and stores its result in the cache. + /// + /// The write happens before the result is shown, so a scan that the user + /// saw is also a scan that survives a restart. + Future _runAndStore() async { + final CommandResult result = await _runChecker(); + if (classifySecurityCheck(result) == SecurityCheckOutcome.success) { + final SecurityScanSnapshot snapshot = SecurityScanSnapshot.fromOutput( + result.output, + timestamp: DateTime.now(), + ); + await _cache.write(snapshot); + if (mounted) setState(() => _lastSnapshot = snapshot); + } + return result; + } static Future _runChecker() { final home = "--home=${Platform.environment['HOME']}"; @@ -76,15 +123,27 @@ class _SecurityCheckContentState extends State { } void _reload() { - setState(() => _checkerOutput = _runChecker()); + // Block body: an arrow function would hand the Future of + // _runAndStore() back to setState, which rejects a callback that returns + // a Future. + setState(() { + _checkerOutput = _runAndStore(); + }); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final Future? running = _checkerOutput; + + // No scan is running: show what the last one found, or that there was + // none. The button to start a scan is part of the banner below. + if (running == null) { + return _idleView(context); + } return FutureBuilder( - future: _checkerOutput, + future: running, builder: (context, snapshot) { if (!snapshot.hasData) { return MintYLoadingPage(text: l10n.analysingSystemSecurity); @@ -113,6 +172,59 @@ class _SecurityCheckContentState extends State { ); } + /// The page before a scan has been started: the cached result of the last + /// scan, or the hint that there was none. + Widget _idleView(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final t = HermesTokens.of(context); + final SecurityScanSnapshot? snapshot = _lastSnapshot; + + return ListView( + padding: const EdgeInsets.all(HermesTokens.space4), + children: [ + _readOnlyBanner(context), + const SizedBox(height: HermesTokens.space4), + if (snapshot == null) + Text( + l10n.securityScanNotRunYet, + style: TextStyle(color: t.muted, fontSize: 12, height: 1.5), + ) + else ...[ + Text( + l10n.securityScanLastRun(_formatAge(context, snapshot.timestamp)), + style: TextStyle(color: t.muted, fontSize: 12, height: 1.5), + ), + const SizedBox(height: HermesTokens.space2), + for (final finding in _buildFindings(context, snapshot.output)) + SecurityFindingTile(finding: finding), + ], + const SizedBox(height: HermesTokens.space4), + Text( + l10n.securityScanRootHint, + style: TextStyle(color: t.muted, fontSize: 12, height: 1.5), + ), + ], + ); + } + + /// The age of a cached result, in the coarsest unit that still says + /// something ("5 min ago" rather than "5 minutes and 12 seconds ago"). + String _formatAge(BuildContext context, DateTime timestamp) { + final l10n = AppLocalizations.of(context)!; + final SecurityScanAge age = + SecurityScanAge.between(timestamp, DateTime.now()); + switch (age.unit) { + case SecurityScanAgeUnit.justNow: + return l10n.securityScanAgeJustNow; + case SecurityScanAgeUnit.minutes: + return l10n.securityScanAgeMinutes(age.count); + case SecurityScanAgeUnit.hours: + return l10n.securityScanAgeHours(age.count); + case SecurityScanAgeUnit.days: + return l10n.securityScanAgeDays(age.count); + } + } + Widget _readOnlyBanner(BuildContext context) { final t = HermesTokens.of(context); final l10n = AppLocalizations.of(context)!; @@ -131,9 +243,10 @@ class _SecurityCheckContentState extends State { ), const Spacer(), TextButton.icon( + key: const Key("security-scan-run"), onPressed: _reload, icon: const Icon(Icons.refresh, size: 14), - label: Text(l10n.reload), + label: Text(l10n.securityScanRunCheck), ), ], ), diff --git a/lib/linux/linux_memory.dart b/lib/linux/linux_memory.dart new file mode 100644 index 00000000..c22ec183 --- /dev/null +++ b/lib/linux/linux_memory.dart @@ -0,0 +1,291 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:linux_assistant/services/logger.dart'; + +/// How hard the memory is under pressure right now. +enum MemoryPressureLevel { + /// The kernel does not report the pressure (no PSI support). + unknown, + + /// Some tasks waited for memory, but rarely and briefly. + normal, + + /// Waiting for memory is measurable. + elevated, + + /// Waiting for memory dominates — the machine is thrashing. + high, +} + +/// The memory pressure the kernel reports through PSI +/// (`/proc/pressure/memory`). +/// +/// PSI is the honest answer to "is the memory tight": the share of the last +/// 60 seconds in which at least one task had to wait for memory. The RAM +/// quote says nothing about that — a machine can be at 30 % RAM usage and +/// still be swapping badly, and it can be at 95 % RAM usage and completely +/// relaxed. +@immutable +class MemoryPressure { + const MemoryPressure({ + required this.someAvg60, + required this.fullAvg60, + required this.level, + }); + + /// `some avg60` — the share of the last minute in which at least one task + /// was stalled on memory. + final double someAvg60; + + /// `full avg60` — the share of the last minute in which *all* tasks were + /// stalled on memory. Much rarer, and much worse. + final double fullAvg60; + + final MemoryPressureLevel level; +} + +/// The zram device's accounting numbers. +/// +/// zram is a compressed RAM disk: the swap data is 7.2 GB in its +/// uncompressed form and occupies 2.1 GB of the real disk. Reporting it as +/// one number ("swap: 7.2 GB of 16 GB") would hide the compression, so the +/// two sizes are kept apart. +@immutable +class ZramInfo { + const ZramInfo({ + required this.devices, + required this.originalBytes, + required this.compressedBytes, + required this.capacityBytes, + }); + + /// The zram devices that were found, e.g. `["zram0"]`. + final List devices; + + /// `orig_data_size` — the size the stored data has in its uncompressed + /// form. + final int originalBytes; + + /// `compr_data_size` — the size the stored data occupies after + /// compression. + final int compressedBytes; + + /// `disksize` — the size the device would take on the disk. + final int capacityBytes; + + /// The data that is actually stored, in its uncompressed size. + int get usedBytes => originalBytes; +} + +/// Reads the memory pressure and the zram accounting from procfs and sysfs. +/// +/// The parsing functions are pure, so the thresholds and the number formats +/// can be tested without a machine that has PSI or zram. +abstract class LinuxMemory { + /// `/proc/pressure/memory` — the PSI lines for memory. + static const String pressurePath = "/proc/pressure/memory"; + + /// The block devices directory; the zram devices live in it. + static const String blockDevicePath = "/sys/block"; + + /// From this share of stalled time on, the pressure counts as elevated. + static const double elevatedThreshold = 5.0; + + /// From this share of stalled time on, the pressure counts as high. + static const double highThreshold = 15.0; + + /// `full avg60` is much rarer than `some avg60`; this share of it is + /// already "high", because at that point every task waits. + static const double fullHighThreshold = 5.0; + + /// Reads `/proc/pressure/memory`. + /// + /// Returns null when the kernel does not provide PSI (CONFIG PSI off) or + /// the file cannot be read. The caller then knows nothing about the + /// pressure instead of knowing "0 %". + static Future memoryPressure() async { + try { + final String raw = await File(pressurePath).readAsString(); + return parsePressure(raw); + } catch (e) { + logInfo("No memory pressure information available: $e"); + return null; + } + } + + /// Parses the contents of `/proc/pressure/memory`. + /// + /// The file has two lines: + /// + /// ``` + /// some avg10=0.00 avg60=0.00 avg300=0.00 total=0 + /// full avg10=0.00 avg60=0.00 avg300=0.00 total=0 + /// ``` + /// + /// `some` counts the time in which at least one task waited for memory, + /// `full` the time in which all of them waited. Only `avg60` is read — it + /// is the window a human can act on. + static MemoryPressure? parsePressure(String raw) { + double? some; + double? full; + + for (final line in raw.split('\n')) { + final trimmed = line.trim(); + if (trimmed.isEmpty) { + continue; + } + final List fields = trimmed.split(RegExp(r'\s+')); + final String kind = fields.first; + if (kind != "some" && kind != "full") { + continue; + } + final double? avg60 = _readAvg60(fields); + if (avg60 == null) { + continue; + } + if (kind == "some") { + some = avg60; + } else { + full = avg60; + } + } + + if (some == null && full == null) { + return null; + } + + final double someValue = some ?? 0; + final double fullValue = full ?? 0; + + return MemoryPressure( + someAvg60: someValue, + fullAvg60: fullValue, + level: levelFor(someAvg60: someValue, fullAvg60: fullValue), + ); + } + + /// Turns the two PSI figures into a level. + static MemoryPressureLevel levelFor({ + required double someAvg60, + required double fullAvg60, + }) { + if (someAvg60 >= highThreshold || fullAvg60 >= fullHighThreshold) { + return MemoryPressureLevel.high; + } + if (someAvg60 >= elevatedThreshold) { + return MemoryPressureLevel.elevated; + } + return MemoryPressureLevel.normal; + } + + static double? _readAvg60(List fields) { + for (final field in fields.skip(1)) { + if (!field.startsWith("avg60=")) { + continue; + } + return double.tryParse(field.substring("avg60=".length)); + } + return null; + } + + /// Reads the zram devices of this machine. + /// + /// Returns null when the machine has no zram device at all. + static Future zramInfo() async { + final List devices; + try { + devices = Directory(blockDevicePath) + .listSync() + .whereType() + .map((dir) => dir.path.split('/').last) + .where((name) => name.startsWith("zram")) + .toList() + ..sort(); + } catch (e) { + logInfo("Could not read $blockDevicePath: $e"); + return null; + } + + if (devices.isEmpty) { + return null; + } + + int original = 0; + int compressed = 0; + int capacity = 0; + var found = false; + + for (final device in devices) { + final String base = "$blockDevicePath/$device"; + final mmStat = await _readFile("$base/mm_stat"); + final diskSize = await _readFile("$base/disksize"); + if (mmStat == null && diskSize == null) { + continue; + } + found = true; + if (mmStat != null) { + final sizes = parseZramMmStat(mmStat); + original += sizes?.$1 ?? 0; + compressed += sizes?.$2 ?? 0; + } + // disksize is optional: a zram device that is not readable yet still + // counts towards the compressed sizes, it just has no capacity. + if (diskSize != null) { + capacity += parseZramDiskSize(diskSize) ?? 0; + } + } + + if (!found) { + return null; + } + + return ZramInfo( + devices: devices, + originalBytes: original, + compressedBytes: compressed, + capacityBytes: capacity, + ); + } + + /// Parses `/sys/block/zram*/mm_stat`. + /// + /// The first two numbers are the interesting ones: the size of the stored + /// data before compression and after it. The rest is the kernel's internal + /// bookkeeping and is ignored. + static (int, int)? parseZramMmStat(String raw) { + final List fields = raw + .trim() + .split(RegExp(r'\s+')) + .where((field) => field.isNotEmpty) + .toList(); + if (fields.length < 2) { + return null; + } + final int? original = int.tryParse(fields[0]); + final int? compressed = int.tryParse(fields[1]); + if (original == null || compressed == null) { + return null; + } + return (original, compressed); + } + + /// Parses `/sys/block/zram*/disksize` — the size the device reserves on + /// the disk, in bytes. + static int? parseZramDiskSize(String raw) { + final String trimmed = raw.trim(); + if (trimmed.isEmpty) { + return null; + } + return int.tryParse(trimmed); + } + + static Future _readFile(String path) async { + try { + return await File(path).readAsString(); + } catch (e) { + logInfo("Could not read $path: $e"); + return null; + } + } +} From 2961904ea60b5baa1280a2923196193fee059923 Mon Sep 17 00:00:00 2001 From: ElGregor Date: Sat, 24 Oct 2026 01:58:25 +0200 Subject: [PATCH 6/8] =?UTF-8?q?feat(mla):=20WP-S3=20+=20V0.8.6=20Welle=201?= =?UTF-8?q?=20(Q1,=20Q2,=20Q3,=20Q5,=20Q6)=20=E2=80=94=20zusammengef=C3=BC?= =?UTF-8?q?hrt=20(Teil=206/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/services/browser_status_service.dart | 247 +++++++++++++++++++++++ lib/services/failed_units_service.dart | 226 +++++++++++++++++++++ lib/services/folder_recursion_depth.dart | 60 ++++++ lib/services/linux.dart | 132 +++++++----- 4 files changed, 619 insertions(+), 46 deletions(-) create mode 100644 lib/services/browser_status_service.dart create mode 100644 lib/services/failed_units_service.dart create mode 100644 lib/services/folder_recursion_depth.dart diff --git a/lib/services/browser_status_service.dart b/lib/services/browser_status_service.dart new file mode 100644 index 00000000..fd5913a3 --- /dev/null +++ b/lib/services/browser_status_service.dart @@ -0,0 +1,247 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:linux_assistant/helpers/command_helper.dart'; +import 'package:linux_assistant/services/app_launcher.dart'; +import 'package:linux_assistant/services/linux.dart'; +import 'package:linux_assistant/services/logger.dart'; + +/// What the system says about the default browser. +/// +/// Two sources that can disagree: `xdg-settings get default-web-browser` +/// (what the desktop settings call the default) and `gio mime +/// x-scheme-handler/https` (what actually opens an HTTPS link). When they +/// differ, the user clicks a link and gets a different browser than the one +/// the desktop advertises — that is worth a warning. +@immutable +class BrowserStatus { + const BrowserStatus({ + this.defaultDesktopId, + this.httpsHandlerDesktopId, + this.handlerKnown = false, + }); + + /// The desktop id of the standard browser, e.g. `firefox.desktop`. + /// Null when the desktop environment does not name one. + final String? defaultDesktopId; + + /// The desktop id that handles `x-scheme-handler/https`. Null when no + /// handler is registered. + final String? httpsHandlerDesktopId; + + /// Whether the HTTPS handler could be determined at all. + /// + /// `gio` is not installed on every minimal system and answers with an + /// error when no handler is registered. In that case there is nothing to + /// compare, so no warning is shown. + final bool handlerKnown; + + /// Whether the HTTPS handler differs from the standard browser. + bool get mismatch { + if (!handlerKnown) { + return false; + } + final standard = defaultDesktopId; + final handler = httpsHandlerDesktopId; + if (standard == null || handler == null) { + return false; + } + return !_sameDesktopId(standard, handler); + } + + /// Two ids are the same when they differ only in the `.desktop` suffix or + /// in case — `firefox` and `firefox.desktop` name the same application. + static bool _sameDesktopId(String a, String b) { + return _stripDesktopSuffix(a).toLowerCase() == + _stripDesktopSuffix(b).toLowerCase(); + } + + static String _stripDesktopSuffix(String id) { + return id.toLowerCase().endsWith(".desktop") + ? id.substring(0, id.length - ".desktop".length) + : id; + } +} + +/// Reads the default browser and the HTTPS handler. +/// +/// The reading is read-only: nothing is written, no settings are changed. +/// The desktop id comes from [AppLauncher.defaultBrowserDesktopId], the same +/// source the browser launcher uses, so the dashboard cannot disagree with +/// what the app does. +class BrowserStatusService { + static final BrowserStatusService _instance = BrowserStatusService._(); + + factory BrowserStatusService() => _instance; + + BrowserStatusService._(); + + /// The default browser changes rarely, so the timer is deliberately slow. + static const Duration defaultInterval = Duration(minutes: 5); + + /// `gio` lives in `/usr/bin` on every distribution that ships it. + static const String gioPath = "/usr/bin/gio"; + + /// The first line of `gio mime` names the default handler: + /// `Default application for ??: `. + static const String kGioDefaultApplicationPrefix = "Default application for"; + + final ValueNotifier status = + ValueNotifier(null); + + Duration interval = defaultInterval; + + Timer? _timer; + int _subscribers = 0; + + /// Both default to true so that a consumer outside the hub keeps working + /// without opting in. The hub sets them to false as soon as the dashboard + /// is not the section on screen. + bool _sectionActive = true; + bool _windowVisible = true; + + bool _reading = false; + + @visibleForTesting + int get subscriberCount => _subscribers; + + /// Read-only view of the section flag, so the coupling between the hub + /// and this service can be asserted without running `gio`. + @visibleForTesting + bool get sectionActive => _sectionActive; + + bool get _shouldPoll => _subscribers > 0 && _sectionActive && _windowVisible; + + /// Registers interest. The first subscriber triggers an immediate read. + void acquire() { + _subscribers++; + _sync(); + } + + /// Drops interest. The last release stops the timer. + void release() { + if (_subscribers == 0) { + return; + } + _subscribers--; + _sync(); + } + + /// Whether the view on screen shows the browser status at all. + void setSectionActive(bool active) { + if (_sectionActive == active) { + return; + } + _sectionActive = active; + _sync(); + } + + /// Whether the window is on screen. + void setWindowVisible(bool visible) { + if (_windowVisible == visible) { + return; + } + _windowVisible = visible; + _sync(); + } + + void _sync() { + if (_shouldPoll) { + if (_timer == null) { + _timer = Timer.periodic(interval, (_) => refresh()); + refresh(); + } + } else { + _timer?.cancel(); + _timer = null; + } + } + + /// Re-reads both sources and publishes the result. + Future refresh() async { + if (_reading) { + return; + } + _reading = true; + try { + final String? standard = await AppLauncher.defaultBrowserDesktopId(); + final String? handler = await _readHttpsHandler(); + status.value = BrowserStatus( + defaultDesktopId: standard, + httpsHandlerDesktopId: handler, + handlerKnown: handler != null, + ); + } catch (e) { + logError("Could not read the browser status", e); + } finally { + _reading = false; + } + } + + /// `gio mime x-scheme-handler/https` prints + /// `x-scheme-handler/https: firefox.desktop` — or several ids, separated + /// by semicolons, when more than one application can handle the scheme. + Future _readHttpsHandler() async { + final CommandResult result = await Linux.runProcess( + gioPath, + ["mime", "x-scheme-handler/https"], + environment: {"LC_ALL": "C"}, + runInShell: false, + ); + + if (!result.success) { + // `gio` exits with 1 when no handler is registered and prints the + // reason to stderr. That is an answer, not an error. + logInfo("gio did not name an HTTPS handler: ${result.error}"); + return null; + } + + return parseGioMimeHandler(result.output); + } + + /// Extracts the handler desktop id from the output of `gio mime`. + /// + /// The real output of `gio mime x-scheme-handler/https` looks like this + /// (measured on gio 2.74): + /// + /// Default application for ?x-scheme-handler/https?: brave-browser.desktop + /// No registered applications + /// No recommended applications + /// + /// Only the first line names the default handler. The other two sections + /// list applications that can open the type without being the default — + /// the dashboard wants the default, so they are ignored. + /// + /// Returns null when the output carries no handler for the scheme. + static String? parseGioMimeHandler(String raw) { + for (final line in raw.split('\n')) { + final String trimmed = line.trim(); + if (!trimmed.startsWith(kGioDefaultApplicationPrefix)) { + continue; + } + final int separator = trimmed.indexOf(':'); + if (separator < 0) { + continue; + } + final String value = trimmed.substring(separator + 1).trim(); + if (value.isEmpty) { + return null; + } + final String first = value.split(';').first.trim(); + return first.isEmpty ? null : first; + } + return null; + } + + /// Test seam: drops all state and stops the timer. + @visibleForTesting + void resetForTesting({BrowserStatus? seed}) { + _timer?.cancel(); + _timer = null; + _subscribers = 0; + _sectionActive = true; + _windowVisible = true; + _reading = false; + status.value = seed; + } +} diff --git a/lib/services/failed_units_service.dart b/lib/services/failed_units_service.dart new file mode 100644 index 00000000..fbe400cd --- /dev/null +++ b/lib/services/failed_units_service.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:linux_assistant/helpers/command_helper.dart'; +import 'package:linux_assistant/services/linux.dart'; +import 'package:linux_assistant/services/logger.dart'; + +/// Which systemd manager a failed unit belongs to. +/// +/// A desktop has two: the system manager (root, `systemctl`) and the user +/// manager (`systemctl --user`). Both can hold failed units, and a service +/// that only lives in the user manager is invisible to the system one. +enum FailedUnitScope { system, user } + +/// One failed systemd unit. +@immutable +class FailedUnit { + const FailedUnit({ + required this.unit, + required this.description, + required this.scope, + }); + + /// The unit name, e.g. `restic-backup.service`. + final String unit; + + /// The unit's description, e.g. "Backup and restore service". May be empty + /// — systemd leaves the field out for units without a description. + final String description; + + /// Which manager reported the unit. + final FailedUnitScope scope; +} + +/// Collects the failed systemd units of both scopes. +/// +/// The list changes far more slowly than the CPU numbers, so this polls on +/// its own slow timer instead of riding on the three-second stats poll — +/// two `systemctl` calls per tick would be a lot of process spawns for a +/// number that usually stays at zero. +class FailedUnitsService { + static final FailedUnitsService _instance = FailedUnitsService._(); + + factory FailedUnitsService() => _instance; + + FailedUnitsService._(); + + /// How often the failed units are re-read while a consumer is listening. + static const Duration defaultInterval = Duration(seconds: 30); + + /// `systemctl` is always in `/usr/bin` on a systemd system. + static const String systemctlPath = "/usr/bin/systemctl"; + + final ValueNotifier?> units = + ValueNotifier?>(null); + + Duration interval = defaultInterval; + + Timer? _timer; + int _subscribers = 0; + + /// Both default to true so that a consumer outside the hub keeps working + /// without opting in. The hub sets them to false as soon as the dashboard + /// is not the section on screen. + bool _sectionActive = true; + bool _windowVisible = true; + + /// Guards against overlapping reads: `systemctl` on a busy system can take + /// longer than the interval, and stacked runs would pile up. + bool _reading = false; + + @visibleForTesting + int get subscriberCount => _subscribers; + + /// Read-only view of the section flag, so the coupling between the hub + /// and this service can be asserted without running `systemctl`. + @visibleForTesting + bool get sectionActive => _sectionActive; + + bool get _shouldPoll => _subscribers > 0 && _sectionActive && _windowVisible; + + /// Registers interest. The first subscriber triggers an immediate read. + void acquire() { + _subscribers++; + _sync(); + } + + /// Drops interest. The last release stops the timer. + void release() { + if (_subscribers == 0) { + return; + } + _subscribers--; + _sync(); + } + + /// Whether the view on screen shows the failed units at all. + void setSectionActive(bool active) { + if (_sectionActive == active) { + return; + } + _sectionActive = active; + _sync(); + } + + /// Whether the window is on screen. + void setWindowVisible(bool visible) { + if (_windowVisible == visible) { + return; + } + _windowVisible = visible; + _sync(); + } + + void _sync() { + if (_shouldPoll) { + if (_timer == null) { + _timer = Timer.periodic(interval, (_) => refresh()); + refresh(); + } + } else { + _timer?.cancel(); + _timer = null; + } + } + + /// Re-reads both scopes and publishes the result. + /// + /// A scope that cannot be read (no systemd, no user session) contributes an + /// empty list instead of failing the whole read: half an answer is better + /// than none, and the tile says what it knows. + Future refresh() async { + if (_reading) { + return; + } + _reading = true; + try { + final system = await _readScope(FailedUnitScope.system); + final user = await _readScope(FailedUnitScope.user); + units.value = [...system, ...user]; + } catch (e) { + logError("Could not read the failed systemd units", e); + } finally { + _reading = false; + } + } + + Future> _readScope(FailedUnitScope scope) async { + final List args = scope == FailedUnitScope.user + ? ["--user", "list-units", "--failed", "-o", "json"] + : ["list-units", "--failed", "-o", "json"]; + + final CommandResult result = await Linux.runProcess( + systemctlPath, + args, + // The C locale keeps the JSON keys and the unit names untranslated. + environment: {"LC_ALL": "C"}, + runInShell: false, + ); + + if (!result.success) { + logInfo("systemctl (${scope.name}) did not answer: ${result.error}"); + return const []; + } + + return parseUnitsJson(result.output, scope); + } + + /// Parses the JSON of `systemctl list-units --failed -o json`. + /// + /// systemd writes `{"units": [ … ]}`; the array alone is accepted as well, + /// because that is what the `--output=json` of some other tools produces + /// and a defensive parser costs nothing here. + static List parseUnitsJson(String raw, FailedUnitScope scope) { + if (raw.trim().isEmpty) { + return const []; + } + + final Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException catch (e) { + logError("systemctl did not return valid JSON", e); + return const []; + } + + List entries = const []; + if (decoded is List) { + entries = decoded; + } else if (decoded is Map) { + final Object? units = decoded["units"]; + if (units is List) { + entries = units; + } + } + + final failed = []; + for (final entry in entries) { + if (entry is! Map) { + continue; + } + final String unit = (entry["unit"] ?? "").toString().trim(); + if (unit.isEmpty) { + continue; + } + final String description = (entry["description"] ?? "").toString().trim(); + failed.add( + FailedUnit(unit: unit, description: description, scope: scope), + ); + } + return failed; + } + + /// Test seam: drops all state and stops the timer. + @visibleForTesting + void resetForTesting({List? seed}) { + _timer?.cancel(); + _timer = null; + _subscribers = 0; + _sectionActive = true; + _windowVisible = true; + _reading = false; + units.value = seed; + } +} diff --git a/lib/services/folder_recursion_depth.dart b/lib/services/folder_recursion_depth.dart new file mode 100644 index 00000000..9cbdcd51 --- /dev/null +++ b/lib/services/folder_recursion_depth.dart @@ -0,0 +1,60 @@ +/// Reichweite der Ordnersuche im Benutzerverzeichnis (#46). +/// +/// Der Wert kommt aus der Konfigurationsdatei des Benutzers +/// (`$HOME/.config/linux-assistant/config.json`) und ist damit frei +/// manipulierbar. Er wird nie ungeprueft verwendet: die Klemme haelt ihn im +/// Bereich [kMinFolderRecursionDepth]..[kMaxFolderRecursionDepth], und ein +/// Wert, der keine Zahl ist, faellt auf [kDefaultFolderRecursionDepth] +/// zurueck. +/// +/// Ohne diese Klemme produzierte ein Textwert im Config einen TypeError im +/// `final int`-Cast — die Ordnersuche lieferte dann eine leere Liste, und +/// der Benutzer wusste nicht, warum seine Ordner fehlen. +library; + +/// Tiefe, die verwendet wird, wenn der Wert fehlt oder unlesbar ist. +const int kDefaultFolderRecursionDepth = 3; + +/// Kleinste sinnvolle Tiefe. Tiefe 0 wuerde nur das Home-Verzeichnis selbst +/// listen. +const int kMinFolderRecursionDepth = 1; + +/// Groesste zulaessige Tiefe. Tiefe 10 in einem Home-Verzeichnis mit +/// `node_modules` oder einem synchronisierten Cloud-Ordner sind bereits +/// hunderttausende Verzeichnisse. +const int kMaxFolderRecursionDepth = 10; + +/// Klemmt [rawValue] auf [kMinFolderRecursionDepth]..[kMaxFolderRecursionDepth]. +/// +/// Angenommen wird eine `int` und ein Text, der eine Zahl enthaelt +/// (`"5"`). Alles andere — ein `bool`, ein `double`, eine Liste oder ein +/// Text wie `"'; touch /tmp/pwned"` — faellt auf +/// [kDefaultFolderRecursionDepth] zurueck. +/// +/// Die Klemmung passiert auf `BigInt`, damit ein riesiger Wert nicht +/// ueberlaeuft: `"99999999999999999999"` wird auf +/// [kMaxFolderRecursionDepth] gesenkt und nicht auf eine +/// Zufallszahl gekuerzt. +int clampFolderRecursionDepth(dynamic rawValue) { + final BigInt? parsed = _parseAsBigInt(rawValue); + if (parsed == null) { + return kDefaultFolderRecursionDepth; + } + final BigInt min = BigInt.from(kMinFolderRecursionDepth); + final BigInt max = BigInt.from(kMaxFolderRecursionDepth); + final BigInt clamped = parsed < min ? min : (parsed > max ? max : parsed); + return clamped.toInt(); +} + +/// Liest [rawValue] als Ganzzahl, oder `null`, wenn es keine ist. +BigInt? _parseAsBigInt(dynamic rawValue) { + if (rawValue is int) { + return BigInt.from(rawValue); + } + if (rawValue is String) { + // `BigInt.tryParse` akzeptiert nur Ziffern mit optionalem Vorzeichen — + // kein "3.5", kein "0x10", kein Text mit Metazeichen. + return BigInt.tryParse(rawValue.trim()); + } + return null; +} diff --git a/lib/services/linux.dart b/lib/services/linux.dart index 01a8cd27..b06baf43 100644 --- a/lib/services/linux.dart +++ b/lib/services/linux.dart @@ -18,6 +18,7 @@ import 'package:linux_assistant/models/environment.dart'; import 'package:linux_assistant/models/linux_command.dart'; import 'package:linux_assistant/services/action_entry_list_service.dart'; import 'package:linux_assistant/services/config_handler.dart'; +import 'package:linux_assistant/services/folder_recursion_depth.dart'; import 'package:linux_assistant/services/hashing.dart'; import 'package:linux_assistant/services/main_search_loader.dart'; import 'package:linux_assistant/l10n/app_localizations.dart'; @@ -94,16 +95,23 @@ class Linux { /// /// If [hostOnFlatpak] is set to false, the command will be issued in the flatpak sandbox, if available. /// - /// [runInShell] defaults to true for compatibility with the existing call - /// sites. Turning it off halves the number of processes spawned — with it, - /// every call forks a shell *and* the command — and is safe whenever the - /// executable is an absolute path and the arguments need no shell handling. + /// [runInShell] defaults to false. The shell was never a split risk: Dart + /// quotes every argv element on its own when it builds the `sh -c` string + /// (`_getShellArguments()` in + /// sdk/lib/_internal/vm/bin/process_patch.dart), so a path with a space + /// stayed one argument and shell metacharacters stayed inert either way. + /// What the shell cost was one extra fork per call and a second parsing of + /// arguments the program already received verbatim. + /// + /// The precondition for running without a shell is an absolute path: a + /// bare name would be looked up over PATH, and [expandCommand] is what + /// turns "firefox" into "/usr/bin/firefox". static Future runCommandWithCustomArguments( String exec, List arguments, {bool getErrorMessages = false, Map? environment, bool hostOnFlatpak = true, - bool runInShell = true}) async { + bool runInShell = false}) async { final CommandResult result = await runProcess(exec, arguments, environment: environment, hostOnFlatpak: hostOnFlatpak, @@ -122,10 +130,14 @@ class Linux { /// this replaces (`contains("ii $appCode")`, `contains(" not found.")`, /// `split("\n").length > 5`) answer a different question and answer it /// differently in every locale. + /// + /// [runInShell] defaults to false for the same reason as in + /// [runCommandWithCustomArguments]: the shell is an unnecessary + /// intermediary, not a quoting mechanism. static Future runProcess(String exec, List arguments, {Map? environment, bool hostOnFlatpak = true, - bool runInShell = true, + bool runInShell = false, bool asRoot = false}) async { exec = expandCommand(exec); @@ -918,6 +930,18 @@ class Linux { runCommand("/usr/bin/xdg-open $website"); } + /// The depth the scan of the home directory descends to (#46). + /// + /// The raw value comes from the user's config file, so it is clamped into + /// [kMinFolderRecursionDepth]..[kMaxFolderRecursionDepth]; a value that is + /// not a number falls back to [kDefaultFolderRecursionDepth]. The result is + /// always an int in that range — it is interpolated into a `find` command + /// line, and it must not be able to carry anything but digits. + static int folderRecursionDepth() { + return clampFolderRecursionDepth(ConfigHandler().getValueUnsafe( + "folder_recursion_depth", kDefaultFolderRecursionDepth)); + } + static Future getAllFolderEntriesOfUser(BuildContext context) async { // Resolved before the first await: the caller does not await this method, // so by the time the scan finishes the context may already be deactivated. @@ -932,8 +956,12 @@ class Linux { // symlink to / or to an unresponsive network mount used to send this off // into the whole filesystem), and a quoted path (a space in the home // directory used to split the command apart). - final int depth = - ConfigHandler().getValueUnsafe("folder_recursion_depth", 3); + // + // The depth itself is clamped (#46): it comes from the user's config + // file, so it is clamped into 1..10 and falls back to 3. The old + // `final int` cast turned a text value into a TypeError, and the value + // ended up unquoted in the `find` command line. + final int depth = folderRecursionDepth(); final String home = homeFolder.endsWith("/") ? homeFolder.substring(0, homeFolder.length - 1) : homeFolder; @@ -1493,10 +1521,19 @@ class Linux { logInfo("Run privileged python script: pkexec $commandList"); - // No shell: with runInShell the argv is space-joined unquoted, so a HOME - // like "/home/john doe" would truncate --home=… mid-path (and any shell - // metacharacter in it would run). expandCommand yields absolute paths, - // which is the documented precondition for turning the shell off. + // No shell — and the shell was never the split risk this comment used to + // claim. With `runInShell: true` Dart does NOT space-join the argv: it + // builds one `sh -c` string and single-quotes every element on its own + // (`_getShellArguments()` in + // sdk/lib/_internal/vm/bin/process_patch.dart, POSIX branch: each + // argument is written as ' ', embedded quotes are escaped as + // '"'"'). A HOME like "/home/john doe" therefore stayed ONE argument + // and shell metacharacters stayed inert. + // + // What the shell did cost: one extra fork per call and a second parsing + // of arguments the program already received verbatim. That is the only + // reason this call site runs without one. expandCommand yields absolute + // paths, which is the documented precondition for turning the shell off. final result = await runProcess("pkexec", commandList, environment: Platform.environment, runInShell: false); if (result.error.isNotEmpty && getErrorMessages) { @@ -2744,41 +2781,44 @@ class Linux { } /// Opens the disk space analyzer of the current environment. - /// If the tool is not installed, it will be installed. - /// Options are baobab and k4dirstat. - static void openDiskSpaceAnalyzer(context, path) { - if (currentenvironment.desktop == DESKTOPS.KDE) { - runCommandWithCustomArguments("k4dirstat", [path]); - ensureApplicationInstallation(["k4dirstat"]); - if (commandQueue.isEmpty) { - runCommandWithCustomArguments("k4dirstat", [path]); - } else { - commandQueue.add(LinuxCommand( - userId: currentenvironment.currentUserId, - argv: ["/usr/bin/k4dirstat", "$path"], - )); - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => RunCommandQueue( - title: AppLocalizations.of(context)!.installX("k4dirstat"), - route: MainSearchLoader()), - )); - } - } else { - ensureApplicationInstallation(["baobab"]); - if (commandQueue.isEmpty) { - runCommandWithCustomArguments("baobab", [path]); - } else { - commandQueue.add(LinuxCommand( - userId: currentenvironment.currentUserId, - argv: ["/usr/bin/baobab", "$path"], - )); - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => RunCommandQueue( - title: AppLocalizations.of(context)!.installX("baobab"), - route: MainSearchLoader()), - )); - } + /// + /// If the tool is not installed, it will be installed first. Options are + /// baobab and k4dirstat. + /// + /// The installation check comes first (it is awaited). The old code started + /// the analyzer unconditionally *before* the check, so a program that was + /// not installed was started (and failed) and then started a second time. + /// Now the analyzer is started exactly once, after the check. + static Future openDiskSpaceAnalyzer( + BuildContext context, String path) async { + final String appCode = + currentenvironment.desktop == DESKTOPS.KDE ? "k4dirstat" : "baobab"; + + // The check decides whether an installation is queued. It has to be + // awaited, otherwise the queue below is read before the installation + // could have been added to it. + await ensureApplicationInstallation([appCode]); + + if (commandQueue.isEmpty) { + // No shell: the mount point is one argv element and reaches the + // analyzer as one argument — "USB Stick" stays one path. + await runCommandWithCustomArguments(appCode, [path], runInShell: false); + return; } + + // The context has to survive the await above. + if (!context.mounted) return; + + // The queue entry is already argv-form with an absolute path. + commandQueue.add(LinuxCommand( + userId: currentenvironment.currentUserId, + argv: ["/usr/bin/$appCode", path], + )); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => RunCommandQueue( + title: AppLocalizations.of(context)!.installX(appCode), + route: MainSearchLoader()), + )); } static void cleanDiskspace(context, path) { From 5619a3f053f5a6651b77c14b284da8afde1cc9c1 Mon Sep 17 00:00:00 2001 From: ElGregor Date: Sat, 24 Oct 2026 01:58:28 +0200 Subject: [PATCH 7/8] =?UTF-8?q?feat(mla):=20WP-S3=20+=20V0.8.6=20Welle=201?= =?UTF-8?q?=20(Q1,=20Q2,=20Q3,=20Q5,=20Q6)=20=E2=80=94=20zusammengef=C3=BC?= =?UTF-8?q?hrt=20(Teil=207/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/services/reboot_service.dart | 101 ++++++++++++ lib/services/security_scan_cache.dart | 214 +++++++++++++++++++++++++ lib/services/system_stats_service.dart | 28 ++++ test/hub_navigation_test.dart | 11 ++ 4 files changed, 354 insertions(+) create mode 100644 lib/services/reboot_service.dart create mode 100644 lib/services/security_scan_cache.dart diff --git a/lib/services/reboot_service.dart b/lib/services/reboot_service.dart new file mode 100644 index 00000000..7cd22bb3 --- /dev/null +++ b/lib/services/reboot_service.dart @@ -0,0 +1,101 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:linux_assistant/services/logger.dart'; + +/// Whether the system asks for a reboot, and which packages caused it. +/// +/// Debian and Ubuntu write `/var/run/reboot-required` when a package upgrade +/// replaced something that is only replaceable with a restart, and +/// `/var/run/reboot-required.pkgs` with the list of packages that caused it. +/// Both files disappear after the reboot, which is exactly the behaviour the +/// dashboard badge needs: it is gone as soon as the machine was restarted. +@immutable +class RebootStatus { + const RebootStatus({required this.required, this.packages = const []}); + + /// The normal case: no reboot is pending. + static const RebootStatus notRequired = RebootStatus(required: false); + + /// Whether the marker file exists. + final bool required; + + /// The packages that triggered the reboot request. Empty when the marker + /// exists but the package list is missing or unreadable — the reboot is + /// still pending then, we just cannot name the cause. + final List packages; +} + +/// Reads the "reboot required" marker files. +/// +/// The parsing is a pure function ([parsePackages]) so it can be tested +/// without a file system; [readStatus] only does the I/O. +class RebootService { + /// The marker file. `/var/run` is a symlink to `/run` on every current + /// distribution, but not on every system, so both spellings are tried. + static const List markerPaths = [ + "/var/run/reboot-required", + "/run/reboot-required", + ]; + + /// The package list that belongs to the marker file. + static const List packagePaths = [ + "/var/run/reboot-required.pkgs", + "/run/reboot-required.pkgs", + ]; + + /// Parses the contents of `reboot-required.pkgs`. + /// + /// The file lists one package per line. Debian writes a header comment in + /// some releases, and trailing blank lines are common — both are dropped, + /// everything else is a package name. + static List parsePackages(String raw) { + final packages = []; + for (final line in raw.split('\n')) { + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) { + continue; + } + packages.add(trimmed); + } + return packages; + } + + /// Reads the marker and the package list. + /// + /// Never throws: a missing marker file means "no reboot pending", and an + /// unreadable package list means "reboot pending, cause unknown". Both + /// are answers the dashboard can show. + static Future readStatus({ + List markerPaths = RebootService.markerPaths, + List packagePaths = RebootService.packagePaths, + }) async { + final String? marker = _firstExisting(markerPaths); + if (marker == null) { + return RebootStatus.notRequired; + } + + final String? packageFile = _firstExisting(packagePaths); + if (packageFile == null) { + logInfo("Reboot marker found, but no package list next to it."); + return const RebootStatus(required: true); + } + + try { + final String raw = await File(packageFile).readAsString(); + return RebootStatus(required: true, packages: parsePackages(raw)); + } catch (e) { + logError("Could not read $packageFile", e); + return const RebootStatus(required: true); + } + } + + static String? _firstExisting(List paths) { + for (final path in paths) { + if (File(path).existsSync()) { + return path; + } + } + return null; + } +} diff --git a/lib/services/security_scan_cache.dart b/lib/services/security_scan_cache.dart new file mode 100644 index 00000000..3aa157cd --- /dev/null +++ b/lib/services/security_scan_cache.dart @@ -0,0 +1,214 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:linux_assistant/layouts/security_check/security_check_outcome.dart'; +import 'package:linux_assistant/services/logger.dart'; + +/// The directory the app keeps its cached data in, below the XDG cache home. +const String kSecurityScanCacheDirectoryName = "linux-assistant"; + +/// The file the result of the last security scan is stored in. +const String kSecurityScanCacheFileName = "security-last.json"; + +/// The format version of the cache file. +/// +/// A file with a different version is ignored rather than misread: the app +/// then shows "no result yet" instead of interpreting fields that mean +/// something else by now. +const int kSecurityScanCacheVersion = 1; + +/// The result of one security scan, as it is kept between app runs. +/// +/// The [tokens] are the checker's own lines — "firewallinactive", +/// "upgradeablepackage: foo-1.2", … — and not the sentences the UI shows. +/// The sentences are derived from the tokens again on every start, so a result +/// that was cached in German still reads correctly after the user switched the +/// app to Finnish. +class SecurityScanSnapshot { + const SecurityScanSnapshot({required this.tokens, required this.timestamp}); + + /// The checker's output lines, one finding per line. + final List tokens; + + /// When the scan ran. + final DateTime timestamp; + + /// The cached result in the shape the checker's output has: one token per + /// line. This is what the finding parser reads. + String get output => tokens.join("\n"); + + /// Takes the checker's raw stdout and keeps the finding lines. + /// + /// The success marker is not a finding and neither are blank lines; both + /// would only make the cache bigger. Everything else is kept verbatim, so a + /// checker that learns a new token does not need a change here. + factory SecurityScanSnapshot.fromOutput(String output, + {required DateTime timestamp}) { + final List tokens = []; + for (final String line in output.split("\n")) { + final String trimmed = line.trim(); + if (trimmed.isEmpty || trimmed == kSecurityCheckSuccessMarker) { + continue; + } + tokens.add(trimmed); + } + return SecurityScanSnapshot(tokens: tokens, timestamp: timestamp); + } + + /// The on-disk shape of a snapshot. + Map toJson() { + return { + "version": kSecurityScanCacheVersion, + // UTC, so the file stays readable after the user changes the timezone + // or crosses a border. + "timestamp": timestamp.toUtc().toIso8601String(), + "tokens": tokens, + }; + } + + /// Reads a snapshot back. + /// + /// Returns null for anything this build cannot make sense of: a truncated + /// file, a foreign format, a version from the future, a timestamp that is + /// not a timestamp. A cache is a convenience, never a reason to fail. + static SecurityScanSnapshot? fromJson(Map json) { + final Object? version = json["version"]; + if (version is! int || version != kSecurityScanCacheVersion) { + return null; + } + + final Object? rawTokens = json["tokens"]; + if (rawTokens is! List) { + return null; + } + final List tokens = []; + for (final Object? token in rawTokens) { + if (token is! String) { + return null; + } + tokens.add(token); + } + + final Object? rawTimestamp = json["timestamp"]; + if (rawTimestamp is! String) { + return null; + } + final DateTime? timestamp = DateTime.tryParse(rawTimestamp); + if (timestamp == null) { + return null; + } + + return SecurityScanSnapshot(tokens: tokens, timestamp: timestamp); + } +} + +/// How coarse the age of a cached result is shown. +enum SecurityScanAgeUnit { + /// Less than a minute old. + justNow, + + minutes, + hours, + days, +} + +/// The age of a cached result, rounded down to the unit the UI shows. +class SecurityScanAge { + const SecurityScanAge(this.unit, this.count); + + final SecurityScanAgeUnit unit; + + /// How many of [unit] have passed. Always 0 for [SecurityScanAgeUnit.justNow]. + final int count; + + /// How old [timestamp] is, seen from [now]. + /// + /// A timestamp in the future — a changed clock, a mixed-up timezone — counts + /// as "just now" instead of producing a negative age. + factory SecurityScanAge.between(DateTime timestamp, DateTime now) { + final Duration age = now.difference(timestamp); + if (age.isNegative || age.inMinutes < 1) { + return const SecurityScanAge(SecurityScanAgeUnit.justNow, 0); + } + if (age.inHours < 1) { + return SecurityScanAge(SecurityScanAgeUnit.minutes, age.inMinutes); + } + if (age.inHours < 24) { + return SecurityScanAge(SecurityScanAgeUnit.hours, age.inHours); + } + return SecurityScanAge(SecurityScanAgeUnit.days, age.inDays); + } +} + +/// The last scan result on disk. +/// +/// The location follows the XDG base directory spec: `$XDG_CACHE_HOME` when it +/// is set, otherwise `~/.cache`. The file is a cache, not configuration: it +/// may be deleted at any time, and the app has to survive its absence. +class SecurityScanCache { + SecurityScanCache({Directory? directory}) + : _directory = directory ?? defaultDirectory(); + + final Directory _directory; + + /// The directory the cache file lives in. + Directory get directory => _directory; + + /// The file the result is stored in. + File get file => File("${_directory.path}/$kSecurityScanCacheFileName"); + + /// `$XDG_CACHE_HOME/linux-assistant`, or `~/.cache/linux-assistant` when the + /// variable is unset or empty. + static Directory defaultDirectory({Map? environment}) { + final Map env = environment ?? Platform.environment; + final String? xdg = env["XDG_CACHE_HOME"]; + final String base = (xdg != null && xdg.trim().isNotEmpty) + ? xdg.trim() + : "${env["HOME"] ?? "."}/.cache"; + return Directory("$base/$kSecurityScanCacheDirectoryName"); + } + + /// The stored result, or null when there is none or it is unusable. + /// + /// Never throws: a broken cache file must not keep the page from opening — + /// the user can always run a new scan. + Future read() async { + try { + final File cacheFile = file; + if (!await cacheFile.exists()) { + return null; + } + final String content = await cacheFile.readAsString(); + if (content.trim().isEmpty) { + return null; + } + final Object? decoded = jsonDecode(content); + if (decoded is! Map) { + return null; + } + return SecurityScanSnapshot.fromJson(Map.from(decoded)); + } catch (error) { + logError("Could not read the cached security scan", error); + return null; + } + } + + /// Stores [snapshot] and replaces the previous result. + /// + /// Written to a sibling first and renamed afterwards: an app that dies + /// mid-write must not leave a half-written file behind that no longer + /// parses. A cache that cannot be written is not worth an error dialog — + /// the scan itself already worked. + Future write(SecurityScanSnapshot snapshot) async { + try { + await _directory.create(recursive: true); + final String content = jsonEncode(snapshot.toJson()); + final File temporary = + File("${file.path}.${DateTime.now().microsecondsSinceEpoch}.tmp"); + await temporary.writeAsString(content, flush: true); + await temporary.rename(file.path); + } catch (error) { + logError("Could not write the cached security scan", error); + } + } +} diff --git a/lib/services/system_stats_service.dart b/lib/services/system_stats_service.dart index 9077105b..c8345580 100644 --- a/lib/services/system_stats_service.dart +++ b/lib/services/system_stats_service.dart @@ -6,9 +6,11 @@ import 'package:flutter/foundation.dart'; // working entry point for it (an export alone does not import). import 'package:la_core/la_core.dart' show MemoryInfo; import 'package:linux_assistant/linux/linux_filesystem.dart'; +import 'package:linux_assistant/linux/linux_memory.dart'; import 'package:linux_assistant/linux/linux_process.dart'; import 'package:linux_assistant/linux/linux_system.dart'; import 'package:linux_assistant/services/linux.dart'; +import 'package:linux_assistant/services/reboot_service.dart'; export 'package:la_core/la_core.dart' show MemoryInfo; @@ -19,6 +21,17 @@ class SystemStats { /// but can exceed 1.0 on an overloaded machine. final double cpuLoad; final MemoryInfo? memory; + + /// The memory pressure the kernel reports through PSI. Null when the + /// kernel does not provide it — which is not the same as "no pressure". + final MemoryPressure? memoryPressure; + + /// The zram device's accounting, if the machine has one. + final ZramInfo? zram; + + /// Whether the system asks for a reboot, and which packages caused it. + final RebootStatus? reboot; + final List disks; final int processCount; final int zombieCount; @@ -40,6 +53,9 @@ class SystemStats { const SystemStats({ this.cpuLoad = 0, this.memory, + this.memoryPressure, + this.zram, + this.reboot, this.disks = const [], this.processCount = 0, this.zombieCount = 0, @@ -192,6 +208,11 @@ class SystemStatsService { LinuxProcess.topProcessesByCpu(5), LinuxProcess.topProcessesByMemory(5), _uptimeOrNull(), + // File reads, no processes: the pressure, the zram accounting and + // the reboot marker are cheap enough for every tick. + LinuxMemory.memoryPressure(), + LinuxMemory.zramInfo(), + RebootService.readStatus(), ]); final cpuLoad = results[0] as double; @@ -200,6 +221,10 @@ class SystemStatsService { stats.value = SystemStats( cpuLoad: cpuLoad, memory: memory ?? previous.memory, + memoryPressure: + results[8] as MemoryPressure? ?? previous.memoryPressure, + zram: results[9] as ZramInfo? ?? previous.zram, + reboot: results[10] as RebootStatus? ?? previous.reboot, disks: results[2] as List, processCount: results[3] as int, zombieCount: results[4] as int, @@ -218,6 +243,9 @@ class SystemStatsService { stats.value = SystemStats( cpuLoad: previous.cpuLoad, memory: previous.memory, + memoryPressure: previous.memoryPressure, + zram: previous.zram, + reboot: previous.reboot, disks: previous.disks, processCount: previous.processCount, zombieCount: previous.zombieCount, diff --git a/test/hub_navigation_test.dart b/test/hub_navigation_test.dart index 3ef17b81..e859af56 100644 --- a/test/hub_navigation_test.dart +++ b/test/hub_navigation_test.dart @@ -21,6 +21,8 @@ import 'package:linux_assistant/layouts/hub/dashboard_section.dart'; import 'package:linux_assistant/layouts/hub/storage_section.dart'; import 'package:linux_assistant/layouts/tools/quick_notes.dart'; import 'package:linux_assistant/services/app_launcher.dart'; +import 'package:linux_assistant/services/failed_units_service.dart'; +import 'package:linux_assistant/services/browser_status_service.dart'; import 'package:linux_assistant/services/system_stats_service.dart'; import 'package:linux_assistant/widgets/hermes/hermes_nav_item.dart'; @@ -74,11 +76,20 @@ void main() { // false and no Timer.periodic / ps/df/free fork ever starts. SystemStatsService().resetForTesting(); SystemStatsService().setWindowVisible(false); + // The dashboard polls the failed units and the browser status as well. + // Both start a process (`systemctl`, `gio`) the moment they are polled, + // so they are put to sleep the same way the stats poller is. + FailedUnitsService().resetForTesting(); + FailedUnitsService().setWindowVisible(false); + BrowserStatusService().resetForTesting(); + BrowserStatusService().setWindowVisible(false); }); tearDown(() { AppLauncher.resetOverrides(); SystemStatsService().resetForTesting(); + FailedUnitsService().resetForTesting(); + BrowserStatusService().resetForTesting(); }); testWidgets('H1 sidebar shows the nine modules and settings in order', From bad312b897f47b8357ad6ae91d899582f04ba8a2 Mon Sep 17 00:00:00 2001 From: ElGregor Date: Sat, 24 Oct 2026 01:58:30 +0200 Subject: [PATCH 8/8] =?UTF-8?q?feat(mla):=20WP-S3=20+=20V0.8.6=20Welle=201?= =?UTF-8?q?=20(Q1,=20Q2,=20Q3,=20Q5,=20Q6)=20=E2=80=94=20zusammengef=C3=BC?= =?UTF-8?q?hrt=20(Teil=208/8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/q1_security_scan_test.dart | 422 +++++++++++++++++++++++++++++ test/q_dashboard_kacheln_test.dart | 251 +++++++++++++++++ test/wp_s3_hygiene_test.dart | 249 +++++++++++++++++ 3 files changed, 922 insertions(+) create mode 100644 test/q1_security_scan_test.dart create mode 100644 test/q_dashboard_kacheln_test.dart create mode 100644 test/wp_s3_hygiene_test.dart diff --git a/test/q1_security_scan_test.dart b/test/q1_security_scan_test.dart new file mode 100644 index 00000000..fa67419a --- /dev/null +++ b/test/q1_security_scan_test.dart @@ -0,0 +1,422 @@ +// Q1 of issue #58: the security scan runs when the user asks for it, not when +// the page is opened — and its result survives an app restart. +// +// The scan reaches root through pkexec. A password dialog that pops up +// because someone opened a page is not an option, so the behaviour is pinned +// here: opening the page starts nothing, pressing the button starts exactly +// one scan, and the result of that scan lands in the cache. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:linux_assistant/helpers/command_helper.dart'; +import 'package:linux_assistant/l10n/app_localizations.dart'; +import 'package:linux_assistant/layouts/mint_y.dart'; +import 'package:linux_assistant/layouts/security_check/overview.dart'; +import 'package:linux_assistant/services/security_scan_cache.dart'; + +/// The key of the button that starts the scan. +const Key kRunScanButton = Key("security-scan-run"); + +/// Records every command instead of running it. +class _RecordingRunner implements CommandRunner { + _RecordingRunner(this.result); + + final CommandResult result; + final List> calls = >[]; + + @override + Future run( + String command, + List arguments, { + Map? environment, + bool asRoot = false, + bool hostOnFlatpak = true, + bool runInShell = false, + }) async { + calls.add([command, ...arguments]); + return result; + } +} + +/// A successful checker run, as the privileged script prints it. +const String kSuccessfulReport = "firewallinactive\n" + "upgradeablepackage: linux-assistant-1.0\n" + "#!script ran successfully."; + +/// Real file IO started from the fake-async test zone needs several +/// drain-and-pump rounds to work through its await chain; one round only +/// advances it by a single hop. +Future _drainRealIo(WidgetTester tester, {int rounds = 8}) async { + for (var i = 0; i < rounds; i++) { + await tester + .runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(const Duration(milliseconds: 50)); + } +} + +/// The page under test, in the app's own theme and localization. +Widget _host(SecurityScanCache cache) { + return MaterialApp( + theme: MintY.theme(), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en', '')], + home: Scaffold(body: SecurityCheckContent(cache: cache)), + ); +} + +void main() { + group("cache location", () { + test("follows XDG_CACHE_HOME when it is set", () { + final Directory directory = SecurityScanCache.defaultDirectory( + environment: { + "XDG_CACHE_HOME": "/tmp/xdg-cache", + "HOME": "/home/tester", + }, + ); + expect(directory.path, "/tmp/xdg-cache/linux-assistant"); + }); + + test("falls back to ~/.cache when XDG_CACHE_HOME is unset", () { + final Directory directory = SecurityScanCache.defaultDirectory( + environment: {"HOME": "/home/tester"}, + ); + expect(directory.path, "/home/tester/.cache/linux-assistant"); + }); + + test("falls back to ~/.cache when XDG_CACHE_HOME is empty", () { + final Directory directory = SecurityScanCache.defaultDirectory( + environment: { + "XDG_CACHE_HOME": " ", + "HOME": "/home/tester", + }, + ); + expect(directory.path, "/home/tester/.cache/linux-assistant"); + }); + + test("the result file is security-last.json in that directory", () { + final SecurityScanCache cache = SecurityScanCache( + directory: Directory("/tmp/q1-cache/linux-assistant"), + ); + expect( + cache.file.path, "/tmp/q1-cache/linux-assistant/security-last.json"); + }); + }); + + group("snapshot", () { + test("keeps the finding lines and drops the success marker", () { + final SecurityScanSnapshot snapshot = SecurityScanSnapshot.fromOutput( + "firewallinactive\n\n#!script ran successfully.\nsshrunning\n", + timestamp: DateTime.utc(2026, 10, 23, 12), + ); + expect(snapshot.tokens, ["firewallinactive", "sshrunning"]); + }); + + test("keeps the payload of a finding", () { + final SecurityScanSnapshot snapshot = SecurityScanSnapshot.fromOutput( + "additionalsource: http://example.org/repo\n", + timestamp: DateTime.utc(2026, 10, 23, 12), + ); + expect( + snapshot.tokens.single, "additionalsource: http://example.org/repo"); + }); + + test("round-trips through JSON", () { + final SecurityScanSnapshot snapshot = SecurityScanSnapshot( + tokens: ["firewallinactive", "sshrunning"], + timestamp: DateTime.utc(2026, 10, 23, 12, 30), + ); + + final SecurityScanSnapshot? restored = + SecurityScanSnapshot.fromJson(snapshot.toJson()); + + expect(restored, isNotNull); + expect(restored!.tokens, snapshot.tokens); + expect(restored.timestamp.toUtc(), snapshot.timestamp.toUtc()); + }); + + test("the JSON shape is stable", () { + final SecurityScanSnapshot snapshot = SecurityScanSnapshot( + tokens: ["sshrunning"], + timestamp: DateTime.utc(2026, 10, 23, 12, 30), + ); + + expect(snapshot.toJson(), { + "version": 1, + "timestamp": "2026-10-23T12:30:00.000Z", + "tokens": ["sshrunning"], + }); + }); + + test("refuses a file from a different format version", () { + final Map json = SecurityScanSnapshot( + tokens: ["sshrunning"], + timestamp: DateTime.utc(2026, 10, 23), + ).toJson(); + json["version"] = 99; + + expect(SecurityScanSnapshot.fromJson(json), isNull); + }); + + test("refuses a file without a usable timestamp", () { + final Map json = SecurityScanSnapshot( + tokens: ["sshrunning"], + timestamp: DateTime.utc(2026, 10, 23), + ).toJson(); + json["timestamp"] = "not a timestamp"; + + expect(SecurityScanSnapshot.fromJson(json), isNull); + }); + + test("refuses a file whose tokens are not strings", () { + final Map json = SecurityScanSnapshot( + tokens: ["sshrunning"], + timestamp: DateTime.utc(2026, 10, 23), + ).toJson(); + json["tokens"] = [42]; + + expect(SecurityScanSnapshot.fromJson(json), isNull); + }); + }); + + group("age", () { + final DateTime now = DateTime.utc(2026, 10, 23, 12); + + test("less than a minute old is just now", () { + final SecurityScanAge age = SecurityScanAge.between( + now.subtract(const Duration(seconds: 42)), + now, + ); + expect(age.unit, SecurityScanAgeUnit.justNow); + }); + + test("minutes are counted", () { + final SecurityScanAge age = SecurityScanAge.between( + now.subtract(const Duration(minutes: 7, seconds: 30)), + now, + ); + expect(age.unit, SecurityScanAgeUnit.minutes); + expect(age.count, 7); + }); + + test("hours are counted", () { + final SecurityScanAge age = SecurityScanAge.between( + now.subtract(const Duration(hours: 5)), + now, + ); + expect(age.unit, SecurityScanAgeUnit.hours); + expect(age.count, 5); + }); + + test("days are counted", () { + final SecurityScanAge age = SecurityScanAge.between( + now.subtract(const Duration(days: 3, hours: 2)), + now, + ); + expect(age.unit, SecurityScanAgeUnit.days); + expect(age.count, 3); + }); + + test("a timestamp in the future is just now, not a negative age", () { + final SecurityScanAge age = SecurityScanAge.between( + now.add(const Duration(hours: 2)), + now, + ); + expect(age.unit, SecurityScanAgeUnit.justNow); + expect(age.count, 0); + }); + + test("exactly one minute is a minute, not just now", () { + final SecurityScanAge age = SecurityScanAge.between( + now.subtract(const Duration(minutes: 1)), + now, + ); + expect(age.unit, SecurityScanAgeUnit.minutes); + expect(age.count, 1); + }); + }); + + group("cache", () { + late Directory directory; + late SecurityScanCache cache; + + setUp(() { + directory = Directory.systemTemp.createTempSync("q1-security-scan"); + cache = SecurityScanCache(directory: directory); + }); + + tearDown(() { + if (directory.existsSync()) { + directory.deleteSync(recursive: true); + } + }); + + test("has no result before the first scan", () async { + expect(await cache.read(), isNull); + }); + + test("writes and reads the result back", () async { + final SecurityScanSnapshot snapshot = SecurityScanSnapshot( + tokens: ["firewallinactive", "sshrunning"], + timestamp: DateTime.utc(2026, 10, 23, 12, 30), + ); + + await cache.write(snapshot); + + final SecurityScanSnapshot? restored = await cache.read(); + expect(restored, isNotNull); + expect(restored!.tokens, snapshot.tokens); + expect(restored.timestamp.toUtc(), snapshot.timestamp.toUtc()); + }); + + test("creates the directory it needs", () async { + final Directory nested = + Directory("${directory.path}/deep/inside/linux-assistant"); + final SecurityScanCache nestedCache = + SecurityScanCache(directory: nested); + + await nestedCache.write(SecurityScanSnapshot( + tokens: ["sshrunning"], + timestamp: DateTime.utc(2026, 10, 23), + )); + + expect(nestedCache.file.existsSync(), isTrue); + }); + + test("survives a corrupt file instead of failing", () async { + cache.file.writeAsStringSync("{not json at all"); + + expect(await cache.read(), isNull); + }); + + test("survives an empty file", () async { + cache.file.writeAsStringSync(" "); + + expect(await cache.read(), isNull); + }); + + test("leaves no temporary file behind", () async { + await cache.write(SecurityScanSnapshot( + tokens: ["sshrunning"], + timestamp: DateTime.utc(2026, 10, 23), + )); + + final List leftovers = directory + .listSync() + .map((FileSystemEntity entity) => entity.path.split("/").last) + .where((String name) => name.endsWith(".tmp")) + .toList(); + expect(leftovers, isEmpty); + }); + + test("the written file is the documented JSON", () async { + await cache.write(SecurityScanSnapshot( + tokens: ["firewallinactive"], + timestamp: DateTime.utc(2026, 10, 23, 12, 30), + )); + + final Map decoded = + jsonDecode(cache.file.readAsStringSync()) as Map; + expect(decoded["version"], 1); + expect(decoded["timestamp"], "2026-10-23T12:30:00.000Z"); + expect(decoded["tokens"], ["firewallinactive"]); + }); + }); + + group("the page", () { + late Directory directory; + late SecurityScanCache cache; + late _RecordingRunner runner; + final CommandRunner originalRunner = CommandHelper.runner; + + setUp(() { + directory = Directory.systemTemp.createTempSync("q1-security-scan"); + cache = SecurityScanCache(directory: directory); + runner = _RecordingRunner(const CommandResult( + true, + kSuccessfulReport, + "", + 0, + )); + CommandHelper.runner = runner; + }); + + tearDown(() { + CommandHelper.runner = originalRunner; + if (directory.existsSync()) { + directory.deleteSync(recursive: true); + } + }); + + testWidgets("starts no scan when the page is opened", (tester) async { + await tester.pumpWidget(_host(cache)); + await _drainRealIo(tester); + + expect(runner.calls, isEmpty, + reason: "the scan reaches root through pkexec; it may only start " + "when the user asks for it"); + }); + + testWidgets("offers the button that starts the scan", (tester) async { + await tester.pumpWidget(_host(cache)); + await _drainRealIo(tester); + + expect(find.byKey(kRunScanButton), findsOneWidget); + }); + + testWidgets("starts exactly one scan when the button is pressed", + (tester) async { + await tester.pumpWidget(_host(cache)); + await _drainRealIo(tester); + + await tester.tap(find.byKey(kRunScanButton)); + await _drainRealIo(tester); + + expect(runner.calls.length, 1); + expect(runner.calls.single.first, contains("pkexec")); + expect(runner.calls.single, contains("read_security_report.py")); + }); + + testWidgets("stores the result of the scan in the cache", (tester) async { + await tester.pumpWidget(_host(cache)); + await _drainRealIo(tester); + + await tester.tap(find.byKey(kRunScanButton)); + await _drainRealIo(tester); + + expect(cache.file.existsSync(), isTrue); + // Echte Datei-IO muss in einem Widget-Test ueber runAsync laufen: in + // der Fake-Async-Zone bekommt das Future nie sein IO-Ereignis und der + // Test haengt bis zum Zeitueberlauf. + final SecurityScanSnapshot? stored = + await tester.runAsync(() => cache.read()); + expect(stored, isNotNull); + expect(stored!.tokens, contains("firewallinactive")); + expect(stored.tokens, isNot(contains("#!script ran successfully."))); + }); + + testWidgets("shows the cached result with its age after a restart", + (tester) async { + // What a restart looks like: the cache file is there, the page is not. + await tester.runAsync(() => cache.write(SecurityScanSnapshot( + tokens: ["firewallinactive"], + timestamp: DateTime.now().subtract(const Duration(minutes: 5)), + ))); + + await tester.pumpWidget(_host(cache)); + await _drainRealIo(tester); + + expect(find.textContaining("5"), findsWidgets, + reason: "the age of the cached result is shown"); + expect(runner.calls, isEmpty); + }); + }); +} diff --git a/test/q_dashboard_kacheln_test.dart b/test/q_dashboard_kacheln_test.dart new file mode 100644 index 00000000..4b352f4e --- /dev/null +++ b/test/q_dashboard_kacheln_test.dart @@ -0,0 +1,251 @@ +// Q2, Q3, Q5 and Q6 of issue #58 — the four dashboard additions. +// +// The dashboard shows: whether a reboot is pending and which packages caused +// it, which systemd units failed, which browser the desktop calls the +// standard one, and what the memory pressure actually is. All four are read +// from the system, so the parsers are the part worth pinning: a wrong +// number on a dashboard is worse than no number. +// +// The parsers are pure functions, so they are tested without touching the +// file system or starting a process. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:linux_assistant/linux/linux_memory.dart'; +import 'package:linux_assistant/services/browser_status_service.dart'; +import 'package:linux_assistant/services/failed_units_service.dart'; +import 'package:linux_assistant/services/reboot_service.dart'; + +void main() { + group("Q2 — the reboot marker", () { + test("lists the packages that caused the reboot", () { + final List packages = RebootService.parsePackages( + "linux-firmware\nlinux-image-6.1\n", + ); + expect(packages, ["linux-firmware", "linux-image-6.1"]); + }); + + test("skips blank lines and comments", () { + final List packages = RebootService.parsePackages( + "\n# a comment\nlinux-firmware\n\n \n", + ); + expect(packages, ["linux-firmware"]); + }); + + test("an empty file yields no packages", () { + expect(RebootService.parsePackages(""), isEmpty); + }); + + test("no marker file means no reboot is pending", () async { + final RebootStatus status = await RebootService.readStatus( + markerPaths: ["/nonexistent/reboot-required"], + packagePaths: ["/nonexistent/reboot-required.pkgs"], + ); + expect(status.required, isFalse); + expect(status.packages, isEmpty); + }); + + test("a marker without a package list is a pending reboot of unknown cause", + () async { + final Directory directory = + Directory.systemTemp.createTempSync("mla-reboot-marker"); + addTearDown(() { + if (directory.existsSync()) directory.deleteSync(recursive: true); + }); + final File marker = File("${directory.path}/reboot-required"); + marker.writeAsStringSync(""); + + final RebootStatus status = await RebootService.readStatus( + markerPaths: [marker.path], + packagePaths: ["${directory.path}/missing.pkgs"], + ); + expect(status.required, isTrue); + expect(status.packages, isEmpty); + }); + + test("a marker with a package list names the cause", () async { + final Directory directory = + Directory.systemTemp.createTempSync("mla-reboot-packages"); + addTearDown(() { + if (directory.existsSync()) directory.deleteSync(recursive: true); + }); + final File marker = File("${directory.path}/reboot-required"); + marker.writeAsStringSync(""); + final File packages = File("${directory.path}/reboot-required.pkgs"); + packages.writeAsStringSync("linux-firmware\nlinux-image-6.1\n"); + + final RebootStatus status = await RebootService.readStatus( + markerPaths: [marker.path], + packagePaths: [packages.path], + ); + expect(status.required, isTrue); + expect(status.packages, ["linux-firmware", "linux-image-6.1"]); + }); + }); + + group("Q3 — the failed systemd units", () { + test("reads unit, description and scope from the systemctl JSON", () { + final List units = FailedUnitsService.parseUnitsJson( + '{"units":[{"unit":"restic-backup.service",' + '"description":"Backup and restore service"}]}', + FailedUnitScope.system, + ); + expect(units, hasLength(1)); + expect(units.single.unit, "restic-backup.service"); + expect(units.single.description, "Backup and restore service"); + expect(units.single.scope, FailedUnitScope.system); + }); + + test("the scope comes from the manager that was asked", () { + final List units = FailedUnitsService.parseUnitsJson( + '{"units":[{"unit":"user@1000.service","description":""}]}', + FailedUnitScope.user, + ); + expect(units.single.scope, FailedUnitScope.user); + }); + + test("a unit without a description is still listed", () { + final List units = FailedUnitsService.parseUnitsJson( + '{"units":[{"unit":"a.service"}]}', + FailedUnitScope.system, + ); + expect(units, hasLength(1)); + expect(units.single.description, isEmpty); + }); + + test("an entry without a unit name is skipped", () { + final List units = FailedUnitsService.parseUnitsJson( + '{"units":[{"description":"no name here"}]}', + FailedUnitScope.system, + ); + expect(units, isEmpty); + }); + + test("broken JSON does not crash the dashboard", () { + final List units = FailedUnitsService.parseUnitsJson( + "{not json at all", + FailedUnitScope.system, + ); + expect(units, isEmpty); + }); + + test("an empty answer is an empty list", () { + expect( + FailedUnitsService.parseUnitsJson("", FailedUnitScope.system), + isEmpty, + ); + expect( + FailedUnitsService.parseUnitsJson( + '{"units":[]}', FailedUnitScope.system), + isEmpty, + ); + }); + }); + + group("Q5 — the standard browser", () { + // The format below is what `gio mime x-scheme-handler/https` really prints + // (measured on gio 2.74). The trailing sections list applications that can + // open the type without being the default; the dashboard wants the + // default, so they are ignored. + const String gioOutput = "Default application for " + "?x-scheme-handler/https?: brave-browser.desktop\n" + "No registered applications\n" + "No recommended applications\n"; + + test("reads the desktop entry that handles HTTPS", () { + expect( + BrowserStatusService.parseGioMimeHandler(gioOutput), + "brave-browser.desktop", + ); + }); + + test("ignores the sections that only list candidates", () { + expect( + BrowserStatusService.parseGioMimeHandler( + "No default applications for ?x-scheme-handler/https?\n", + ), + isNull, + ); + }); + + test("an output without a default line means the handler is unknown", () { + expect(BrowserStatusService.parseGioMimeHandler(""), isNull); + expect( + BrowserStatusService.parseGioMimeHandler( + "No registered applications\n"), + isNull, + ); + }); + + test("an empty value means the handler is unknown", () { + expect( + BrowserStatusService.parseGioMimeHandler( + "Default application for ?x-scheme-handler/https?: \n", + ), + isNull, + ); + }); + }); + + group("Q6 — the memory tile", () { + test("reads the average over the last minute from the PSI file", () { + final MemoryPressure? pressure = LinuxMemory.parsePressure( + "some avg10=0.00 avg60=1.23 avg300=0.45 total=1234\n" + "full avg10=0.00 avg60=0.10 avg300=0.02 total=567\n", + ); + expect(pressure, isNotNull); + expect(pressure!.someAvg60, closeTo(1.23, 0.001)); + expect(pressure.fullAvg60, closeTo(0.10, 0.001)); + }); + + test("a machine under no pressure is normal", () { + expect( + LinuxMemory.levelFor(someAvg60: 0.5, fullAvg60: 0.0), + MemoryPressureLevel.normal, + ); + }); + + test("sustained pressure is elevated", () { + expect( + LinuxMemory.levelFor(someAvg60: 6.0, fullAvg60: 0.0), + MemoryPressureLevel.elevated, + ); + }); + + test("a machine that is out of memory is at the high level", () { + expect( + LinuxMemory.levelFor(someAvg60: 20.0, fullAvg60: 0.0), + MemoryPressureLevel.high, + ); + expect( + LinuxMemory.levelFor(someAvg60: 0.0, fullAvg60: 6.0), + MemoryPressureLevel.high, + ); + }); + + test("a PSI file without a usable figure yields nothing", () { + expect(LinuxMemory.parsePressure(""), isNull); + expect(LinuxMemory.parsePressure("some avg10=0.00 total=1\n"), isNull); + }); + + test("zram reports the size before and after compression", () { + final (int, int)? sizes = + LinuxMemory.parseZramMmStat("7516192768 2147483648 0 0 0 0 0"); + expect(sizes, isNotNull); + expect(sizes!.$1, 7516192768); + expect(sizes.$2, 2147483648); + }); + + test("a broken zram statistic yields nothing", () { + expect(LinuxMemory.parseZramMmStat(""), isNull); + expect(LinuxMemory.parseZramMmStat("7516192768"), isNull); + expect(LinuxMemory.parseZramMmStat("no numbers here"), isNull); + }); + + test("the zram capacity is the size of the device", () { + expect(LinuxMemory.parseZramDiskSize("2147483648\n"), 2147483648); + expect(LinuxMemory.parseZramDiskSize(""), isNull); + }); + }); +} diff --git a/test/wp_s3_hygiene_test.dart b/test/wp_s3_hygiene_test.dart new file mode 100644 index 00000000..c0d0c5d8 --- /dev/null +++ b/test/wp_s3_hygiene_test.dart @@ -0,0 +1,249 @@ +// WP-S3 (#56, #46, #50): das Hygiene-Bundle der Kommandoebene. +// +// Drei Dinge werden hier festgenagelt: +// +// 1. Die Kommandoebene startet per Default *ohne* Shell. Die untere Ebene +// (`CommandHelper`, `la_core`) hatte den Default bereits auf `false`; die +// App-Ebene (`Linux.runCommandWithCustomArguments`, `Linux.runProcess`) +// stand noch auf `true` und startete damit fuer jeden Aufruf eine Shell +// *und* das Kommando. +// 2. Ein Pfad mit Leerzeichen kommt als EIN argv-Element an. Der alte +// Kommentar im Code behauptete das Gegenteil ("argv space-joined +// unquoted") — das war falsch: Dart quotet jedes argv-Element einzeln, +// wenn es den `sh -c`-String baut. Die Shell war nie ein Split-Risiko, +// sondern nur ein ueberfluessiger Zwischenschritt. +// 3. Die Rekursionstiefe der Ordnersuche ist geklemmt. Der Wert kommt aus +// einer Konfigurationsdatei, die der Benutzer schreiben kann; der alte +// `final int`-Cast machte daraus einen TypeError statt eines Defaults. +// +// Der Test benutzt den Seam `CommandHelper.runner` — es startet kein echter +// Prozess, ausser dort, wo es gerade um das Verhalten der Dart-VM geht. + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:linux_assistant/enums/desktops.dart'; +import 'package:linux_assistant/enums/softwareManagers.dart'; +import 'package:linux_assistant/helpers/command_helper.dart'; +import 'package:linux_assistant/models/environment.dart'; +import 'package:linux_assistant/services/config_handler.dart'; +import 'package:linux_assistant/services/folder_recursion_depth.dart'; +import 'package:linux_assistant/services/linux.dart'; + +/// Zeichnet auf, was stattgefunden haette, statt etwas zu starten. +class _RecordingRunner implements CommandRunner { + /// Das Kommando, so wie es der Runner bekommen hat (bereits expandiert). + final List commands = []; + + /// Die Argumente je Aufruf. + final List> argumentLists = >[]; + + /// Das `runInShell`-Flag je Aufruf. + final List runInShellFlags = []; + + @override + Future run( + String command, + List arguments, { + Map? environment, + bool asRoot = false, + bool hostOnFlatpak = true, + bool runInShell = false, + }) async { + commands.add(command); + argumentLists.add(List.of(arguments)); + runInShellFlags.add(runInShell); + return const CommandResult(true, "", ""); + } +} + +void main() { + late _RecordingRunner runner; + + setUp(() { + runner = _RecordingRunner(); + CommandHelper.runner = runner; + }); + + tearDown(() { + CommandHelper.runner = CommandHelper.processRunner; + }); + + group("Der Default der Kommandoebene ist: keine Shell", () { + test("runCommandWithCustomArguments startet ohne Shell", () async { + await Linux.runCommandWithCustomArguments("/usr/bin/true", const []); + + expect(runner.runInShellFlags, hasLength(1)); + expect(runner.runInShellFlags.single, isFalse); + }); + + test("runProcess reicht runInShell: false durch", () async { + await Linux.runProcess("/usr/bin/true", const []); + + expect(runner.runInShellFlags, hasLength(1)); + expect(runner.runInShellFlags.single, isFalse); + }); + + test("runCommand (String-Form) startet ohne Shell", () async { + await Linux.runCommand("/usr/bin/true"); + + expect(runner.runInShellFlags, hasLength(1)); + expect(runner.runInShellFlags.single, isFalse); + }); + }); + + group("Ein Pfad mit Leerzeichen bleibt ein Argument", () { + test("der Pfad kommt als ein argv-Element an", () async { + const String path = "/home/user/My Documents/x.txt"; + + await Linux.runCommandWithCustomArguments("/usr/bin/ls", [path]); + + expect(runner.argumentLists, hasLength(1)); + expect(runner.argumentLists.single, hasLength(1)); + expect(runner.argumentLists.single.single, path); + }); + + test( + "Dart haelt das Argument auch dann zusammen, wenn es eine Shell " + "baut", () async { + // Beleg fuer den korrigierten Kommentar im Code: mit + // `runInShell: true` space-joined Dart das argv nicht, es quotet jedes + // Element einzeln. Ohne diesen Beleg waere die Korrektur wieder eine + // Behauptung. + const String path = "/home/user/My Documents/x.txt"; + + final ProcessResult result = + await Process.run("/usr/bin/echo", [path], runInShell: true); + + expect(result.exitCode, 0); + expect(result.stdout.toString().trim(), path); + }); + + test("Shell-Metazeichen in einem Argument bleiben inert", () async { + // Der alte Kommentar behauptete, ein Metazeichen wuerde ausgefuehrt. + // Dart quotet es, also wird es nur durchgereicht. + final Directory sandbox = + Directory.systemTemp.createTempSync("mla_wp_s3_shell_"); + final String payload = "; touch ${sandbox.path}/pwned"; + addTearDown(() { + if (sandbox.existsSync()) sandbox.deleteSync(recursive: true); + }); + + final ProcessResult result = + await Process.run("/usr/bin/echo", [payload], runInShell: true); + + expect(result.exitCode, 0); + expect(result.stdout.toString().trim(), payload); + expect(File("${sandbox.path}/pwned").existsSync(), isFalse); + }); + }); + + group("Die Rekursionstiefe der Ordnersuche ist geklemmt", () { + late Directory sandbox; + + setUp(() { + sandbox = Directory.systemTemp.createTempSync("mla_wp_s3_depth_"); + ConfigHandler().resetForTesting(directory: sandbox); + }); + + tearDown(() { + ConfigHandler().resetForTesting(); + if (sandbox.existsSync()) sandbox.deleteSync(recursive: true); + }); + + test("eine zu kleine Tiefe wird auf 1 gehoben", () { + ConfigHandler().setValueUnsafe("folder_recursion_depth", 0); + + expect(Linux.folderRecursionDepth(), 1); + }); + + test("eine zu grosse Tiefe wird auf 10 gesenkt", () { + ConfigHandler().setValueUnsafe("folder_recursion_depth", 999); + + expect(Linux.folderRecursionDepth(), 10); + }); + + test("eine plausible Tiefe bleibt unveraendert", () { + ConfigHandler().setValueUnsafe("folder_recursion_depth", 3); + + expect(Linux.folderRecursionDepth(), 3); + }); + + test("ein Textwert landet im Fallback und loest nichts aus", () { + final Directory artifact = + Directory.systemTemp.createTempSync("mla_wp_s3_inject_"); + addTearDown(() { + if (artifact.existsSync()) artifact.deleteSync(recursive: true); + }); + ConfigHandler().setValueUnsafe( + "folder_recursion_depth", "'; touch ${artifact.path}/pwned"); + + // Kein TypeError (der alte `final int`-Cast), sondern der Default. + expect(Linux.folderRecursionDepth(), 3); + expect(File("${artifact.path}/pwned").existsSync(), isFalse); + }); + + test("fehlt der Config-Wert, gilt der Default", () { + expect(Linux.folderRecursionDepth(), kDefaultFolderRecursionDepth); + }); + + test("die Klemme nimmt eine Zahl als Text an", () { + expect(clampFolderRecursionDepth("5"), 5); + }); + + test("die Klemme verwirft Werte, die keine Zahl sind", () { + expect(clampFolderRecursionDepth(true), kDefaultFolderRecursionDepth); + expect(clampFolderRecursionDepth(3.5), kDefaultFolderRecursionDepth); + expect(clampFolderRecursionDepth(""), kDefaultFolderRecursionDepth); + expect(clampFolderRecursionDepth(null), kDefaultFolderRecursionDepth); + expect(clampFolderRecursionDepth([]), kDefaultFolderRecursionDepth); + }); + }); + + group("Der Disk-Analyzer startet nach der Installationspruefung", () { + late Environment originalEnvironment; + + setUp(() { + originalEnvironment = Linux.currentenvironment; + Linux.currentenvironment = Environment() + ..desktop = DESKTOPS.KDE + ..installedSoftwareManagers = [SOFTWARE_MANAGERS.APT]; + Linux.commandQueue.clear(); + }); + + tearDown(() { + Linux.currentenvironment = originalEnvironment; + Linux.commandQueue.clear(); + }); + + testWidgets( + "der Analyzer startet genau einmal, ohne Shell und nach dem Check", + (WidgetTester tester) async { + late BuildContext context; + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (BuildContext inner) { + context = inner; + return const SizedBox.shrink(); + }), + )); + + await Linux.openDiskSpaceAnalyzer(context, "/mnt/data stick"); + + // Der Installationscheck lief vor dem Start. + expect(runner.commands, contains("/usr/bin/dpkg-query")); + final int checkIndex = runner.commands.indexOf("/usr/bin/dpkg-query"); + final int launchIndex = runner.commands.indexOf("/usr/bin/k4dirstat"); + expect(launchIndex, greaterThan(checkIndex)); + + // Genau ein Start — der alte Code startete bedingungslos und danach + // ein zweites Mal. + expect(runner.commands.where((String c) => c == "/usr/bin/k4dirstat"), + hasLength(1)); + + // Ohne Shell, und der Mountpoint ist ein Argument. + expect(runner.runInShellFlags[launchIndex], isFalse); + expect(runner.argumentLists[launchIndex], ["/mnt/data stick"]); + }); + }); +}