Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ Deeper instructions override `~/AGENTS.md` for this subtree.

**Purpose and stack:** Flutter/Dart Linux desktop app (C++/GTK runner,
localized en/de/it/fi) with privileged Python helpers and Debian packaging.
Read `MANIFEST.md` (philosophy) and `features.csv` (distro/desktop support
matrix) before changing distro-conditional behavior. Requires
Read `MANIFEST.md` (philosophy; reference system: Zorin OS 18.1) and
`features.csv` (distro/desktop support matrix; distro columns frozen at
upstream state) before changing distro-conditional behavior. Requires
Dart ≥3.4 / Flutter ≥3.27.

**Key files:** `version` (single source of truth), `pubspec.yaml`,
Expand Down
28 changes: 22 additions & 6 deletions MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,23 @@ as long the free (alternative) is well adapted in the community and has similar
The installation of Linux Assistant should be very easy.
As few dependencies as possible should be used.

> **Fork note (removable derivation):** this repository is first a personal
> cockpit for Zorin OS 18.1. The upstream mission above stands unchanged;
> the tiers below are measured on the reference system only.

## Feature tiers

To keep the mission focused, features are grouped into three tiers.
Every feature in `features.csv` carries its tier in the `Category` column.

**Core** — Functions the mission depends on: the daily helper (search,
environment recognition) and administrative tasks (package management,
updates, security and health checks, system setup). These deserve the
broadest distribution support; a broken core function is a release blocker.
environment recognition), administrative tasks (package management,
updates, security and health checks, system setup) and the shipped hub
tools (browser launcher, quick notes, file manager, system monitor).
A broken core function is a release blocker on the reference system.
Integrations with foreign backends (TokenTelemetry, Hermes, Odysseus,
weather APIs) are never Core; system components (apt, systemd, Restic,
Docker) may be.

**Quality of Life (QoL)** — Functions that make daily use noticeably more
convenient without adding new mission scope. Rules of thumb:
Expand All @@ -34,11 +42,19 @@ convenient without adding new mission scope. Rules of thumb:
- They may be invisible on many distributions without hurting the product.
- Removing one is legitimate whenever its maintenance cost exceeds its value.

**Frozen distro matrix:** the distribution and desktop columns of
`features.csv` are frozen at the upstream state — only the reference
system (Zorin OS 18.1, GNOME) is verified by this fork.

Kurzfassung (DE):

- **Core:** Kern der Mission — täglicher Helfer plus Admin-Aufgaben;
breiter Distro-Support ist Pflicht, ein Bruch ist Release-Blocker.
- **Core:** Kern der Mission — täglicher Helfer, Admin-Aufgaben und die
mitgelieferten Hub-Werkzeuge; am Referenzsystem (Zorin OS 18.1) ist ein
Bruch Release-Blocker. Fremd-Backend-Integrationen sind nie Core,
Systemkomponenten (apt, systemd, Restic, Docker) dürfen.
- **QoL:** Alltagskomfort ohne neue Missions-Scope — keine neuen
Abhängigkeiten, folgt dem Design-System, darf auf Distros fehlen.
- **n2h:** Optionale Extras — nur mit kleinem Abhängigkeits-Fußabdruck und
unangetasteten Security-Invarianten; Entfernung ist legitim.
unangetasteten Security-Invarianten; Entfernung ist legitim.
- **Eingefroren:** Die Distro-/Desktop-Spalten der `features.csv` stehen auf
Upstream-Stand; verifiziert wird nur das Referenzsystem.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,17 @@ sudo apt install libkeybinder-3.0-0 libkeybinder-3.0-dev
To run an installed package, only the runtime libraries are needed — the `.deb`
declares them, so `apt` pulls them in for you. The declared runtime set is
`libgtk-3-0, libkeybinder-3.0-0, python3, python3-gi,
gir1.2-gtk-3.0, python3-apt, mesa-utils, pkexec | policykit-1` (see
`deb/DEBIAN/control`).
gir1.2-gtk-3.0, python3-apt, mesa-utils, pkexec | policykit-1, xdg-utils,
libgtk-3-bin, libglib2.0-bin` (see `deb/DEBIAN/control`).

If you build with `flutter build linux` and run the bundle directly (Option 1
under [Build](#build)), `apt` does not install those packages for you. The
Python helpers need GObject introspection, so install them by hand first:

```bash
sudo apt install libgtk-3-0 libkeybinder-3.0-0 python3 python3-gi \
gir1.2-gtk-3.0 python3-apt mesa-utils policykit-1
gir1.2-gtk-3.0 python3-apt mesa-utils policykit-1 \
xdg-utils libgtk-3-bin libglib2.0-bin
```

## Build
Expand Down
16 changes: 11 additions & 5 deletions additional/python/jessentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@ def get_environment_variable(key, default=""):

# example for environment={'DEBIAN_FRONTEND': 'noninteractive'}
# if return_output==true: function returns a array of strings
# A list is used as argv unchanged; a string is shlex-split. Callers that
# build commands from values (paths, URLs) must pass a list, so a space
# stays inside one argument.
def run_command(command, print_output=True, return_output=False, environment = {}, user=None):
argv = list(command) if isinstance(command, list) else shlex.split(command)
if sys.version_info < (3, 9):
if user == None:
user = os.getuid()
process = subprocess.Popen(shlex.split(command), stdout=subprocess.PIPE, env=environment, preexec_fn=_demote(user, user))
process = subprocess.Popen(argv, stdout=subprocess.PIPE, env=environment, preexec_fn=_demote(user, user))
else:
process = subprocess.Popen(shlex.split(command), stdout=subprocess.PIPE, env=environment, user=user)
process = subprocess.Popen(argv, stdout=subprocess.PIPE, env=environment, user=user)
output_lines = [] # In this the output is saved line per line
if print_output or return_output:
while True:
Expand Down Expand Up @@ -164,7 +168,8 @@ def get_accessible_table_of_raw_csv_table(csv_raw_table):


def download_file(link, destination_folder):
run_command("wget %s -P %s" % (link, destination_folder), False)
# `--` keeps a leading-dash link from becoming wget options.
run_command(["wget", "-P", destination_folder, "--", link], False)


def get_filename_of_path(path):
Expand All @@ -176,8 +181,9 @@ def unzip_file(file_path):
file_name_zip = get_filename_of_path(file_path)
file_name = os.path.splitext(file_name_zip)[0]
path = file_path.replace(file_name_zip, "")
run_command("mkdir %s%s" % (path, file_name), False)
run_command("unzip -o %s -d %s%s" % (file_path, path, file_name), False)
# No `--` fence here: after it, unzip would read `-d` as a member name.
run_command(["mkdir", path + file_name], False)
run_command(["unzip", "-o", file_path, "-d", path + file_name], False)
Comment on lines +184 to +186
return "%s%s" % (path, file_name)

def import_json_string(string):
Expand Down
3 changes: 2 additions & 1 deletion additional/python/jfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import gettext
import json
import os
import shutil

def does_file_exist(file_path):
return os.path.exists(file_path) and os.path.isfile(file_path)
Expand Down Expand Up @@ -178,7 +179,7 @@ def get_string_of_file(file_path):


def copy_file(source_path, destination_path):
os.system("cp '" + source_path + "' '" + destination_path + "'")
shutil.copy2(source_path, destination_path)

def get_dict_of_json_file(file_path):
return json.load(open(file_path, 'r'))
Expand Down
62 changes: 62 additions & 0 deletions additional/python/tests/test_jessentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Tests for the download/unzip helpers.

WP-P2 (#48): `download_file` and `unzip_file` have no callers in this repo,
but the root scripts import jessentials, so the helpers must stay safe to
call. Both used to interpolate URLs and paths into command strings that
`run_command` hands to shlex: a path with spaces became several arguments
(whitespace split), and a leading-dash URL became wget options (CWE-88).
Passed as argv lists, the values stay values.
"""

import os
import sys
import unittest
from unittest import mock

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import jessentials # noqa: E402


class RunCommand(unittest.TestCase):
def test_accepts_an_argv_list_directly(self):
completed = jessentials.run_command(
[sys.executable, "-c", "print('ok')"], print_output=False
)
self.assertEqual(completed, 0)


class DownloadFile(unittest.TestCase):
def test_url_and_folder_stay_single_arguments(self):
with mock.patch.object(jessentials, "run_command") as run:
jessentials.download_file(
"https://example.org/my file.tar.gz", "/opt/my apps"
)
argv = run.call_args[0][0]
self.assertIsInstance(argv, list)
self.assertIn("https://example.org/my file.tar.gz", argv)
self.assertIn("/opt/my apps", argv)

def test_leading_dash_url_lands_after_the_option_fence(self):
with mock.patch.object(jessentials, "run_command") as run:
jessentials.download_file("-O/tmp/pwned", "/tmp")
argv = run.call_args[0][0]
self.assertIn("--", argv)
self.assertLess(argv.index("--"), argv.index("-O/tmp/pwned"))


class UnzipFile(unittest.TestCase):
def test_path_with_spaces_stays_one_argument(self):
with mock.patch.object(jessentials, "run_command") as run:
returned = jessentials.unzip_file("/opt/my apps/archive v2.zip")
mkdir_argv = run.call_args_list[0][0][0]
unzip_argv = run.call_args_list[1][0][0]
self.assertEqual(mkdir_argv, ["mkdir", "/opt/my apps/archive v2"])
self.assertEqual(
unzip_argv,
["unzip", "-o", "/opt/my apps/archive v2.zip", "-d", "/opt/my apps/archive v2"],
)
self.assertEqual(returned, "/opt/my apps/archive v2")
# No `--` fence for unzip: after it, the `-d` target would be read
# as a member name to extract instead of the destination.
self.assertNotIn("--", unzip_argv)
44 changes: 44 additions & 0 deletions additional/python/tests/test_jfiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Tests for the file helpers.

WP-P1 (#43): `copy_file` backs the Timeshift setup, which copies the default
config to its live location. The copy must not route through `os.system` with
interpolated paths — and `shutil.copy2` additionally keeps the metadata the
old `cp` invocation promised, which matters for a config file an admin may
have just edited.
"""

import os
import sys
import tempfile
import unittest
from unittest import mock

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import jfiles # noqa: E402


class CopyFile(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.source = os.path.join(self._tmp.name, "default.json")
self.destination = os.path.join(self._tmp.name, "timeshift.json")
with open(self.source, "w") as handle:
handle.write('{"schedule": "daily"}\n')
# A whole-second stamp: also exact on filesystems with coarse
# timestamp granularity.
os.utime(self.source, (1234567, 1234567))

def tearDown(self):
self._tmp.cleanup()

def test_copies_content_and_mtime_without_a_shell(self):
with mock.patch.object(
jfiles.os, "system", side_effect=AssertionError("os.system must not be used")
):
jfiles.copy_file(self.source, self.destination)
with open(self.destination) as handle:
self.assertEqual(handle.read(), '{"schedule": "daily"}\n')
self.assertEqual(
os.stat(self.destination).st_mtime, os.stat(self.source).st_mtime
)
2 changes: 1 addition & 1 deletion deb/DEBIAN/control
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Package: linux-assistant
Depends: libgtk-3-0, libkeybinder-3.0-0, python3, python3-gi, gir1.2-gtk-3.0, python3-apt, mesa-utils, pkexec | policykit-1
Depends: libgtk-3-0, libkeybinder-3.0-0, python3, python3-gi, gir1.2-gtk-3.0, python3-apt, mesa-utils, pkexec | policykit-1, xdg-utils, libgtk-3-bin, libglib2.0-bin
Maintainer: Jean28518
Architecture: amd64
Homepage: https://www.linux-assistant.org/
Expand Down
2 changes: 1 addition & 1 deletion docs/design/admin-hub-followups.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ flutter test
Manuelle Checks:

- [ ] WERKZEUGE-Sektion erscheint in der Sidebar (nicht collapsed)
- [ ] Browser-Klick startet Brave/xdg-open, ändert die Section nicht
- [ ] Browser-Klick startet preferred/XDG-Standardbrowser (gtk-launch bzw. xdg-open), ändert die Section nicht
- [ ] Quick Notes: anlegen, tippen (Autosave 500 ms), Section wechseln,
zurück → Inhalt + Selektion bleiben
- [ ] Dateimanager: navigieren, Datei öffnen, Löschen mit Confirm (voller
Expand Down
6 changes: 6 additions & 0 deletions docs/design/feature-spec-admin-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ visuell abgesetzt (Sektions-Label in `textDim`, 12px, uppercase, wie

## 2. Feature 1: Browser-Verknüpfung (Brave)

> **Überholt (DR) seit V0.8.2:** Der Launch geht jetzt über `preferred_browser`
> (Allowlist-geprüft, WP-B1/#47) → XDG-Standardbrowser (`xdg-settings` +
> `.desktop`-Prüfung, `gtk-launch`/`xdg-open`) → `kKnownBrowsers`-Liste als
> letzter Fallback. Aktueller Stand: `docs/wiki/Admin-Hub.md` (E1). Der
> folgende Abschnitt beschreibt den historischen Erststand.

### Verhalten
- Klick auf „Browser" → öffnet Brave als externen Prozess
- Fallback-Kette: `brave` → `brave-browser` → `xdg-open https://`
Expand Down
2 changes: 1 addition & 1 deletion docs/handoff/panels/hub-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Registrierung erfolgt **statisch über zwei Enums**, nicht über eine Laufzeit-R
3. Routing: `_onToolTap()` (:417-432) verzweigt — `browser` → `_launchBrowser()` (:171-187), sonst → `_selectTool()` (:156-164).
4. Content: `_contentFor()` mappt `HubTool` auf Seite — `QuickNotesPage` (:287-288), `FileManagerPage` (:289-290), `SystemMonitorPage` (:291-292); `browser` wird nie Content (`SizedBox.shrink()`, :293-296).

**E1 Browser-Launcher:** kein eigenes Panel. `AppLauncher.launchBrowser()` (`services/app_launcher.dart:37, :101`, Kandidaten inkl. `brave`/`brave-browser` :21-22) startet detached; Feedback über `BrowserLaunchResult` → Snackbars nur bei Fallback („Brave nicht gefunden – Standard-Browser geöffnet.") und Fehler (:174-186).
**E1 Browser-Launcher:** kein eigenes Panel. `AppLauncher.launchBrowser()` (`services/app_launcher.dart:44, :173`; XDG-Stufe `xdg-settings` + `.desktop`-Prüfung + `gtk-launch`/`xdg-open`, letzter Fallback `kKnownBrowsers` :21-28) startet detached; Feedback über `BrowserLaunchResult` → Snackbars nur bei Listen-Fallback („Standard-Browser nicht ermittelbar – ersatzweise bekannten Browser gestartet.") und Fehler (:174-186).

**E2 Quick Notes:** `layouts/tools/quick_notes.dart` (`QuickNotesPage`, :13) — nutzt `MintYText.mono` für das Textfeld (:298, 301).

Expand Down
Loading