Fix [tanin] AUR rebuild names: eww-git, tiramisu-git (not eww/tiramisu)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 18:41:02 +02:00
parent 7a060fb7af
commit 9451e04f1c
154 changed files with 18947 additions and 3 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ mkdir -p "$OUT"
OWN_SIMPLE=(tanin-greet tanin-libadwaita tanin-setup tanin-eww tanin-desktop)
OWN_BUILD=(taninux)
AUR_REBUILD=(eww tiramisu waypaper)
AUR_REBUILD=(eww-git tiramisu-git waypaper)
build() {
local p="$1"
+2 -2
View File
@@ -16,9 +16,9 @@ sudo pacman -S --needed --noconfirm python-build python-installer python-hatchli
( cd "$HERE/taninux" && makepkg -sf --noconfirm ) \
&& cp "$HERE/taninux/"*.pkg.tar.zst "$OUT/" && echo " ok" || echo " FAILED (taninux)"
echo "==> AUR rebuilds: eww tiramisu waypaper"
echo "==> AUR rebuilds: eww-git tiramisu-git waypaper"
tmp="$(mktemp -d)"
for p in eww tiramisu waypaper; do
for p in eww-git tiramisu-git waypaper; do
echo " -- $p"
if git clone --depth=1 "https://aur.archlinux.org/$p.git" "$tmp/$p" >/dev/null 2>&1 \
&& ( cd "$tmp/$p" && makepkg -sf --noconfirm ); then
File diff suppressed because it is too large Load Diff
Binary file not shown.
+33
View File
@@ -0,0 +1,33 @@
# Generated by makepkg 7.1.0
# using fakeroot version 1.37.2
pkgname = taninux
pkgbase = taninux
xdata = pkgtype=pkg
pkgver = 0.2.0-1
pkgdesc = Central Linux management for Arch — GTK System Settings, Software Hub and TUI
url = https://taninux.kgva.ch
builddate = 1781886675
packager = Unknown Packager
size = 2745490
arch = any
license = GPL-3.0-or-later
depend = python
depend = python-gobject
depend = gtk4
depend = libadwaita
depend = polkit
optdepend = paru: AUR support in the Software Hub (opt-in)
optdepend = flatpak: Flatpak apps and updates
optdepend = pacman-contrib: update check (checkupdates) and cache cleanup (paccache)
optdepend = networkmanager: network settings
optdepend = bluez-utils: bluetooth settings
optdepend = wireplumber: audio settings (PipeWire)
optdepend = cups: printer management
optdepend = brightnessctl: display brightness
optdepend = wlsunset: night light
optdepend = timeshift: system snapshots/backups
makedepend = python-build
makedepend = python-installer
makedepend = python-wheel
makedepend = python-hatchling
makedepend = git
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from taninux.cli import main
if __name__ == "__main__":
sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0])
sys.exit(main())
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from taninux.gui.app import main
if __name__ == "__main__":
sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0])
sys.exit(main())
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from taninux.gui.app import main_hub
if __name__ == "__main__":
sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0])
sys.exit(main_hub())
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from taninux.gui.app import main_settings
if __name__ == "__main__":
sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0])
sys.exit(main_settings())
@@ -0,0 +1,9 @@
Metadata-Version: 2.4
Name: taninux
Version: 0.2.0
Summary: TANINUX — central Linux maintenance & management TUI for Arch. Pillars: maintain, update, kernel, suggest, health.
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: textual>=0.86
Provides-Extra: gui
Requires-Dist: pygobject>=3.50; (sys_platform == 'linux') and extra == 'gui'
@@ -0,0 +1,144 @@
../../../bin/taninux,sha256=EazMj8nYdwUA2FX1l7QoCaPTkVWapFfjFGcEZCxhIrA,209
taninux-0.2.0.dist-info/METADATA,sha256=XD3zJwHDMe9igtaV3aWRihX-6oNUDE_QTSwBCwRFFQ8,349
taninux-0.2.0.dist-info/RECORD,,
taninux-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
taninux-0.2.0.dist-info/entry_points.txt,sha256=a8OoA6CDS8mfUAEGXzS4OMI7W7KWNOpFqcqvKOqV7ZM,154
taninux-0.2.0.dist-info/licenses/LICENSE,sha256=-5gWaMGKJ54oX8TYP7oeg2zITdTapzyWl9PP0tispuA,34674
../../../bin/taninux-gtk,sha256=qVixIhGEFtuAtrhLWU-PAw2zyr2cMPSvQ-Z6-uO_Gzs,213
taninux/__init__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
taninux/__main__.py,sha256=8yGxX4NhckPZsL2XmkmWw_t3hwocE4oOb9yvKKvl3cE,55
taninux/app.py,sha256=iJeV-F80aLmGFCM0mmpqWkLiTnZWLtX-e1Ljdxo-6Uk,5285
taninux/bundles/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
taninux/bundles/builtins.py,sha256=CXSetbLIsjXi_p8sMN3RGJD8nThQzS-9tbT_YeG2w8E,3936
taninux/bundles/loader.py,sha256=ZtUEjyj130uUA78Kd62LBQFumUcFQ2g6Hub-MyAjOy4,1891
taninux/bundles/pillar.py,sha256=aALPWIWw_d19FBUzKlwgPwNWQKAHeLDHVnQ63TatRmY,258
taninux/bundles/screen.py,sha256=6exvk4HRYk0qSDYG_XG-2N-5qf-5i3XrSDfAE-YbTus,8603
taninux/cli.py,sha256=xyAuZVY7kYSVf9ODkta9ZKGu64mZ2Ww-_4IJUuQpdUA,27737
taninux/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
taninux/core/accessibility.py,sha256=laf7-c8D4OwYm9XHX8x_b9VXOgbs8eWHNbHYaz30s_k,1411
taninux/core/account.py,sha256=JU14ExIQ4q_R2tGiO8syRUiuU72TWqcsLVV8QgDQ-Hw,3771
taninux/core/appinfo.py,sha256=57faxBWrUvfReAGxngAURiljDU-KmVlqB_HPQPYZJj8,4819
taninux/core/audio.py,sha256=mufj-cdaeACgR-zcxvzlh6biwmiWRtsD4HkrskDvDEE,4256
taninux/core/avatar.py,sha256=e1Szn7KM8Z0bi__g2odm6WKJiaGkKffVTLnjZ7b-jVA,1942
taninux/core/backup.py,sha256=USYELzHQGPqnJ3_qFQUXOuwWWRzwakzbgbF06Dc-NnU,2048
taninux/core/bluetooth.py,sha256=TOg5WzeCl7X7PZ0KD8aSJQZAL9PS0cZzcMPsFROOKKY,2789
taninux/core/bootloader.py,sha256=wtrQ6wUZkhelWX5Iw4_LwlydrELrS4ZnH8j5Kfo53pM,5339
taninux/core/catalog.py,sha256=GwyWIrkuVP2emzKQjGWPQYIOTazyrBbYADoZlPERaWY,5576
taninux/core/compositor.py,sha256=Zt2JeWO6eNC6q-zHblSSfUyJpZKRYFPkqJcxiK2XVAM,1090
taninux/core/confirm.py,sha256=ICUvK01bi-0jpRW2lQz4zOoScZEKbI1m7I29f57DBBk,2518
taninux/core/defaultapps.py,sha256=C-0_wcUCUrJiSZsbb1xbxBDGdaNZdy6ugBoKEZ8rqB4,3956
taninux/core/display.py,sha256=s0IzBDxewwqw1jwtGfIijhsuAUG6mscgh2hfYwPpjAQ,9390
taninux/core/firewall.py,sha256=0qf5iHMDUs3mjmThSJlXUDrJswteuaziUkCq0g29Frs,1201
taninux/core/localization.py,sha256=8hMJb167JN7gVxiO025jU19kMZDJIb5CBWMiGvgtwQo,4146
taninux/core/lock.py,sha256=kGaoHRKiZcNFChGa_J7A0NhQSh7LMun2m_zsHCjIEvc,684
taninux/core/network.py,sha256=gLjaUXSTs9XRJnLapDwcXbdKVHTJ2-G--xAJ3vlX5mU,6327
taninux/core/nextcloud.py,sha256=Ez6M-4_sI1KhkeoiYnZNuVxahG-B_XiaOxJJrF8R5EA,3952
taninux/core/package_index.py,sha256=pHJmt1ua5k6krHFyyoxi8ZdFIjD80O0vsGNaeuN0_ok,2656
taninux/core/packages.py,sha256=0SwtI5WmU6JJwk17Es4pCMkRcd4M7zL_bOfYJMnVVik,1230
taninux/core/pacman.py,sha256=lIn7rvZxZN1LfzwD5j5rRDZgusSmvGvBm-bQc06VUao,813
taninux/core/panel.py,sha256=1Gp9JhREXmN1Kdd1oxS5wojRk0LlMPVxswM-8Pa95hY,5381
taninux/core/paths.py,sha256=F7i4rWlPeS6YnsQntzShmm2T-qubsN5ff0rpEwiNiag,712
taninux/core/pkg.py,sha256=jtxtoC3kOmz-edu-Jn6d7Vn5_pu4i5UAFGgwq8S0uCE,4568
taninux/core/power.py,sha256=hbmIlH2qxOnTSjdH0IE4LoZMnARfo-8Py9XEAXe-moE,8979
taninux/core/printers.py,sha256=DzPVLiMg6Kr56jRDgZUAqcV4dtnpgBCxNvB1R0z0n9A,3814
taninux/core/runner.py,sha256=hKuJXkyoW0iD5UGE5ylxPRjyp_jtEdZbeY43xOJ8UTs,1547
taninux/core/search.py,sha256=p83NEtFAD9qcMZW1JL8G4fb3tCxqcYlZkEDJhsEbXVo,5165
taninux/core/shares.py,sha256=Pcq82CJoQ_YWajGW-UhvhKFXWEQCrpLzTuDEid1lIzE,4624
taninux/core/snapshot.py,sha256=9gQkT_gB2xj2VKLsCM_SvHFUh5SRcpG7vtUxbRM-9b4,3884
taninux/core/state.py,sha256=-TwV-u68zVYC9D2svQh521839lJJRN5C1Qbmtzg-RHY,1875
taninux/core/storage.py,sha256=xyDhhGe6LJVKp8Az1SuBVjWY-g64Cdp6zsTXkfHhxhc,2190
taninux/core/streaming.py,sha256=tL8v17lfqM_SJMWYjh1ATLehL8kfO9EYxgS9ebUgjhw,5731
taninux/core/sudo.py,sha256=tIY6JAtwUCVczSX63tytccw31qYg1zQ1mmMC0QNslGk,1341
taninux/core/sudo_modal.py,sha256=iOdsC1MDx8nDVL5fxwCNvjkxBH9ScciGDF4m7m5wz3c,2222
taninux/core/sysinfo.py,sha256=hEazy-fZdt7J88ReClFG7iTYMKd_NnqvBfyTzQ8_L48,7594
taninux/core/theme.py,sha256=fgd2Lo3ryeTXnWbI9iNh-GDuLWGR6rUSiyULU_2lRIs,1069
taninux/core/trash.py,sha256=yThXpdeGVjH6_pSMFGkv5l4ExfY85Pz1nY4dfYkIy5o,3537
taninux/core/wallpaper.py,sha256=S3v_BzFYIbz_qqhmShFh103UAn5eribhFhauLlYtMZY,4134
taninux/flatpak/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
taninux/flatpak/pillar.py,sha256=Io0dKiboFu5EMixbYjT-T-0OXOecb2bP0Mi2q0SDGrE,251
taninux/flatpak/screen.py,sha256=kpySJ5F-08RolQCwuSpLpTXSfGXlxzCfAjjcDchaBBQ,10842
taninux/gui/README.md,sha256=X6n2y-FNlu8TPTlRWp-usASjocuvvWJ1UrZXgn45bQU,3496
taninux/gui/__init__.py,sha256=ybAnNTWZ7EbAi6dJ3HKJRL5RkkRaA2Hx1dt99GRSS2w,396
taninux/gui/__main__.py,sha256=jfT07opWgNkuL3XiJU45GcQNCsi326MWIe51odh2rSQ,59
taninux/gui/accents.py,sha256=x0U6HdIEIhSZzbrACLIXhNL-uR2IiOedVtlb2-iDb9o,1011
taninux/gui/app.py,sha256=KlOBzVTNIdZAzzfLjiP-8rjQVjwIae9tvjvoedtlCMU,1775
taninux/gui/config.py,sha256=k8VXfMvevQ-NEOYjm3rTtmgdcs-cj02na_LUAirl5SE,1240
taninux/gui/confirm.py,sha256=ShkFsYiQFr0lq79jBHvVo9N76mLWuW7iEPTzrnZxEZc,1410
taninux/gui/console.py,sha256=vEIZcmxDorOwXdrDiQdNkz1cRdPF10vCR7DTHCSXaCc,4391
taninux/gui/desktop.py,sha256=8b0So96mzSvPlfyAy4SOxc3HL5TuWaLbiE54N5cv0rg,8336
taninux/gui/eww.py,sha256=qXHvMJx5TSRpirMSS2tP5CuOMLolhPD6vUlDCwxaypE,1175
taninux/gui/helper/taninux-helper,sha256=3ivVllI4UeV8gzWDyLrYUXsWwZKfZ4dLjNrJuZ4ukIY,7172
taninux/gui/pages/__init__.py,sha256=h7fzAyVZtifwQ7vfUgApWm9b3Br5VzQwrmVwhNFcsCA,6220
taninux/gui/pages/about.py,sha256=9YYiV9zKpJmoB-wN6MQ_EbHyZ4kIfmfNxDtK3vk-b5M,1837
taninux/gui/pages/accessibility.py,sha256=W4O23XwjozpVNzAxomj1i4fdl8Y6M2yVQsZvm23a4j4,1195
taninux/gui/pages/appdetail.py,sha256=y6oqtQQlSqJ2VrthpLbsDjPnyGzFc524NEn1FqdItgA,7093
taninux/gui/pages/audio.py,sha256=t45im3l3Hfp-gu55krwwa0sqUEaHPlYPqQ1lNkHOkt4,6129
taninux/gui/pages/backup.py,sha256=IjztvLOUd5dJLLmRBhDTcdLRQCLbczuYljFbcIAHyPs,6792
taninux/gui/pages/bluetooth.py,sha256=kWL8i4n8TCIgSzd5Qq3haxYGvJQ8NRnshlKWwiWToco,6197
taninux/gui/pages/bundles.py,sha256=IAxg0wx-97LP4xguDee1L5oSzPKh8p-sM9b8K-Kp-H4,4774
taninux/gui/pages/dashboard.py,sha256=0f-0Zvq_PoumqE_TJG0LVFIhWmsWxDYaTm6yXHsIL84,9496
taninux/gui/pages/defaultapps.py,sha256=Rg91RtJEnOMi0nPnhe1DGESZB2N63huCDntLO6zwbJk,1481
taninux/gui/pages/display.py,sha256=iovVXT058FkGKuHsvEIOsnV_aShu7RMXEiw5ldeWyxk,7024
taninux/gui/pages/firewall.py,sha256=hJoOJS3KvrwYbVy-PC9R8WjmpebQSH45ToCTC0XWuXM,1530
taninux/gui/pages/hub_home.py,sha256=ijjX5lu4dxh333Qr7ZC_2wRHjce7Pc-lkgo95I2w1jY,4508
taninux/gui/pages/kernel.py,sha256=nrsVAMUkC89VkYwTcD8SVYJrEJ16gZSWgwA5DlruavM,5168
taninux/gui/pages/locale.py,sha256=8DEbHFkbNgBXUNiFX5S28-q-RYO_4-xW8uHGE9Lq5RI,7993
taninux/gui/pages/lock.py,sha256=P7vDu8e07mhMCayLssIErDSuj5eQI8mZsYYCy2XcNto,1185
taninux/gui/pages/maintain.py,sha256=frkP7W5ilnHWY5ODp0wHcNtPbKYP-2PsUIxfqZG_Jrk,14839
taninux/gui/pages/network.py,sha256=PVMIi9T4nHX4I3NcXFLly6jVWPbF1DD6PPXPP4nCfOg,6744
taninux/gui/pages/panel.py,sha256=rTsKPsYV5l86GLYSBYt8AFzRY68QDgqpw4avXFtOwZ4,5930
taninux/gui/pages/power.py,sha256=i3v4QCVB99-yJHpl2q31D2GNOlqv7HW9hkVI-Ur8vTk,6884
taninux/gui/pages/printers.py,sha256=1Smm2SSqztfTJA9vbu426_jAjWAGB4a4ilUnxbc-qs8,5961
taninux/gui/pages/settings.py,sha256=LJgtejider4GlFZM_t4BACxwNvicVOu6WQ6AMdP-zgM,7679
taninux/gui/pages/shares.py,sha256=xJR9yZkjAeauvbWfYSFfMdqTLq-_504Et3Yy2PfZU00,10392
taninux/gui/pages/storage.py,sha256=CZ3uU5cvoAoheIRXdaxAFlJackvFJCRqcPS3mtqpzaQ,2605
taninux/gui/pages/store.py,sha256=D57KOhz6a1vJHwSVokAVCsUf86HMPUF9Vr_Qo9xXKrk,5297
taninux/gui/pages/update.py,sha256=fIo8PoIS5Emd9CEi-melxReZOzlRG7WZJm4krm3o284,6245
taninux/gui/pages/users.py,sha256=ROY6lS273tGYItY2zPyWp94bW5ESBxF90-eIkuyBlIU,7999
taninux/gui/pages/vpn.py,sha256=0I8lGopdhG9T7d25IE-tKTUKbXqZFqoy9que9Tk0RLY,4391
taninux/gui/pages/wallpaper.py,sha256=OwBupe30UoqlIpZoBApLPWc1As1N0aUqeEIxOG7TQ3g,11202
taninux/gui/polkit/ch.gabrielevarano.Taninux.policy,sha256=FWDXgh4YtXJ-oRYh__c3flJoezTG0C9bM9UdRxP2uqM,1366
taninux/gui/process.py,sha256=Oc6Yvm-ADStEUuRt6-ZxB1ATq3RCuQtDetkUexhhdbU,3850
taninux/gui/style.py,sha256=VIhJzOWAAe5nNbLqVD1n7Pm526QwzGLVYhQVmcD7d2c,10179
taninux/gui/window.py,sha256=_2YuuI89Isf4503--9TnlskNJ2tzEwg4wo3r1fT2_VA,5476
taninux/health/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
taninux/health/pillar.py,sha256=TnD-oFRA_0cfEQ-CyV53QQWyj1ZG_LFWDFnIiI3QUp4,258
taninux/health/screen.py,sha256=dh9yRpHwQwMgHW-K7K5hV19VJSKd0pWrASjV0YfRfuM,9293
taninux/kernel/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
taninux/kernel/pillar.py,sha256=Yjd-_xxm2eq4teUfETzE6p-1S20t6swLteflYnSoNG0,256
taninux/kernel/screen.py,sha256=KmksMHH1K8J0Pi6Qu5UfFLX-m21h7F69PJndBj7VFIQ,18564
taninux/maintain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
taninux/maintain/pillar.py,sha256=Br8K6Zo0SejRyR2WSTH-KwegIhuajLbqRGzxUDoKaZM,253
taninux/maintain/scanners/__init__.py,sha256=tHph4nEMgf5-pzAQPp4ujAuoTw2bV_YLhW-wEO0T0bo,3257
taninux/maintain/scanners/_util.py,sha256=WcNBG3uyBpxiSjB31JdMO08lZCit3erDOpopUzvWCd4,1018
taninux/maintain/scanners/aur.py,sha256=tjBrzwvGySpdmNb67ga2GajG5KHtB2F7Mppb8VLg14w,2789
taninux/maintain/scanners/base.py,sha256=QTCyeVo4eSITMoWqXsqmFd2NGfgNEX02ys_gD4tW72I,191
taninux/maintain/scanners/dotfolders.py,sha256=RKZJl-4a9US8NjuoMpnmmViBAJfN0AeyHF7tJz7kwx0,4862
taninux/maintain/scanners/flatpak.py,sha256=y1XNi2rNwjmUcSmUsNuEWgz1GJRdacCz481zVRUIFdE,5742
taninux/maintain/scanners/pacman.py,sha256=-Efq8cKqLX6CNPfB3ziaT4yjr0NxgMaXL0yJQvrt4Ig,7965
taninux/maintain/scanners/system.py,sha256=cO5E-zeKiuquNYQzxNsRt0S-WEUN71vOYqBm8vvcyLI,7945
taninux/maintain/screen.py,sha256=vXkSMAx77xvuOgWLu_IMS8x6XIMnHJViouIIzj84GMI,19193
taninux/maintain/types.py,sha256=eGNDf-daRLUXAktJhO-qSkAoMbiL8pVbNXB7x3GEFBI,1772
taninux/overview/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
taninux/overview/pillar.py,sha256=TuB436V05t_xWXc-qv5dCbAQsTwNwPrUtHWVkitHops,255
taninux/overview/screen.py,sha256=XiVEl3Cj_UfF3793wz4CX4xHlpRYTgpeqzcvMaG562E,12398
taninux/packages/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
taninux/packages/pillar.py,sha256=CdX75cFGRqS32j3Z2rOE12rxfLqM4Hs9IFBmnBerXDg,258
taninux/packages/screen.py,sha256=BzSiAy6aw8MT3krm2j_ZqCoUqo2OgfagNgNwg8LgUTU,10452
taninux/pillars/__init__.py,sha256=HAalRrRLJBgF50GUtq_sJuUbkTp1V7x-Fm1lBKUn15g,794
taninux/pillars/base.py,sha256=cL0yWYaRTEvt5qIU1qZt4Q0kuopSgE8LzRkdx5IEjhQ,878
taninux/suggest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
taninux/suggest/pillar.py,sha256=_fVkV3mBgetx9uxbJMvZJToQk6L0qWEq09co0n91UFk,261
taninux/suggest/rules.py,sha256=7QCvrB4efDI5wUCvGza0KDD2IqXH7C4k_bu0_Ffhx-g,4800
taninux/suggest/screen.py,sha256=qpTSFg2q4PvnaDBCPPkvqg6Ja0TmfwVqhHRMmth0t-Y,5963
taninux/update/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
taninux/update/pillar.py,sha256=Z9cdgUHbs8KRAmvVsXcGjwCcDAcxpMbgGEMmjihdgfA,267
taninux/update/screen.py,sha256=-mEZvhTEx5zpCAWiau0b3I8WvNPC9guqLmf3TE0-l5o,15523
taninux/update/sources/__init__.py,sha256=nRVW3ilMZ5U6aowjEPmKf2-lf2vfCE9HrHYKDNS8S6A,515
taninux/update/sources/aur.py,sha256=42hu33RLvEN8l9EYzPqE-rX0RmCYtCqXWG9nSq2nqqE,1285
taninux/update/sources/flatpak.py,sha256=4F1-QrIv2a3sIJQe7DKA7PQDPCEf_1KpwIPCTgLwldw,2027
taninux/update/sources/fwupd.py,sha256=e3e6g0olO4BJJrI8ith9mgHFKUrdWLptC2YAHqgp3BI,1150
taninux/update/sources/pacman.py,sha256=95cFbaK7xnUGJvxFB5l2fw0Tu3BrU0wCLT75A3vMtps,1065
taninux/update/types.py,sha256=jAxtOjCABznmDBLNkCx5p4Uv7v0LgPw9m56wPj0fVoI,515
../../../bin/thub,sha256=B3XLYTBUObruzl0lW2AWgAKkXpJuJkrSIGN0LtVe2jU,221
../../../bin/tsettings,sha256=nkajkGpJqiDiwrjYXzr9zuxXD5Xo6tAokgCad6kKpiE,231
@@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: hatchling 1.30.1
Root-Is-Purelib: true
Tag: py3-none-any
@@ -0,0 +1,5 @@
[console_scripts]
taninux = taninux.cli:main
taninux-gtk = taninux.gui.app:main
thub = taninux.gui.app:main_hub
tsettings = taninux.gui.app:main_settings
@@ -0,0 +1,232 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
@@ -0,0 +1 @@
__version__ = "0.2.0"
@@ -0,0 +1,3 @@
from taninux.cli import main
raise SystemExit(main())
@@ -0,0 +1,192 @@
"""TANINUX — Top-Level App. Sidebar links, ContentSwitcher rechts."""
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.theme import Theme
from textual.widgets import ContentSwitcher, Footer, Header, Static
from taninux import __version__
from taninux.core.theme import (
BASE1, BASE2, BASE3, BASE4, BASE5,
CHIKYU, FG_ALT, RED, SHINKAI, TAKE, UME, YORU, YUKI,
)
from taninux.pillars import PILLARS, by_key
MOUNTAIN_THEME = Theme(
name="mountain-fuji",
background=YORU,
surface=YORU,
panel=YORU,
foreground=YUKI,
primary=UME,
secondary=SHINKAI,
accent=BASE5,
success=TAKE,
warning=CHIKYU,
error=RED,
dark=True,
variables={
"footer-key-foreground": BASE5,
"footer-key-background": YORU,
"footer-description-foreground": BASE4,
"footer-description-background": YORU,
"footer-foreground": BASE4,
"footer-background": YORU,
"header-background": YORU,
"header-foreground": UME,
"border": BASE2,
"border-blurred": BASE2,
"primary-darken-1": BASE2,
"primary-darken-2": BASE1,
},
)
class Sidebar(Vertical):
"""Linke Pillar-Liste mit dem aktiv markierten Eintrag."""
DEFAULT_CSS = f"""
Sidebar {{
width: 22;
background: {YORU};
padding: 1 1 1 2;
border-right: vkey {BASE2};
}}
Sidebar Static {{
background: {YORU};
}}
Sidebar #s-brand {{
height: 2;
padding-bottom: 1;
color: {UME};
}}
Sidebar #s-version {{
height: 1;
color: {BASE4};
}}
Sidebar .pillar-row {{
height: 2;
padding: 0 0 0 0;
}}
"""
current: reactive[str] = reactive("")
def compose(self) -> ComposeResult:
yield Static(f"[{UME}]▲[/] [{UME}]TANINUX[/]", id="s-brand", markup=True)
yield Static(f"[{BASE4}]v{__version__} · fuji[/]", id="s-version", markup=True)
yield Static("", id="s-gap")
for p in PILLARS:
yield Static("", id=f"row-{p.key}", classes="pillar-row", markup=True)
def watch_current(self, _old: str, new: str) -> None:
self._refresh_view()
def on_mount(self) -> None:
self._refresh_view()
def _refresh_view(self) -> None:
for p in PILLARS:
row = self.query_one(f"#row-{p.key}", Static)
is_active = (self.current == p.key)
key_color = UME if is_active else BASE4
label_color = YUKI if is_active else BASE5
tagline_color = BASE5 if is_active else BASE4
marker = "" if is_active else " "
row.update(
f"[{key_color}]{marker} {p.icon} {p.label}[/]\n"
f"[{tagline_color}] {p.tagline}[/]"
)
class TaninuxApp(App):
"""TANINUX — Linux maintenance & management TUI."""
CSS = f"""
Screen {{
background: {YORU};
color: {YUKI};
layout: vertical;
}}
Header {{
background: {YORU};
color: {UME};
}}
HeaderIcon {{
display: none;
}}
#root {{
height: 1fr;
background: {YORU};
}}
#content {{
width: 1fr;
background: {YORU};
}}
Footer {{
background: {YORU};
color: {BASE4};
}}
FooterKey {{
background: {YORU};
color: {BASE4};
}}
FooterKey > .footer-key--key {{
background: {YORU};
color: {BASE5};
}}
FooterKey > .footer-key--description {{
background: {YORU};
color: {BASE4};
}}
"""
BINDINGS = [
Binding("o", "switch('o')", "overview"),
Binding("p", "switch('p')", "packages"),
Binding("m", "switch('m')", "maintain"),
Binding("u", "switch('u')", "update"),
Binding("b", "switch('b')", "bundles"),
Binding("s", "switch('s')", "suggest"),
Binding("f", "switch('f')", "flatpak"),
Binding("k", "switch('k')", "kernel"),
Binding("h", "switch('h')", "health"),
Binding("q", "quit", "quit"),
]
TITLE = "▲ TANINUX"
SUB_TITLE = "fuji · linux maintenance"
def __init__(self) -> None:
super().__init__()
self.register_theme(MOUNTAIN_THEME)
self.theme = "mountain-fuji"
self.current_pillar: str = PILLARS[0].key
def compose(self) -> ComposeResult:
yield Header(show_clock=False, icon="")
with Horizontal(id="root"):
yield Sidebar(id="sidebar")
with ContentSwitcher(initial=PILLARS[0].key, id="content"):
for p in PILLARS:
yield p.factory(id=p.key)
yield Footer()
def on_mount(self) -> None:
sidebar = self.query_one(Sidebar)
sidebar.current = self.current_pillar
def action_switch(self, key: str) -> None:
if by_key(key) is None:
return
self.current_pillar = key
self.query_one("#content", ContentSwitcher).current = key
self.query_one(Sidebar).current = key
def run() -> None:
TaninuxApp().run()
@@ -0,0 +1,148 @@
"""Eingebaute Paket-Bundles — deklarative Paket-Sets nach Use-Case.
Built-ins können nicht editiert werden; User-Bundles in
~/.local/share/taninux/bundles/<id>.toml ergänzen sie (TODO)."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Bundle:
id: str
label: str
tagline: str
packages: tuple[str, ...]
tags: tuple[str, ...] = ()
BUILTIN_BUNDLES: list[Bundle] = [
Bundle(
id="cli-essentials",
label="CLI Essentials",
tagline="modern terminal tools",
packages=(
"bat", "eza", "fd", "ripgrep", "fzf", "zoxide",
"dust", "bottom", "starship", "tldr",
),
tags=("cli",),
),
Bundle(
id="rust-dev",
label="Rust Development",
tagline="rust toolchain + LSP + qol",
packages=(
"rust", "rust-analyzer", "sccache", "cargo-audit",
"cargo-watch", "cargo-edit",
),
tags=("dev", "rust"),
),
Bundle(
id="python-dev",
label="Python Development",
tagline="python + uv + ruff + ipython",
packages=(
"python", "uv", "ruff", "ipython",
"python-pip", "python-lsp-server",
),
tags=("dev", "python"),
),
Bundle(
id="web-dev",
label="Web Development",
tagline="node + typescript + pnpm",
packages=(
"nodejs", "npm", "pnpm", "typescript",
"yarn",
),
tags=("dev", "web"),
),
Bundle(
id="go-dev",
label="Go Development",
tagline="go toolchain + LSP",
packages=(
"go", "gopls", "delve",
),
tags=("dev", "go"),
),
Bundle(
id="gaming",
label="Gaming",
tagline="steam + qol for proton/wine games",
packages=(
"steam", "gamemode", "mangohud", "lutris",
"wine", "wine-mono", "wine-gecko",
"winetricks", "vulkan-tools",
),
tags=("gaming",),
),
Bundle(
id="multimedia",
label="Multimedia",
tagline="audio/video player, editor, recorder",
packages=(
"mpv", "ffmpeg", "obs-studio",
"kdenlive", "audacity", "imagemagick",
),
tags=("multimedia",),
),
Bundle(
id="niri",
label="niri Desktop",
tagline="niri scrolling compositor + eww bar/dock + launcher (wayland)",
packages=(
"niri", "eww", "swaylock", "swayidle", "wlsunset",
"waypaper", "swaybg", "wl-clipboard", "grim", "slurp",
"kitty",
),
tags=("wm", "wayland"),
),
Bundle(
id="security",
label="Security Tooling",
tagline="audit + scan + monitoring",
packages=(
"nmap", "arch-audit",
"rkhunter", "lynis", "wireshark-qt",
),
tags=("security",),
),
Bundle(
id="containers",
label="Containers",
tagline="docker + buildah + podman + compose",
packages=(
"podman", "buildah", "skopeo",
"podman-compose", "docker", "docker-compose",
),
tags=("dev", "containers"),
),
Bundle(
id="virtualization",
label="Virtualization",
tagline="KVM/QEMU/libvirt stack",
packages=(
"qemu-full", "libvirt", "virt-manager",
"edk2-ovmf", "dnsmasq", "bridge-utils",
),
tags=("virt",),
),
Bundle(
id="creative",
label="Creative Tools",
tagline="design + 3d + illustration",
packages=(
"blender", "krita", "inkscape", "gimp",
"darktable",
),
tags=("creative",),
),
]
def by_id(bid: str) -> Bundle | None:
for b in BUILTIN_BUNDLES:
if b.id == bid:
return b
return None
@@ -0,0 +1,67 @@
"""User-Bundle Loader.
Lädt zusätzlich zu den BUILTIN_BUNDLES alle TOML-Files aus
~/.local/share/taninux/bundles/<id>.toml.
Format:
label = "My Setup"
tagline = "alles was ich nach einer neuinstallation brauche"
tags = ["personal"]
packages = [
"neovim",
"git",
"tmux",
]
"""
from __future__ import annotations
import tomllib
from pathlib import Path
from taninux.bundles.builtins import Bundle, BUILTIN_BUNDLES
from taninux.core.paths import bundles_dir
def load_user_bundles() -> list[Bundle]:
bundles_path = bundles_dir()
out: list[Bundle] = []
for p in sorted(bundles_path.glob("*.toml")):
try:
data = tomllib.loads(p.read_text())
except (OSError, tomllib.TOMLDecodeError):
continue
if "packages" not in data:
continue
out.append(Bundle(
id=p.stem,
label=data.get("label") or p.stem,
tagline=data.get("tagline", ""),
packages=tuple(data["packages"]),
tags=tuple(data.get("tags", ())),
))
return out
def all_bundles() -> list[Bundle]:
return [*BUILTIN_BUNDLES, *load_user_bundles()]
def save_user_bundle(bundle_id: str, label: str, tagline: str,
packages: list[str], tags: list[str] | None = None) -> Path:
"""Schreibt ein User-Bundle als TOML."""
safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in bundle_id)
target = bundles_dir() / f"{safe_id}.toml"
lines = [
f'label = "{label}"',
f'tagline = "{tagline}"',
]
if tags:
tags_str = ", ".join(f'"{t}"' for t in tags)
lines.append(f"tags = [{tags_str}]")
lines.append("packages = [")
for p in packages:
lines.append(f' "{p}",')
lines.append("]")
target.write_text("\n".join(lines) + "\n")
return target
@@ -0,0 +1,13 @@
from __future__ import annotations
from taninux.bundles.screen import BundlesView
from taninux.pillars.base import Pillar
BUNDLES = Pillar(
key="b",
label="bundles",
tagline="deklarative paket-sets",
icon="",
factory=BundlesView,
)
@@ -0,0 +1,247 @@
"""Bundles-Pillar — deklarative Paket-Sets installieren/checken."""
from __future__ import annotations
from dataclasses import dataclass
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import VerticalScroll
from textual.widgets import DataTable, Static
from taninux.bundles.builtins import Bundle
from taninux.bundles.loader import all_bundles
from taninux.core import pacman as pac
from taninux.core import sysinfo
from taninux.core import theme as T
from taninux.core.confirm import ConfirmModal
from taninux.core.runner import Command, run_interactive
@dataclass
class BundleStatus:
bundle: Bundle
installed: list[str]
missing: list[str]
@property
def state(self) -> str:
if not self.installed:
return "none"
if not self.missing:
return "satisfied"
return "partial"
STATE_GLYPH = {
"satisfied": ("", T.TAKE),
"partial": ("", T.CHIKYU),
"none": ("", T.BASE4),
}
STATE_LABEL = {
"satisfied": "vollständig",
"partial": "teilweise",
"none": "fehlt",
}
def status_for(bundle: Bundle, installed: frozenset[str]) -> BundleStatus:
inst = [p for p in bundle.packages if p in installed]
miss = [p for p in bundle.packages if p not in installed]
return BundleStatus(bundle=bundle, installed=inst, missing=miss)
class BundlesView(VerticalScroll):
DEFAULT_CSS = f"""
BundlesView {{
background: {T.YORU};
padding: 1 2;
scrollbar-color: {T.BASE2} {T.YORU};
scrollbar-background: {T.YORU};
scrollbar-corner-color: {T.YORU};
scrollbar-size-vertical: 1;
}}
BundlesView Static {{
background: {T.YORU};
margin-bottom: 1;
}}
BundlesView #b-table {{
height: auto;
background: {T.YORU};
color: {T.FG_ALT};
border: none;
overflow-x: hidden;
}}
BundlesView #b-table:focus {{
border: none;
}}
BundlesView DataTable > .datatable--header {{
background: {T.YORU};
color: {T.SHINKAI};
}}
BundlesView DataTable > .datatable--cursor {{
background: {T.BASE1};
color: {T.YUKI};
}}
BundlesView DataTable > .datatable--even-row {{
background: {T.YORU};
}}
BundlesView DataTable > .datatable--odd-row {{
background: {T.YORU};
}}
"""
BINDINGS = [
Binding("r", "rescan", "rescan"),
Binding("i", "install", "fehlende installieren"),
Binding("j", "cursor_down", "down", show=False),
Binding("k", "cursor_up", "up", show=False),
]
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.statuses: list[BundleStatus] = []
def compose(self) -> ComposeResult:
yield Static("", id="b-intro", markup=True)
yield DataTable(id="b-table", cursor_type="row", zebra_stripes=False)
yield Static("", id="b-detail", markup=True)
yield Static("", id="b-flash", markup=True)
def on_mount(self) -> None:
t = self.query_one("#b-table", DataTable)
t.add_column("", width=2, key="glyph")
t.add_column("bundle", width=22, key="name")
t.add_column("status", width=14, key="status")
t.add_column("fortschritt", width=18, key="progress")
t.add_column("beschreibung", key="desc")
self.action_rescan()
def action_rescan(self) -> None:
pac.invalidate()
installed = pac.installed_packages()
self.statuses = [status_for(b, installed) for b in all_bundles()]
# Sortierung: partial first (in-progress), dann none (zum vorschlagen), dann satisfied
order = {"partial": 0, "none": 1, "satisfied": 2}
self.statuses.sort(key=lambda s: (order[s.state], s.bundle.label))
self._refresh_view()
self._flash(f"{len(self.statuses)} bundles geprüft")
def action_install(self) -> None:
cur = self._current()
if cur is None:
return
if not cur.missing:
self._flash(f"{cur.bundle.label} bereits vollständig", color=T.TAKE)
return
body = (
f"bundle [{cur.bundle.label}] vervollständigen?\n\n"
f"fehlende pakete ({len(cur.missing)}):\n"
f" {', '.join(cur.missing)}"
)
self.app.push_screen(
ConfirmModal(f"install {cur.bundle.id}?", body, danger=False),
lambda ok: self._after_install(ok, cur),
)
def _after_install(self, ok: bool | None, cur: BundleStatus) -> None:
if not ok:
self._flash("abgebrochen")
return
helper = "paru" if sysinfo.has_binary("paru") else (
"yay" if sysinfo.has_binary("yay") else "sudo pacman"
)
argv = helper.split() + ["-S", "--needed", *cur.missing]
codes = run_interactive(
self.app,
[Command(
label=f"install {len(cur.missing)} pakete für [{cur.bundle.label}]",
argv=argv,
)],
)
success = codes and codes[0] == 0
self._flash(
f"{cur.bundle.label} installiert" if success else "install fehlgeschlagen",
color=T.TAKE if success else T.RED,
)
self.action_rescan()
def action_cursor_down(self) -> None:
self.query_one("#b-table", DataTable).action_cursor_down()
def action_cursor_up(self) -> None:
self.query_one("#b-table", DataTable).action_cursor_up()
def _current(self) -> BundleStatus | None:
t = self.query_one("#b-table", DataTable)
r = t.cursor_row
if r is None or r < 0 or r >= len(self.statuses):
return None
return self.statuses[r]
def _flash(self, msg: str, color: str | None = None) -> None:
color = color or T.BASE4
self.query_one("#b-flash", Static).update(f"[{color}]{msg}[/]")
def _refresh_view(self) -> None:
total = len(self.statuses)
sat = sum(1 for s in self.statuses if s.state == "satisfied")
par = sum(1 for s in self.statuses if s.state == "partial")
non = sum(1 for s in self.statuses if s.state == "none")
intro = (
f"[{T.UME}]◆ bundles[/]\n"
f"[{T.BASE5}]deklarative paket-sets · "
f"[{T.TAKE}]●[/] {sat} vollständig "
f"[{T.CHIKYU}]◐[/] {par} teilweise "
f"[{T.BASE4}]○[/] {non} fehlend[/]\n"
f"[{T.BASE5}]`i` installiert die fehlenden pakete des ausgewählten bundles[/]"
)
self.query_one("#b-intro", Static).update(intro)
t = self.query_one("#b-table", DataTable)
t.clear()
for s in self.statuses:
glyph, color = STATE_GLYPH[s.state]
label = STATE_LABEL[s.state]
total_p = len(s.bundle.packages)
done = len(s.installed)
# Progress bar als unicode block-chars
ratio = done / total_p if total_p else 0
blocks = int(ratio * 10)
bar = "" * blocks + "" * (10 - blocks)
t.add_row(
f"[{color}]{glyph}[/]",
f"[{T.YUKI}]{s.bundle.label}[/]",
f"[{color}]{label}[/]",
f"[{color}]{bar}[/] [{T.BASE5}]{done}/{total_p}[/]",
f"[{T.BASE5}]{s.bundle.tagline}[/]",
)
# Detail: zeige fehlende Pakete des aktuellen Bundles
if self.statuses:
self._render_detail(self.statuses[0])
def _render_detail(self, s: BundleStatus) -> None:
lines = [
f"[{T.SHINKAI}]{s.bundle.label}[/] [{T.BASE5}]{s.bundle.tagline}[/]",
]
if s.bundle.tags:
lines.append(f"[{T.BASE4}]tags:[/] {', '.join(s.bundle.tags)}")
lines += [
"",
f"[{T.SHINKAI}]installiert ({len(s.installed)})[/]",
]
for p in s.installed:
lines.append(f" [{T.TAKE}]●[/] [{T.YUKI}]{p}[/]")
if s.missing:
lines += [
"",
f"[{T.SHINKAI}]fehlt ({len(s.missing)})[/]",
]
for p in s.missing:
lines.append(f" [{T.BASE4}]○[/] [{T.BASE5}]{p}[/]")
self.query_one("#b-detail", Static).update("\n".join(lines))
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
if event.cursor_row is None or event.cursor_row >= len(self.statuses):
return
self._render_detail(self.statuses[event.cursor_row])
@@ -0,0 +1,837 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def _human_size(n: int) -> str:
f = float(n)
for unit in ("B", "K", "M", "G", "T"):
if f < 1024 or unit == "T":
return f"{f:.0f}{unit}" if unit == "B" else f"{f:.1f}{unit}"
f /= 1024
return f"{f:.1f}T"
def _format_days(d: int) -> str:
if d == 0:
return "heute"
if d < 30:
return f"{d}d"
if d < 365:
return f"{d // 30}mo"
return f"{d // 365}y"
def _console():
from rich.console import Console
return Console()
# ---------------------------------------------------------------- scan
def _scan(category: str) -> int:
from rich.table import Table
from taninux.maintain.scanners import SCANNERS, by_id
from taninux.core import theme as T
from taninux.maintain.types import (
CLASS_COLOR, CLASS_GLYPH, CLASS_LABEL, Classification,
)
console = _console()
specs = SCANNERS if category == "all" else [s for s in [by_id(category)] if s]
if not specs:
console.print(f" [{T.RED}]unknown category: {category}[/]")
return 2
home = str(Path.home())
for spec in specs:
scanner = spec.factory()
findings = scanner.scan()
reclaim = sum(
f.size_bytes for f in findings
if f.classification in (Classification.SAFE, Classification.LIKELY_ORPHAN)
)
console.print()
console.print(
f" [{T.UME}]▲ {spec.label}[/] "
f"[{T.YUKI}]{len(findings)}[/] [{T.BASE5}]einträge[/] "
f"[{T.BASE3}]·[/] "
f"[{T.TAKE}]{_human_size(reclaim)}[/] [{T.BASE5}]potentiell wegwerfbar[/]"
)
console.print()
if not findings:
console.print(f" [{T.BASE4}]keine einträge.[/]")
continue
table = Table(
show_header=True,
header_style=T.SHINKAI,
border_style=T.BASE2,
pad_edge=False,
box=None,
)
table.add_column("", width=2)
table.add_column("pfad", no_wrap=True)
table.add_column("grösse", justify="right")
table.add_column("idle", justify="right")
table.add_column("status")
for f in findings:
color = CLASS_COLOR[f.classification]
s = str(f.path)
path = s[6:] if s.startswith("pkg://") else s.replace(home, "~", 1)
size = "" if not f.size_bytes else _human_size(f.size_bytes)
days = "" if f.last_access_days is None else _format_days(f.last_access_days)
table.add_row(
f"[{color}]{CLASS_GLYPH[f.classification]}[/]",
f"[{T.FG_ALT}]{path}[/]",
f"[{T.CHIKYU}]{size}[/]" if f.size_bytes else f"[{T.BASE3}]—[/]",
f"[{T.BASE5}]{days}[/]",
f"[{color}]{CLASS_LABEL[f.classification]}[/]",
)
console.print(table)
return 0
# ---------------------------------------------------------------- trash
def _trash_list() -> int:
from rich.table import Table
from taninux.core.trash import list_trash
from taninux.core import theme as T
console = _console()
entries = list_trash()
if not entries:
console.print(f" [{T.BASE4}]trash ist leer.[/]")
return 0
total = sum(e.size_bytes for e in entries)
console.print()
console.print(
f" [{T.UME}]▲ trash[/] "
f"[{T.YUKI}]{len(entries)}[/] [{T.BASE5}]einträge[/] "
f"[{T.BASE3}]·[/] "
f"[{T.CHIKYU}]{_human_size(total)}[/]"
)
console.print()
table = Table(
show_header=True,
header_style=T.SHINKAI,
border_style=T.BASE2,
pad_edge=False,
box=None,
)
table.add_column("id")
table.add_column("origin", no_wrap=True)
table.add_column("grösse", justify="right")
table.add_column("gelöscht am")
home = str(Path.home())
for e in entries:
origin = str(e.original).replace(home, "~", 1)
table.add_row(
f"[{T.BASE5}]{e.id}[/]",
f"[{T.FG_ALT}]{origin}[/]",
f"[{T.CHIKYU}]{_human_size(e.size_bytes)}[/]",
f"[{T.BASE5}]{e.trashed_at}[/]",
)
console.print(table)
return 0
def _trash_restore(slot_id: str) -> int:
from taninux.core.trash import restore
from taninux.core import theme as T
console = _console()
try:
original = restore(slot_id)
except FileNotFoundError:
console.print(f" [{T.RED}]kein eintrag: {slot_id}[/]")
return 2
except FileExistsError as e:
console.print(f" [{T.RED}]{e}[/]")
return 2
console.print(f" [{T.TAKE}]wiederhergestellt:[/] [{T.YUKI}]{original}[/]")
return 0
def _trash_purge(slot_id: str | None) -> int:
from taninux.core.trash import list_trash, purge
from taninux.core import theme as T
console = _console()
if slot_id:
try:
purge(slot_id)
except FileNotFoundError:
console.print(f" [{T.RED}]kein eintrag: {slot_id}[/]")
return 2
console.print(f" [{T.BASE5}]gelöscht:[/] [{T.YUKI}]{slot_id}[/]")
return 0
entries = list_trash()
if not entries:
console.print(f" [{T.BASE4}]trash bereits leer.[/]")
return 0
total = sum(e.size_bytes for e in entries)
console.print(
f" [{T.RED}]purge {len(entries)} einträge[/] "
f"[{T.BASE4}]({_human_size(total)})?[/] "
f"[{T.BASE5}]bestätige mit:[/] taninux trash purge --all --yes"
)
return 1
def _trash_purge_all(confirmed: bool) -> int:
from taninux.core.trash import list_trash, purge
from taninux.core import theme as T
console = _console()
entries = list_trash()
if not entries:
console.print(f" [{T.BASE4}]trash bereits leer.[/]")
return 0
if not confirmed:
return _trash_purge(None)
for e in entries:
purge(e.id)
console.print(f" [{T.BASE5}]{len(entries)} einträge endgültig gelöscht.[/]")
return 0
# ---------------------------------------------------------------- update
def _update_list() -> int:
from collections import Counter
from rich.table import Table
from taninux.core import theme as T
from taninux.update.sources import fetch_all
console = _console()
console.print(f"\n [{T.BASE4}]scanne pacman + AUR + flatpak + fwupd…[/]")
items = fetch_all()
if not items:
console.print(f"\n [{T.TAKE}]system ist aktuell.[/]\n")
return 0
total_size = sum(i.size_bytes for i in items)
counts = Counter(i.source for i in items)
console.print()
console.print(
f" [{T.UME}]↻ updates[/] "
f"[{T.YUKI}]{len(items)}[/] [{T.BASE5}]verfügbar[/]"
+ (f" [{T.BASE3}]·[/] [{T.CHIKYU}]{_human_size(total_size)}[/]" if total_size else "")
)
parts = []
for src in ("pacman", "aur", "flatpak", "fwupd"):
n = counts.get(src, 0)
if n:
parts.append(f"[{T.BASE5}]{src} {n}[/]")
if parts:
console.print(f" {' · '.join(parts)}")
console.print()
order = {"pacman": 0, "aur": 1, "flatpak": 2, "fwupd": 3}
items.sort(key=lambda i: (order.get(i.source, 9), i.name))
table = Table(
show_header=True,
header_style=T.SHINKAI,
border_style=T.BASE2,
pad_edge=False,
box=None,
)
table.add_column("quelle", width=9)
table.add_column("paket")
table.add_column("aktuell")
table.add_column("", width=1)
table.add_column("neu")
table.add_column("grösse", justify="right")
for i in items:
size = "" if not i.size_bytes else _human_size(i.size_bytes)
table.add_row(
f"[{T.BASE5}]{i.source}[/]",
f"[{T.YUKI}]{i.name}[/]",
f"[{T.BASE4}]{i.current_version}[/]",
f"[{T.BASE3}]→[/]",
f"[{T.TAKE}]{i.new_version}[/]",
f"[{T.CHIKYU}]{size}[/]" if i.size_bytes else f"[{T.BASE3}]—[/]",
)
console.print(table)
console.print()
cmds = []
if counts.get("pacman") or counts.get("aur"):
cmds.append("paru -Syu")
if counts.get("flatpak"):
cmds.append("flatpak update -y")
if counts.get("fwupd"):
cmds.append("sudo fwupdmgr update")
if cmds:
console.print(f" [{T.SHINKAI}]ausführen:[/]")
for c in cmds:
console.print(f" [{T.CHIKYU}]{c}[/]")
console.print()
return 0
def _update_execute() -> int:
"""Headless: führt alle pending updates aus mit --noconfirm."""
import subprocess
from taninux.core import sysinfo
from taninux.core import theme as T
from taninux.update.sources import fetch_all
from collections import Counter
console = _console()
console.print(f"\n [{T.UME}]↻ scanne updates…[/]")
items = fetch_all()
if not items:
console.print(f" [{T.TAKE}]system ist aktuell.[/]\n")
return 0
counts = Counter(i.source for i in items)
console.print(f" [{T.YUKI}]{len(items)}[/] [{T.BASE5}]updates verfügbar[/]")
console.print()
cmds: list[list[str]] = []
if counts.get("pacman") or counts.get("aur"):
helper = "paru" if sysinfo.has_binary("paru") else (
"yay" if sysinfo.has_binary("yay") else None
)
if helper:
cmds.append([
helper, "-Syu",
"--noconfirm", "--sudoloop",
"--skipreview", "--removemake", "--cleanafter",
"--batchinstall",
])
elif counts.get("pacman"):
cmds.append(["sudo", "pacman", "-Syu", "--noconfirm"])
if counts.get("flatpak"):
cmds.append(["flatpak", "update", "--noninteractive"])
if counts.get("fwupd"):
cmds.append(["sudo", "fwupdmgr", "update", "--assume-yes", "--no-reboot-check"])
rc_total = 0
for argv in cmds:
console.print(f" [{T.SHINKAI}]$ {' '.join(argv)}[/]")
rc = subprocess.run(argv, check=False).returncode
if rc != 0:
console.print(f" [{T.RED}]exit {rc}[/]\n")
rc_total = rc
else:
console.print(f" [{T.TAKE}]ok[/]\n")
return rc_total
# ---------------------------------------------------------------- health
def _health_summary() -> int:
from taninux.core import sysinfo
from taninux.core import theme as T
console = _console()
mem = sysinfo.memory()
total = mem.get("MemTotal", 0)
avail = mem.get("MemAvailable", 0)
used = total - avail
la = sysinfo.load_avg()
ncpu = sysinfo.cpu_count()
failed = sysinfo.systemctl_failed()
errs_today = sysinfo.journal_errors_today()
boot = sysinfo.boot_time_seconds()
console.print()
console.print(f" [{T.UME}]◈ {sysinfo.hostname()}[/] [{T.BASE5}]{sysinfo.distro_pretty()} · {sysinfo.kernel_release()}[/]")
console.print()
console.print(f" [{T.SHINKAI}]uptime[/] [{T.YUKI}]{int(sysinfo.uptime_seconds() // 3600)}h[/]")
if boot:
console.print(f" [{T.SHINKAI}]boot[/] [{T.YUKI}]{boot:.1f}s[/]")
console.print(f" [{T.SHINKAI}]load[/] [{T.YUKI}]{la[0]:.2f}[/] [{T.BASE5}]{la[1]:.2f} {la[2]:.2f} ({ncpu} cpus)[/]")
if total:
console.print(f" [{T.SHINKAI}]memory[/] [{T.YUKI}]{used/1024**3:.1f}G[/] [{T.BASE5}]/[/] [{T.YUKI}]{total/1024**3:.1f}G[/] [{T.BASE4}]({used*100/total:.0f}%)[/]")
console.print()
for g in sysinfo.gpus():
console.print(f" [{T.SHINKAI}]gpu[/] [{T.YUKI}]{g.vendor}[/] [{T.BASE5}]·[/] [{T.BASE5}]{g.model[:55]}[/] [{T.CHIKYU}]{g.driver or ''}[/]")
console.print(f" [{T.SHINKAI}]audio[/] [{T.YUKI}]{sysinfo.audio_server()}[/]")
console.print()
failed_color = T.TAKE if not failed else T.RED
console.print(f" [{failed_color}]●[/] [{T.YUKI}]{len(failed)}[/] [{T.BASE5}]failed services[/]")
for u in failed[:5]:
console.print(f" [{T.RED}]{u}[/]")
err_color = T.TAKE if errs_today == 0 else (T.CHIKYU if errs_today < 20 else T.RED)
err_label = f"{errs_today}" if errs_today >= 0 else "?"
console.print(f" [{err_color}]●[/] [{T.YUKI}]{err_label}[/] [{T.BASE5}]errors heute (journalctl -p 3)[/]")
console.print()
return 0
# ---------------------------------------------------------------- suggest
def _suggest_list() -> int:
import subprocess
from rich.table import Table
from taninux.core import theme as T
from taninux.suggest.rules import all_suggestions
console = _console()
installed = {
l for l in subprocess.run(
["pacman", "-Qq"], capture_output=True, text=True, check=False,
).stdout.splitlines() if l
}
suggestions = sorted(
all_suggestions(installed),
key=lambda s: (s.priority, s.category),
)
console.print()
console.print(
f" [{T.UME}]✦ suggest[/] "
f"[{T.YUKI}]{len(suggestions)}[/] [{T.BASE5}]empfehlungen[/]"
)
console.print()
if not suggestions:
console.print(f" [{T.TAKE}]nichts zu empfehlen — solides setup.[/]\n")
return 0
table = Table(
show_header=True,
header_style=T.SHINKAI,
border_style=T.BASE2,
pad_edge=False,
box=None,
)
table.add_column("kat", width=8)
table.add_column("anlass")
table.add_column("pakete")
table.add_column("pri", width=6)
for s in suggestions:
pri_color = {1: T.RED, 2: T.CHIKYU, 3: T.BASE5}.get(s.priority, T.BASE5)
pri_label = {1: "hoch", 2: "mittel", 3: "nice"}.get(s.priority, str(s.priority))
table.add_row(
f"[{T.SHINKAI}]{s.category}[/]",
f"[{T.YUKI}]{s.reason}[/]",
f"[{T.BASE5}]{', '.join(s.packages)}[/]",
f"[{pri_color}]{pri_label}[/]",
)
console.print(table)
console.print()
return 0
# ---------------------------------------------------------------- snapshot
def _snapshot_save(label: str) -> int:
from taninux.core import snapshot as snap
from taninux.core import theme as T
console = _console()
s = snap.take_snapshot(label=label)
console.print()
console.print(
f" [{T.TAKE}]snapshot[/] [{T.YUKI}]{s.id}[/] "
f"[{T.BASE5}]({s.label})[/]"
)
console.print(
f" [{T.BASE5}]explicit[/] [{T.YUKI}]{len(s.explicit)}[/] "
f"[{T.BASE5}]aur[/] [{T.YUKI}]{len(s.aur)}[/] "
f"[{T.BASE5}]deps[/] [{T.YUKI}]{len(s.deps)}[/]"
)
console.print(f" [{T.BASE4}]→ {s.path}[/]")
console.print()
return 0
def _snapshot_list() -> int:
from rich.table import Table
from taninux.core import snapshot as snap
from taninux.core import theme as T
console = _console()
snaps = snap.list_snapshots()
if not snaps:
console.print(f" [{T.BASE4}]keine snapshots — speichere mit:[/] taninux snapshot save")
return 0
table = Table(
show_header=True,
header_style=T.SHINKAI,
border_style=T.BASE2,
pad_edge=False,
box=None,
)
table.add_column("id")
table.add_column("zeitpunkt")
table.add_column("label")
table.add_column("explicit", justify="right")
table.add_column("aur", justify="right")
table.add_column("deps", justify="right")
for s in snaps:
table.add_row(
f"[{T.YUKI}]{s.id}[/]",
f"[{T.BASE5}]{s.timestamp}[/]",
f"[{T.BASE5}]{s.label}[/]",
f"[{T.CHIKYU}]{len(s.explicit)}[/]",
f"[{T.CHIKYU}]{len(s.aur)}[/]",
f"[{T.BASE4}]{len(s.deps)}[/]",
)
console.print()
console.print(table)
console.print()
return 0
def _snapshot_diff(from_id: str | None, to_id: str | None) -> int:
from taninux.core import snapshot as snap
from taninux.core import theme as T
console = _console()
if from_id is None:
old = snap.latest()
if old is None:
console.print(f" [{T.BASE4}]keine snapshots vorhanden — speichere mit:[/] taninux snapshot save")
return 1
else:
old = snap.load(from_id)
if old is None:
console.print(f" [{T.RED}]kein snapshot mit id: {from_id}[/]")
return 2
if to_id is None:
new = snap.current_snapshot()
else:
new_loaded = snap.load(to_id)
if new_loaded is None:
console.print(f" [{T.RED}]kein snapshot mit id: {to_id}[/]")
return 2
new = new_loaded
d = snap.diff(old, new)
console.print()
console.print(
f" [{T.UME}]drift[/] "
f"[{T.BASE5}]{old.id} ({old.label})[/] [{T.BASE3}]→[/] "
f"[{T.BASE5}]{new.id} ({new.label})[/]"
)
console.print()
total_added = len(d.added_explicit) + len(d.added_aur) + len(d.added_deps)
total_removed = len(d.removed_explicit) + len(d.removed_aur) + len(d.removed_deps)
if total_added == 0 and total_removed == 0:
console.print(f" [{T.TAKE}]keine änderungen seit {old.timestamp}.[/]")
console.print()
return 0
def render_block(title: str, added: list[str], removed: list[str]) -> None:
if not added and not removed:
return
console.print(f" [{T.SHINKAI}]{title}[/]")
for p in added:
console.print(f" [{T.TAKE}]+[/] [{T.YUKI}]{p}[/]")
for p in removed:
console.print(f" [{T.RED}]-[/] [{T.BASE5}]{p}[/]")
console.print()
render_block("explicit", d.added_explicit, d.removed_explicit)
render_block("aur", d.added_aur, d.removed_aur)
render_block("deps", d.added_deps, d.removed_deps)
console.print(
f" [{T.BASE4}]gesamt:[/] "
f"[{T.TAKE}]+{total_added}[/] "
f"[{T.RED}]-{total_removed}[/]"
)
console.print()
return 0
# ---------------------------------------------------------------- bundles
def _bundles_list() -> int:
from rich.table import Table
from taninux.bundles.loader import all_bundles
from taninux.core import pacman as pac
from taninux.core import theme as T
console = _console()
installed = pac.installed_packages()
bundles = all_bundles()
console.print()
console.print(f" [{T.UME}]◆ bundles[/] [{T.YUKI}]{len(bundles)}[/] [{T.BASE5}]bundles[/]")
console.print()
table = Table(
show_header=True,
header_style=T.SHINKAI,
border_style=T.BASE2,
pad_edge=False,
box=None,
)
table.add_column("", width=2)
table.add_column("id", width=22)
table.add_column("status")
table.add_column("fortschritt")
table.add_column("tagline")
for b in bundles:
inst = sum(1 for p in b.packages if p in installed)
total = len(b.packages)
state = "satisfied" if inst == total else ("partial" if inst else "none")
glyph, color = {
"satisfied": ("", T.TAKE),
"partial": ("", T.CHIKYU),
"none": ("", T.BASE4),
}[state]
ratio = inst / total if total else 0
bar = "" * int(ratio * 10) + "" * (10 - int(ratio * 10))
table.add_row(
f"[{color}]{glyph}[/]",
f"[{T.YUKI}]{b.id}[/]",
f"[{color}]{state}[/]",
f"[{color}]{bar}[/] [{T.BASE5}]{inst}/{total}[/]",
f"[{T.BASE5}]{b.tagline}[/]",
)
console.print(table)
console.print()
return 0
def _bundles_export(bundle_id: str, label: str | None, tagline: str) -> int:
import subprocess
from taninux.bundles.loader import save_user_bundle
from taninux.core import theme as T
console = _console()
try:
r = subprocess.run(
["pacman", "-Qenq"],
capture_output=True, text=True, check=False, timeout=15,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
console.print(f" [{T.RED}]pacman nicht verfügbar[/]")
return 2
pkgs = sorted(l.strip() for l in r.stdout.splitlines() if l.strip())
if not pkgs:
console.print(f" [{T.RED}]keine pakete gefunden[/]")
return 2
target = save_user_bundle(
bundle_id=bundle_id,
label=label or bundle_id,
tagline=tagline,
packages=pkgs,
tags=["exported"],
)
console.print()
console.print(
f" [{T.TAKE}]exportiert:[/] [{T.YUKI}]{target}[/] "
f"[{T.BASE5}]{len(pkgs)} pakete[/]"
)
console.print(
f" [{T.BASE4}]restore via:[/] taninux bundles install {bundle_id}"
f" [{T.BASE4}](in TUI: bundles tab → cursor auf '{bundle_id}' → i)[/]"
)
console.print()
return 0
# ---------------------------------------------------------------- whitelist
def _whitelist_list() -> int:
from taninux.core.state import State
from taninux.core import theme as T
console = _console()
state = State.load()
if not state.whitelist and not state.ignored:
console.print(f" [{T.BASE4}]whitelist und ignore-list sind leer.[/]")
return 0
home = str(Path.home())
if state.whitelist:
console.print(f"\n [{T.SHINKAI}]whitelist[/]")
for p in sorted(state.whitelist):
console.print(f" [{T.YUKI}]{str(p).replace(home, '~', 1)}[/]")
if state.ignored:
console.print(f"\n [{T.BASE4}]ignored[/]")
for p in sorted(state.ignored):
console.print(f" [{T.BASE5}]{str(p).replace(home, '~', 1)}[/]")
return 0
def _whitelist_add(path: str) -> int:
from taninux.core.state import State
from taninux.core import theme as T
console = _console()
p = Path(path).expanduser().resolve()
state = State.load()
state.whitelist_add(p)
console.print(f" [{T.SHINKAI}]+ whitelist:[/] [{T.YUKI}]{p}[/]")
return 0
def _whitelist_remove(path: str) -> int:
from taninux.core.state import State
from taninux.core import theme as T
console = _console()
p = Path(path).expanduser().resolve()
state = State.load()
state.whitelist_remove(p)
console.print(f" [{T.BASE5}]- whitelist:[/] [{T.YUKI}]{p}[/]")
return 0
# ---------------------------------------------------------------- entry
def main() -> int:
parser = argparse.ArgumentParser(
prog="taninux",
description="Mountain-themed Arch Linux maintenance TUI.",
)
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("tui", help="Open the interactive TUI (default)")
p_scan = sub.add_parser("scan", help="Print findings without the TUI")
p_scan.add_argument(
"--category",
choices=[
"all", "dotfolders", "orphans", "pacnew", "cache",
"flatpak-runtimes", "flatpak-data", "aur-cache",
"journal", "coredumps", "kernels",
],
default="all",
help="Scanner-Kategorie (default: all)",
)
p_trash = sub.add_parser("trash", help="Trash management")
p_trash_sub = p_trash.add_subparsers(dest="trash_cmd")
p_trash_sub.add_parser("list", help="List trashed entries")
p_restore = p_trash_sub.add_parser("restore", help="Restore an entry")
p_restore.add_argument("id", help="Trash entry id")
p_purge = p_trash_sub.add_parser("purge", help="Permanently delete trash")
p_purge.add_argument("id", nargs="?", help="Trash entry id (or omit for --all)")
p_purge.add_argument("--all", action="store_true", help="Purge everything")
p_purge.add_argument("--yes", action="store_true", help="Skip confirmation")
p_update = sub.add_parser("update", help="List pending updates from all sources")
p_update.add_argument("--execute", "-y", action="store_true",
help="Updates direkt ausführen (paru/flatpak/fwupdmgr, --noconfirm)")
sub.add_parser("health", help="System health summary (headless)")
sub.add_parser("suggest", help="System-aware package suggestions")
p_snap = sub.add_parser("snapshot", help="Pacman state snapshots / drift")
p_snap_sub = p_snap.add_subparsers(dest="snap_cmd")
p_s_save = p_snap_sub.add_parser("save", help="Aktuellen State speichern")
p_s_save.add_argument("--label", default="manual", help="Optionales Label")
p_snap_sub.add_parser("list", help="Alle Snapshots zeigen")
p_s_diff = p_snap_sub.add_parser("diff", help="Diff zwischen Snapshot und jetzt (oder zweier Snapshots)")
p_s_diff.add_argument("from_id", nargs="?", help="Snapshot-ID (default: latest)")
p_s_diff.add_argument("to_id", nargs="?", help="Zweite Snapshot-ID (default: current)")
p_bundles = sub.add_parser("bundles", help="Bundle management")
p_bundles_sub = p_bundles.add_subparsers(dest="bundles_cmd")
p_bundles_sub.add_parser("list", help="Liste alle bundles (builtin + user)")
p_b_export = p_bundles_sub.add_parser("export", help="Aktuelles System als Bundle speichern")
p_b_export.add_argument("id", help="Bundle-ID (= dateiname ohne .toml)")
p_b_export.add_argument("--label", default=None, help="Display-Name")
p_b_export.add_argument("--tagline", default="exported by taninux", help="Kurze Beschreibung")
p_b_export.add_argument("--explicit-only", action="store_true", default=True,
help="Nur pacman -Qe (explizit installierte, ohne deps)")
p_wl = sub.add_parser("whitelist", help="Whitelist management")
p_wl_sub = p_wl.add_subparsers(dest="wl_cmd")
p_wl_sub.add_parser("list", help="Show whitelist + ignore-list")
p_add = p_wl_sub.add_parser("add", help="Add a path to the whitelist")
p_add.add_argument("path")
p_rm = p_wl_sub.add_parser("remove", help="Remove a path from the whitelist")
p_rm.add_argument("path")
args = parser.parse_args()
if args.cmd in (None, "tui"):
from taninux.app import run
run()
return 0
if args.cmd == "scan":
return _scan(args.category)
if args.cmd == "update":
if args.execute:
return _update_execute()
return _update_list()
if args.cmd == "health":
return _health_summary()
if args.cmd == "suggest":
return _suggest_list()
if args.cmd == "bundles":
if args.bundles_cmd == "list":
return _bundles_list()
if args.bundles_cmd == "export":
return _bundles_export(args.id, args.label, args.tagline)
p_bundles.print_help()
return 1
if args.cmd == "snapshot":
if args.snap_cmd == "save":
return _snapshot_save(args.label)
if args.snap_cmd == "list":
return _snapshot_list()
if args.snap_cmd == "diff":
return _snapshot_diff(args.from_id, args.to_id)
p_snap.print_help()
return 1
if args.cmd == "trash":
if args.trash_cmd == "list":
return _trash_list()
if args.trash_cmd == "restore":
return _trash_restore(args.id)
if args.trash_cmd == "purge":
if args.all:
return _trash_purge_all(args.yes)
return _trash_purge(args.id)
p_trash.print_help()
return 1
if args.cmd == "whitelist":
if args.wl_cmd == "list":
return _whitelist_list()
if args.wl_cmd == "add":
return _whitelist_add(args.path)
if args.wl_cmd == "remove":
return _whitelist_remove(args.path)
p_wl.print_help()
return 1
parser.print_help()
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,52 @@
"""Accessibility-Schalter über gsettings (user-level, kein root)."""
from __future__ import annotations
import subprocess
_IFACE = "org.gnome.desktop.interface"
_A11Y = "org.gnome.desktop.a11y.interface"
_BIG_TEXT = 1.25
def _get(schema: str, key: str) -> str:
try:
r = subprocess.run(["gsettings", "get", schema, key],
capture_output=True, text=True, check=False, timeout=5)
return r.stdout.strip()
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
def _set(schema: str, key: str, value: str) -> None:
try:
subprocess.run(["gsettings", "set", schema, key, value],
check=False, timeout=5)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
def large_text() -> bool:
try:
return float(_get(_IFACE, "text-scaling-factor") or "1") > 1.1
except ValueError:
return False
def set_large_text(on: bool) -> None:
_set(_IFACE, "text-scaling-factor", str(_BIG_TEXT if on else 1.0))
def high_contrast() -> bool:
return _get(_A11Y, "high-contrast") == "true"
def set_high_contrast(on: bool) -> None:
_set(_A11Y, "high-contrast", "true" if on else "false")
def animations() -> bool:
return _get(_IFACE, "enable-animations") != "false"
def set_animations(on: bool) -> None:
_set(_IFACE, "enable-animations", "true" if on else "false")
@@ -0,0 +1,121 @@
"""Eigenes Benutzerkonto. UI-frei.
Konto-Infos aus pwd/grp. Passwort-Änderung des eigenen Users über `passwd`,
das interaktiv ist — wir treiben es über ein PTY (kein root, kein Terminal).
"""
from __future__ import annotations
import grp
import os
import pwd
import select
import time
from dataclasses import dataclass, field
@dataclass
class Account:
username: str
fullname: str
admin: bool # in 'wheel'
groups: list[str] = field(default_factory=list)
def current() -> Account:
info = pwd.getpwuid(os.getuid())
fullname = info.pw_gecos.split(",")[0].strip() or info.pw_name
gids = os.getgrouplist(info.pw_name, info.pw_gid)
names = []
for gid in gids:
try:
names.append(grp.getgrgid(gid).gr_name)
except KeyError:
pass
return Account(info.pw_name, fullname, "wheel" in names, sorted(names))
def _groups_of(name: str, gid: int) -> list[str]:
names = []
for g in os.getgrouplist(name, gid):
try:
names.append(grp.getgrgid(g).gr_name)
except KeyError:
pass
return names
def list_users() -> list[Account]:
"""Menschliche Konten (UID 100064999, ohne nobody)."""
me = os.getuid()
out: list[Account] = []
for info in pwd.getpwall():
if 1000 <= info.pw_uid < 65000 and info.pw_name != "nobody":
groups = _groups_of(info.pw_name, info.pw_gid)
fullname = info.pw_gecos.split(",")[0].strip() or info.pw_name
acc = Account(info.pw_name, fullname, "wheel" in groups, sorted(groups))
out.append(acc)
out.sort(key=lambda a: a.username != pwd.getpwuid(me).pw_name) # eigener zuerst
return out
def add_user_cmds(username: str, fullname: str, admin: bool) -> list[list[str]]:
"""Befehls-Queue: Konto anlegen, optional zu wheel, Passwort-Reset erzwingen."""
cmds = [["useradd", "-m", "-c", fullname or username, username]]
if admin:
cmds.append(["gpasswd", "-a", username, "wheel"])
cmds.append(["passwd", "-e", username]) # neuer User setzt PW beim 1. Login
return cmds
def delete_user_argv(username: str) -> list[str]:
return ["userdel", "-r", username]
def change_password(current_pw: str, new_pw: str, timeout: float = 15.0) -> tuple[bool, str]:
"""Eigenes Passwort via passwd über ein PTY ändern. (ok, message)."""
import pty
pid, fd = pty.fork()
if pid == 0: # child
try:
os.execvp("passwd", ["passwd"])
except OSError:
os._exit(127)
answers = [current_pw, new_pw, new_pw]
sent = 0
out = b""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
r, _, _ = select.select([fd], [], [], 0.4)
if fd not in r:
continue
try:
chunk = os.read(fd, 4096)
except OSError:
break
if not chunk:
break
out += chunk
tail = chunk.decode(errors="ignore").rstrip()
if tail.endswith(":") and sent < len(answers):
os.write(fd, (answers[sent] + "\n").encode())
sent += 1
try:
_, status = os.waitpid(pid, 0)
except OSError:
status = 1
ok = os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0
text = out.decode(errors="ignore")
msg = "Password changed." if ok else _error_hint(text)
return ok, msg
def _error_hint(text: str) -> str:
low = text.lower()
if "authentication" in low or "incorrect" in low:
return "Current password is incorrect."
if "too short" in low or "dictionary" in low or "simplistic" in low or "bad" in low:
return "New password is too weak."
return "Could not change the password."
@@ -0,0 +1,145 @@
"""App-Detail-Metadaten + Quellen-Probing. UI-frei.
Holt Beschreibung/Version/Größe/Lizenz für ein Paket aus pacman/paru/flatpak
und ermittelt, aus welchen Quellen (Repo · AUR · Flatpak) es installierbar ist.
Cross-Source-Mapping ist namensbasiert (Repo/AUR teilen oft den Paketnamen);
einen echten AppStream-Mapping gibt es bewusst nicht.
"""
from __future__ import annotations
import shutil
import subprocess
from dataclasses import dataclass
from taninux.core import pacman
SOURCE_LABEL = {
"repo": "Official repository",
"aur": "AUR (built from source)",
"flatpak": "Flatpak (Flathub)",
}
@dataclass
class Detail:
source: str # 'repo' | 'aur' | 'flatpak'
pkg: str # pacman-Name bzw. Flatpak-App-ID
version: str = ""
summary: str = ""
description: str = ""
size: str = "" # Download-Größe
installed_size: str = ""
license: str = ""
url: str = ""
remote: str = "flathub"
available: bool = False
installed: bool = False
def _run(argv: list[str], timeout: int = 20):
try:
return subprocess.run(argv, capture_output=True, text=True,
check=False, timeout=timeout)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
def _parse_kv(text: str) -> dict[str, str]:
out: dict[str, str] = {}
for line in text.splitlines():
if ":" not in line:
continue
key, _, val = line.partition(":")
key = key.strip().lower()
if key and key not in out:
out[key] = val.strip()
return out
def _pacman_detail(name: str, *, aur: bool) -> Detail | None:
helper = shutil.which("paru") or shutil.which("yay")
argv = [helper, "-Si", name] if (aur and helper) else ["pacman", "-Si", name]
r = _run(argv)
if r is None or r.returncode != 0 or not r.stdout.strip():
return None
kv = _parse_kv(r.stdout)
# paru -Si markiert AUR-Pakete mit "Repository: aur"; sonst ist's Repo.
source = "aur" if kv.get("repository") == "aur" else "repo"
return Detail(
source=source,
pkg=name,
version=kv.get("version", ""),
summary=kv.get("description", ""),
description=kv.get("description", ""),
size=kv.get("download size", ""),
installed_size=kv.get("installed size", ""),
license=kv.get("licenses", ""),
url=kv.get("url", ""),
available=True,
installed=pacman.is_installed(name),
)
def _flatpak_detail(app_id: str, remote: str = "flathub") -> Detail | None:
if not shutil.which("flatpak"):
return None
r = _run(["flatpak", "remote-info", remote, app_id])
if r is None or r.returncode != 0 or not r.stdout.strip():
return None
kv = _parse_kv(r.stdout)
summary = ""
first = r.stdout.strip().splitlines()[0].strip()
if " - " in first:
summary = first.split(" - ", 1)[1].strip()
installed = False
chk = _run(["flatpak", "info", app_id], timeout=8)
if chk is not None and chk.returncode == 0:
installed = True
return Detail(
source="flatpak",
pkg=app_id,
version=kv.get("version", ""),
summary=summary,
description=summary,
size=kv.get("download size", ""),
installed_size=kv.get("installed size", ""),
license=kv.get("license", ""),
url="",
remote=remote,
available=True,
installed=installed,
)
def candidates(*, repo_name: str | None = None, flatpak_id: str | None = None,
remote: str = "flathub", include_aur: bool = False) -> list[Detail]:
"""Verfügbare Installationsquellen für eine App (blockierend → Worker)."""
out: list[Detail] = []
if repo_name:
d = _pacman_detail(repo_name, aur=False)
if d and d.source == "repo":
out.append(d)
if include_aur:
a = _pacman_detail(repo_name, aur=True)
if a and a.source == "aur": # nur echte AUR-Treffer
out.append(a)
if flatpak_id:
f = _flatpak_detail(flatpak_id, remote)
if f:
out.append(f)
return out
def install_argv(d: Detail) -> tuple[list[str], bool]:
if d.source == "flatpak":
return ["flatpak", "install", "--assumeyes", d.remote, d.pkg], False
if d.source == "aur":
helper = shutil.which("paru") or shutil.which("yay") or "paru"
return [helper, "--sudo", "pkexec", "-S", "--noconfirm", d.pkg], False
return ["pacman", "-S", "--needed", "--noconfirm", d.pkg], True
def remove_argv(d: Detail) -> tuple[list[str], bool]:
if d.source == "flatpak":
return ["flatpak", "uninstall", "--assumeyes", d.pkg], False
return ["pacman", "-Rns", "--noconfirm", d.pkg], True
@@ -0,0 +1,140 @@
"""Audio-Steuerung via pactl (PulseAudio/PipeWire). UI-frei.
Genug für eine pavucontrol-artige Geräte+Lautstärke-Steuerung: Sinks/Sources
auflisten, Default setzen, Volume/Mute lesen+setzen. Alles User-Level (kein
root). `kind` ist "sink" (Ausgabe) oder "source" (Eingabe).
"""
from __future__ import annotations
import re
import subprocess
from dataclasses import dataclass
_PCT = re.compile(r"(\d+)%")
@dataclass
class Device:
name: str # interner pactl-Name (für set-default etc.)
description: str # menschlicher Name (fürs UI)
@dataclass
class Stream:
index: str # sink-input-Index (für set-sink-input-* etc.)
app: str # App-Name fürs UI
volume: int
muted: bool
def _pactl(args: list[str], timeout: int = 4) -> str:
try:
r = subprocess.run(
["pactl", *args],
capture_output=True, text=True, check=False, timeout=timeout,
)
return r.stdout if r.returncode == 0 else ""
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
def available() -> bool:
return bool(_pactl(["info"]))
def list_devices(kind: str, include_monitors: bool = False) -> list[Device]:
"""Parst `pactl list sinks|sources` für Name + Description je Block."""
out = _pactl(["list", f"{kind}s"])
devices: list[Device] = []
name: str | None = None
for line in out.splitlines():
if line and not line[0].isspace(): # Block-Header "Sink #N"
name = None
continue
s = line.strip()
if s.startswith("Name:") and name is None:
name = s.split(":", 1)[1].strip()
elif s.startswith("Description:") and name is not None:
desc = s.split(":", 1)[1].strip()
if include_monitors or not name.endswith(".monitor"):
devices.append(Device(name, desc))
name = None
return devices
def get_default(kind: str) -> str:
return _pactl([f"get-default-{kind}"]).strip()
def get_volume(kind: str, name: str) -> int:
m = _PCT.search(_pactl([f"get-{kind}-volume", name]))
return int(m.group(1)) if m else 0
def get_muted(kind: str, name: str) -> bool:
return "yes" in _pactl([f"get-{kind}-mute", name]).lower()
def set_default(kind: str, name: str) -> None:
_pactl([f"set-default-{kind}", name])
def set_volume(kind: str, name: str, pct: int) -> None:
_pactl([f"set-{kind}-volume", name, f"{int(pct)}%"])
def set_mute(kind: str, name: str, muted: bool) -> None:
_pactl([f"set-{kind}-mute", name, "1" if muted else "0"])
_APP_NAME = re.compile(r'application\.name\s*=\s*"([^"]*)"')
_MEDIA_NAME = re.compile(r'media\.name\s*=\s*"([^"]*)"')
def playback_streams() -> list[Stream]:
"""Parst `pactl list sink-inputs` zu Stream je App. Robust/leer bei Fehler."""
out = _pactl(["list", "sink-inputs"])
streams: list[Stream] = []
index: str | None = None
muted = False
volume = 0
app = ""
have_vol = False
def flush() -> None:
if index is not None:
streams.append(Stream(index, app or f"Stream #{index}", volume, muted))
for line in out.splitlines():
if line and not line[0].isspace(): # Block-Header "Sink Input #N"
flush()
m = re.search(r"#(\d+)", line)
index = m.group(1) if m else None
muted, volume, app, have_vol = False, 0, "", False
continue
s = line.strip()
if s.startswith("Mute:"):
muted = "yes" in s.lower()
elif s.startswith("Volume:") and not have_vol:
m = _PCT.search(s)
if m:
volume = int(m.group(1))
have_vol = True
else:
m = _APP_NAME.search(s)
if m and not app:
app = m.group(1)
elif not app:
m = _MEDIA_NAME.search(s)
if m:
app = m.group(1)
flush()
return streams
def set_stream_volume(index: str, pct: int) -> None:
_pactl(["set-sink-input-volume", str(index), f"{int(pct)}%"])
def set_stream_mute(index: str, muted: bool) -> None:
_pactl(["set-sink-input-mute", str(index), "1" if muted else "0"])
@@ -0,0 +1,64 @@
"""Profilbild des eigenen Users. UI-frei, kein root.
Schreibt ~/.face (von vielen Greetern/Apps gelesen) und setzt zusätzlich das
AccountsService-Icon (Greeter/Lockscreen) — Letzteres über D-Bus, das für die
EIGENEN Userdaten per polkit ohne Admin erlaubt ist.
"""
from __future__ import annotations
import getpass
import shutil
import subprocess
from pathlib import Path
def _face() -> Path:
return Path.home() / ".face"
def current_path() -> str | None:
asv = Path("/var/lib/AccountsService/icons") / getpass.getuser()
if asv.is_file():
return str(asv)
if _face().is_file():
return str(_face())
return None
def _user_object_path() -> str | None:
try:
r = subprocess.run(
["gdbus", "call", "--system", "--dest", "org.freedesktop.Accounts",
"--object-path", "/org/freedesktop/Accounts",
"--method", "org.freedesktop.Accounts.FindUserByName", getpass.getuser()],
capture_output=True, text=True, check=False, timeout=8,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
# Ausgabe: (objectpath '/org/freedesktop/Accounts/User1000',)
s = r.stdout
if "'" in s:
return s.split("'")[1]
return None
def set_own(image_path: str) -> bool:
"""Profilbild setzen. image_path sollte bereits skaliert sein (PNG)."""
ok = False
try:
shutil.copyfile(image_path, _face())
ok = True
except OSError:
pass
obj = _user_object_path()
if obj:
try:
subprocess.run(
["gdbus", "call", "--system", "--dest", "org.freedesktop.Accounts",
"--object-path", obj,
"--method", "org.freedesktop.Accounts.User.SetIconFile", image_path],
check=False, timeout=8,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return ok
@@ -0,0 +1,63 @@
"""Backup-Engine — Time-Machine-artige Snapshots über Timeshift (CLI).
UI-frei: liefert nur argv-Listen + parst die Timeshift-Ausgabe. Timeshift
braucht für ALLES root (auch `--list`) → das Frontend schleust die argv durch
den privilegierten Helper (pkexec).
"""
from __future__ import annotations
import re
import shutil
from dataclasses import dataclass
# Zeitstempel-Name eines Snapshots, z.B. "2024-01-02_00-00-01".
_NAME_RE = re.compile(r"\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}")
@dataclass
class Snapshot:
name: str # Zeitstempel, z.B. "2024-01-02_00-00-01"
tags: str # Buchstaben-Tags (O/B/H/D/W/M)
description: str # freier Kommentar
def available() -> bool:
return shutil.which("timeshift") is not None
def list_argv() -> list[str]:
return ["timeshift", "--list"]
def create_argv(comment: str) -> list[str]:
return ["timeshift", "--create", "--comments", comment or "TANINUX", "--scripted"]
def delete_argv(name: str) -> list[str]:
return ["timeshift", "--delete", "--snapshot", name, "--yes"]
def restore_argv(name: str) -> list[str]:
# Sehr destruktiv: stellt das System wieder her, ggf. Reboot nötig.
return ["timeshift", "--restore", "--snapshot", name, "--yes"]
def parse_list(text: str) -> list[Snapshot]:
"""Parst die Ausgabe von `timeshift --list`.
BTRFS-Format: nach einer Trennlinie ("----") Zeilen wie
`0 > 2024-01-02_00-00-01 O mein kommentar`
Robust per Regex auf den Zeitstempel; der Rest danach ist tags+description.
Header- und Trennzeilen werden ignoriert.
"""
snapshots: list[Snapshot] = []
for line in text.splitlines():
match = _NAME_RE.search(line)
if match is None:
continue
name = match.group(0)
rest = line[match.end():].split(maxsplit=1)
tags = rest[0] if rest else ""
description = rest[1].strip() if len(rest) > 1 else ""
snapshots.append(Snapshot(name=name, tags=tags, description=description))
return snapshots
@@ -0,0 +1,99 @@
"""Bluetooth via bluetoothctl (BlueZ). UI-frei.
Power an/aus, Geräte (paired/connected/discovered), connect/disconnect, scan.
bluetoothctl-Einzelkommandos laufen one-shot. Für den aktiven User ohne root.
Schreib-Aktionen liefern argv (Aufrufer führt via ProcessRunner aus).
"""
from __future__ import annotations
import subprocess
from dataclasses import dataclass
def _run(args: list[str], timeout: int = 6) -> str:
try:
r = subprocess.run(["bluetoothctl", *args], capture_output=True,
text=True, check=False, timeout=timeout)
return r.stdout if r.returncode == 0 else r.stdout
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
def available() -> bool:
return bool(_run(["show"]))
def powered() -> bool:
for line in _run(["show"]).splitlines():
if "Powered:" in line:
return line.split(":", 1)[1].strip() == "yes"
return False
def set_power_argv(on: bool) -> list[str]:
return ["bluetoothctl", "power", "on" if on else "off"]
def scan_argv(seconds: int = 8) -> list[str]:
return ["bluetoothctl", "--timeout", str(seconds), "scan", "on"]
@dataclass
class BtDevice:
mac: str
name: str
paired: bool
connected: bool
trusted: bool = False
def _macs(args: list[str]) -> set[str]:
macs: set[str] = set()
for line in _run(args).splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0] == "Device":
macs.add(parts[1])
return macs
def devices() -> list[BtDevice]:
paired = _macs(["devices", "Paired"])
connected = _macs(["devices", "Connected"])
trusted = _macs(["devices", "Trusted"])
out: list[BtDevice] = []
seen: set[str] = set()
for line in _run(["devices"]).splitlines():
parts = line.split(maxsplit=2)
if len(parts) >= 2 and parts[0] == "Device":
mac = parts[1]
if mac in seen:
continue
seen.add(mac)
name = parts[2] if len(parts) > 2 else mac
out.append(BtDevice(
mac=mac, name=name,
paired=mac in paired, connected=mac in connected,
trusted=mac in trusted,
))
# Verbundene/gepairte zuerst
return sorted(out, key=lambda d: (not d.connected, not d.paired, d.name.lower()))
def connect_argv(mac: str) -> list[str]:
return ["bluetoothctl", "connect", mac]
def disconnect_argv(mac: str) -> list[str]:
return ["bluetoothctl", "disconnect", mac]
def pair_argv(mac: str) -> list[str]:
return ["bluetoothctl", "pair", mac]
def trust_argv(mac: str) -> list[str]:
return ["bluetoothctl", "trust", mac]
def remove_argv(mac: str) -> list[str]:
return ["bluetoothctl", "remove", mac]
@@ -0,0 +1,142 @@
"""Bootloader-Detection + Default-Entry Management.
Unterstützt:
- systemd-boot (via /boot/loader/entries/ + bootctl)
- GRUB (info-only — grub-set-default ist fragil, command zeigen)
"""
from __future__ import annotations
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass
class BootloaderInfo:
kind: str # "systemd-boot" / "grub" / "unknown"
config_path: Path | None
default_entry: str | None # bei systemd-boot der entry-name (z.B. "arch.conf")
entries: list[Path] # alle .conf entries (nur systemd-boot)
def detect() -> BootloaderInfo:
# systemd-boot
loader_conf = Path("/boot/loader/loader.conf")
entries_dir = Path("/boot/loader/entries")
if loader_conf.exists() and entries_dir.is_dir():
default = None
try:
for line in loader_conf.read_text().splitlines():
line = line.strip()
if line.startswith("default"):
parts = line.split(maxsplit=1)
if len(parts) == 2:
default = parts[1].strip()
break
except OSError:
pass
# bootctl status liefert oft den effektiven default (falls "default" in loader.conf einen pattern hat)
if not default or "*" in default:
try:
r = subprocess.run(
["bootctl", "list", "--json=short"],
capture_output=True, text=True, check=False, timeout=5,
)
if r.returncode == 0:
import json
for entry in json.loads(r.stdout):
if entry.get("isDefault"):
default = entry.get("id", default)
break
except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
pass
return BootloaderInfo(
kind="systemd-boot",
config_path=loader_conf,
default_entry=default,
entries=sorted(entries_dir.glob("*.conf")),
)
# GRUB
grub_cfg = Path("/boot/grub/grub.cfg")
if grub_cfg.exists():
default = None
try:
etcdefault = Path("/etc/default/grub").read_text()
m = re.search(r'^GRUB_DEFAULT\s*=\s*"?([^"\n]+)"?', etcdefault, re.MULTILINE)
if m:
default = m.group(1).strip()
except OSError:
pass
return BootloaderInfo(
kind="grub",
config_path=grub_cfg,
default_entry=default,
entries=[],
)
return BootloaderInfo(kind="unknown", config_path=None,
default_entry=None, entries=[])
def entry_for_kernel(bl: BootloaderInfo, kernel_pkg: str) -> Path | None:
"""Findet das systemd-boot .conf entry für ein gegebenes Kernel-Paket.
Match-Strategie: entry enthält `linux /vmlinuz-<suffix>` wo suffix mit
`kernel_pkg` korrespondiert. `linux` → `/vmlinuz-linux`,
`linux-zen` → `/vmlinuz-linux-zen`."""
if bl.kind != "systemd-boot":
return None
target = f"vmlinuz-{kernel_pkg}"
for entry in bl.entries:
try:
content = entry.read_text()
except OSError:
continue
# Match exakt: vmlinuz-linux-zen, NICHT vmlinuz-linux wenn pkg=linux-zen
for line in content.splitlines():
line = line.strip()
if line.startswith("linux"):
# Format: "linux /vmlinuz-linux-zen"
parts = line.split()
if len(parts) >= 2:
path = parts[1].rstrip("/").split("/")[-1]
if path == target:
return entry
return None
def is_default(bl: BootloaderInfo, entry: Path) -> bool:
if bl.default_entry is None:
return False
return bl.default_entry == entry.name or bl.default_entry == entry.stem
def set_default_argv(bl: BootloaderInfo, entry) -> list[str] | None:
"""ARGV zum Setzen des Default-Boot-Entry.
core eskaliert NICHT selbst — gibt nur die Befehlsliste zurück, die der
(GTK-)Aufruferprivilegiert (pkexec) ausführt. Passt zum Muster „command zeigen".
- systemd-boot: `bootctl set-default <entry-id>` (entry-id = .conf-Dateiname).
`entry` darf ein str, eine Path oder None sein.
- GRUB: None — `grub-set-default` ist fragil (Index vs. Menüpfad, danach
`grub-mkconfig` nötig); bewusst nicht automatisiert.
"""
if bl.kind != "systemd-boot" or entry is None:
return None
# entry-id für bootctl ist der Dateiname des .conf-Eintrags
entry_id = entry.name if isinstance(entry, Path) else str(entry)
return ["bootctl", "set-default", entry_id]
def prune_argv(keep: int) -> list[str]:
"""ARGV zum Aufräumen alter zwischengespeicherter Pakete via `paccache`.
Meint den pacman-Cache (/var/cache/pacman/pkg) — darin liegen alte Versionen
aller Pakete, inkl. alter Kernel. `paccache -rk <keep>` entfernt alle bis auf
die `keep` jüngsten Versionen pro Paket; idiomatischer Arch-Weg.
core eskaliert nicht selbst — der Aufrufer führt das privilegiert aus.
"""
return ["paccache", "-rk", str(max(0, int(keep)))]
@@ -0,0 +1,110 @@
"""Kuratierter App-Katalog für Endnutzer (GNOME-Software-Stil). UI-frei.
Beliebte Desktop-Apps, die ein normaler User typisch braucht — gruppiert nach
Kategorie. Quelle ist entweder ein Arch-Repo-Paket (via paru) oder Flathub.
Empfohlen wird, was noch nicht installiert ist.
"""
from __future__ import annotations
import shutil
import subprocess
from dataclasses import dataclass
@dataclass(frozen=True)
class App:
id: str # stabiler Key
name: str
summary: str
category: str
source: str # 'repo' | 'flatpak'
pkg: str # pacman-Name bzw. Flatpak-App-ID
icon: str # bevorzugter Icon-Name (Fallback = Kategorie-Icon)
CATEGORY_ORDER = [
"Internet", "Office", "Communication", "Multimedia",
"Graphics", "Games", "Productivity", "Utilities",
]
CATEGORY_ICON = {
"Internet": "web-browser-symbolic",
"Office": "x-office-document-symbolic",
"Communication": "mail-send-symbolic",
"Multimedia": "multimedia-player-symbolic",
"Graphics": "applications-graphics-symbolic",
"Games": "applications-games-symbolic",
"Productivity": "view-list-symbolic",
"Utilities": "applications-utilities-symbolic",
}
CATALOG: list[App] = [
# Internet
App("firefox", "Firefox", "Fast, private web browser", "Internet", "repo", "firefox", "firefox"),
App("chromium", "Chromium", "Open-source web browser", "Internet", "repo", "chromium", "chromium"),
App("qbittorrent", "qBittorrent", "Lightweight BitTorrent client", "Internet", "repo", "qbittorrent", "qbittorrent"),
# Office
App("libreoffice", "LibreOffice", "Word processor, spreadsheets and slides", "Office", "repo", "libreoffice-fresh", "libreoffice-startcenter"),
App("okular", "Okular", "View PDFs, comics and e-books", "Office", "repo", "okular", "okular"),
# Communication
App("thunderbird", "Thunderbird", "Email, calendar and contacts", "Communication", "repo", "thunderbird", "thunderbird"),
App("signal", "Signal", "Private encrypted messaging", "Communication", "repo", "signal-desktop", "signal-desktop"),
App("telegram", "Telegram", "Cloud-based messaging", "Communication", "repo", "telegram-desktop", "telegram"),
App("discord", "Discord", "Voice and text chat for communities", "Communication", "repo", "discord", "discord"),
# Multimedia
App("vlc", "VLC", "Plays nearly any audio or video file", "Multimedia", "repo", "vlc", "vlc"),
App("spotify", "Spotify", "Music streaming", "Multimedia", "flatpak", "com.spotify.Client", "com.spotify.Client"),
App("obs", "OBS Studio", "Screen recording and live streaming", "Multimedia", "repo", "obs-studio", "com.obsproject.Studio"),
App("kdenlive", "Kdenlive", "Non-linear video editor", "Multimedia", "repo", "kdenlive", "kdenlive"),
# Graphics
App("gimp", "GIMP", "Powerful image editor", "Graphics", "repo", "gimp", "gimp"),
App("inkscape", "Inkscape", "Vector graphics editor", "Graphics", "repo", "inkscape", "org.inkscape.Inkscape"),
App("krita", "Krita", "Digital painting studio", "Graphics", "repo", "krita", "krita"),
App("blender", "Blender", "3D modelling and animation", "Graphics", "repo", "blender", "blender"),
# Games
App("steam", "Steam", "The biggest PC game store", "Games", "repo", "steam", "steam"),
App("lutris", "Lutris", "Manage and run games from anywhere", "Games", "repo", "lutris", "lutris"),
App("heroic", "Heroic", "Epic, GOG and Amazon games launcher", "Games", "flatpak", "com.heroicgameslauncher.hgl", "com.heroicgameslauncher.hgl"),
# Productivity
App("obsidian", "Obsidian", "Markdown notes and knowledge base", "Productivity", "flatpak", "md.obsidian.Obsidian", "md.obsidian.Obsidian"),
App("keepassxc", "KeePassXC", "Secure offline password manager", "Productivity", "repo", "keepassxc", "keepassxc"),
# Utilities
App("bottles", "Bottles", "Run Windows software easily", "Utilities", "flatpak", "com.usebottles.bottles", "com.usebottles.bottles"),
App("flatseal", "Flatseal", "Manage Flatpak app permissions", "Utilities", "flatpak", "com.github.tchx84.Flatseal", "com.github.tchx84.Flatseal"),
]
def _flatpak_installed_ids() -> set[str]:
if not shutil.which("flatpak"):
return set()
try:
r = subprocess.run(
["flatpak", "list", "--app", "--columns=application"],
capture_output=True, text=True, check=False, timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return set()
return {ln.strip() for ln in r.stdout.splitlines() if ln.strip()}
def is_installed(app: App, pacman_set: set[str], flatpak_ids: set[str]) -> bool:
if app.source == "flatpak":
return app.pkg in flatpak_ids
return app.pkg in pacman_set
def recommended(pacman_set: set[str], flatpak_ids: set[str] | None = None) -> list[App]:
"""Noch nicht installierte Katalog-Apps, in Kategorie-Reihenfolge."""
if flatpak_ids is None:
flatpak_ids = _flatpak_installed_ids()
out = [a for a in CATALOG if not is_installed(a, pacman_set, flatpak_ids)]
out.sort(key=lambda a: (CATEGORY_ORDER.index(a.category)
if a.category in CATEGORY_ORDER else 99, a.name.lower()))
return out
def install_argv(app: App) -> tuple[list[str], bool]:
"""(argv, privileged). Katalog-Apps sind Repo (via Helper) oder Flatpak."""
if app.source == "flatpak":
return ["flatpak", "install", "--assumeyes", "flathub", app.pkg], False
return ["pacman", "-S", "--needed", "--noconfirm", app.pkg], True
@@ -0,0 +1,37 @@
"""Compositor — TANINUX ist niri-only. UI-frei.
Früher unterschied dieses Modul Hyprland vs Sway/SwayFX; seit der Umstellung
auf **niri** gibt es nur noch einen Compositor. Das Modul bleibt als dünne
Schicht erhalten, damit die Session-Erkennung („läuft niri überhaupt?") an
EINER Stelle liegt — genutzt von der Display-Seite (niri-IPC verfügbar?).
"""
from __future__ import annotations
import os
import subprocess
NIRI = "niri"
UNKNOWN = "unknown"
def detect() -> str:
# niri exportiert NIRI_SOCKET in jede Session — das ist der zuverlässige Weg.
if os.environ.get("NIRI_SOCKET"):
return NIRI
# Fallback: laufender Prozess (z.B. wenn aus anderem Env gestartet).
try:
r = subprocess.run(["pgrep", "-x", "niri"],
capture_output=True, timeout=3)
if r.returncode == 0:
return NIRI
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return UNKNOWN
def is_niri() -> bool:
return detect() == NIRI
def pretty() -> str:
return "niri" if is_niri() else "unknown"
@@ -0,0 +1,89 @@
"""Keyboard-driven confirmation modal im Mountain-Stil."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Static
from taninux.core import theme as T
def _escape_markup(s: str) -> str:
"""Escape Textual/rich markup brackets — wir wollen body als plain text."""
return s.replace("[", "\\[").replace("]", "\\]")
class ConfirmModal(ModalScreen[bool]):
"""Yes/No-Modal — `y` bestätigt, `n`/`esc` bricht ab."""
BINDINGS = [
Binding("y", "confirm", "ja"),
Binding("n", "cancel", "nein"),
Binding("enter", "confirm", "ja", show=False),
Binding("escape", "cancel", "abbruch"),
]
CSS = f"""
ConfirmModal {{
align: center middle;
background: rgba(0, 0, 0, 0.6);
}}
ConfirmModal #dialog {{
width: 80;
height: auto;
max-height: 90%;
padding: 1 2;
background: {T.YORU};
border: round {T.BASE3};
}}
ConfirmModal #c-title {{
height: 1;
background: {T.YORU};
}}
ConfirmModal #c-body {{
height: auto;
margin-top: 1;
background: {T.YORU};
color: {T.YUKI};
}}
ConfirmModal #c-hint {{
height: 1;
margin-top: 1;
background: {T.YORU};
color: {T.BASE4};
}}
"""
def __init__(self, title: str, body: str, danger: bool = False) -> None:
super().__init__()
self._title = title
self._body = body
self._danger = danger
def compose(self) -> ComposeResult:
title_color = T.RED if self._danger else T.UME
with Vertical(id="dialog"):
yield Static(
f"[{title_color}]{self._title}[/]",
id="c-title",
markup=True,
)
# Body: markup escaped — sonst crashen brackets aus argv-output
yield Static(
_escape_markup(self._body),
id="c-body",
)
yield Static(
f"[{T.BASE5}]y[/]/[{T.BASE5}]enter[/] [{T.BASE4}]bestätigen[/] "
f"[{T.BASE5}]n[/]/[{T.BASE5}]esc[/] [{T.BASE4}]abbrechen[/]",
id="c-hint",
markup=True,
)
def action_confirm(self) -> None:
self.dismiss(True)
def action_cancel(self) -> None:
self.dismiss(False)
@@ -0,0 +1,112 @@
"""Standard-Anwendungen pro Kategorie. UI-frei, user-level (kein root).
Liest/setzt die MIME-Default-Apps über `gio mime` + `xdg-mime`. Anzeigename und
Icon kommen aus den .desktop-Dateien.
"""
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
# (Label, repräsentativer MIME-Typ, Fallback-Icon)
CATEGORIES = [
("Web browser", "x-scheme-handler/https", "web-browser-symbolic"),
("Email", "x-scheme-handler/mailto", "mail-send-symbolic"),
("Files", "inode/directory", "folder-symbolic"),
("Images", "image/png", "image-x-generic-symbolic"),
("Audio", "audio/mpeg", "audio-x-generic-symbolic"),
("Video", "video/mp4", "video-x-generic-symbolic"),
("PDF documents", "application/pdf", "x-office-document-symbolic"),
]
_DIRS = [
Path.home() / ".local/share/applications",
Path.home() / ".local/share/flatpak/exports/share/applications",
Path("/var/lib/flatpak/exports/share/applications"),
Path("/usr/share/applications"),
]
@dataclass
class App:
id: str # desktop file id, z.B. firefox.desktop
name: str
icon: str
def _find_desktop(desktop_id: str) -> Path | None:
for d in _DIRS:
p = d / desktop_id
if p.is_file():
return p
return None
def _meta(desktop_id: str) -> App:
name, icon = desktop_id.removesuffix(".desktop"), ""
path = _find_desktop(desktop_id)
if path:
try:
in_entry = False
for line in path.read_text().splitlines():
s = line.strip()
if s.startswith("["):
in_entry = s == "[Desktop Entry]"
elif in_entry and s.startswith("Name=") and name == desktop_id.removesuffix(".desktop"):
name = s[5:].strip()
elif in_entry and s.startswith("Icon="):
icon = s[5:].strip()
except OSError:
pass
return App(id=desktop_id, name=name, icon=icon)
def _gio_mime(mime: str) -> tuple[str | None, list[str]]:
env = {**os.environ, "LC_ALL": "C"}
try:
r = subprocess.run(["gio", "mime", mime], capture_output=True, text=True,
check=False, timeout=8, env=env)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None, []
default: str | None = None
recommended: list[str] = []
section = None
for line in r.stdout.splitlines():
if line.startswith("Default application"):
default = line.split(":", 1)[1].strip() or None
elif line.startswith("Recommended applications"):
section = "rec"
elif line.startswith("Registered applications"):
section = "reg" if not recommended else section
elif line.startswith("\t") or line.startswith(" "):
app = line.strip()
if app.endswith(".desktop") and app not in recommended and section in ("rec", "reg"):
recommended.append(app)
return default, recommended
def apps_for(mime: str) -> tuple[App | None, list[App]]:
"""(aktueller Default, verfügbare Apps) für einen MIME-Typ."""
default_id, ids = _gio_mime(mime)
apps = [_meta(i) for i in ids]
current = _meta(default_id) if default_id else None
return current, apps
def set_default(desktop_id: str, mime: str) -> bool:
ok = True
try:
subprocess.run(["xdg-mime", "default", desktop_id, mime],
check=False, timeout=8)
except (FileNotFoundError, subprocess.TimeoutExpired):
ok = False
if mime.startswith("x-scheme-handler/http"):
# Browser zusätzlich als System-Default registrieren.
try:
subprocess.run(["xdg-settings", "set", "default-web-browser", desktop_id],
check=False, timeout=8)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return ok
@@ -0,0 +1,291 @@
"""Display / Output-Einstellungen — niri-only über native IPC. UI-frei.
Liest Monitore via `niri msg --json outputs` und normalisiert sie auf ein
`Output`-Dataclass. Apply erzeugt das passende argv (`niri msg output <name> …`).
Bewusst NUR live: `niri msg output` ändert die Konfiguration temporär, nichts
wird in die niri-Config (config.kdl) geschrieben. Nach einem Reload/Login sind
die Änderungen wieder weg.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
# Eigener TANINUX-User-Service für wlsunset (analog swayidle in core/power.py).
# Der niri-Autostart (spawn-at-startup) wird NICHT angefasst.
WLSUNSET_UNIT = (
Path.home() / ".config" / "systemd" / "user" / "taninux-wlsunset.service"
)
@dataclass
class Output:
name: str
make: str
model: str
width: int
height: int
refresh_hz: float
x: int
y: int
scale: float
transform: str # "normal" / "90" / "180" / "270"
enabled: bool
modes: list[tuple[int, int, float]] = field(default_factory=list) # (w, h, hz)
# -- Lesen ----------------------------------------------------------------
def _run_json(argv: list[str]):
try:
r = subprocess.run(argv, capture_output=True, text=True, timeout=5)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
if r.returncode != 0 or not r.stdout.strip():
return None
try:
return json.loads(r.stdout)
except json.JSONDecodeError:
return None
def _norm_transform(value) -> str:
"""niri-Transform → "normal"/"90"/"180"/"270".
niri liefert z.B. "Normal", "_90", "Flipped", "Flipped90". Die UI bietet
nur die vier rechtwinkligen Rotationen; flipped-* fallen auf "normal" /
deren Rotationsanteil zurück.
"""
s = str(value).lower().replace("flipped", "").strip("_-")
return s if s in ("90", "180", "270") else "normal"
def outputs() -> list[Output]:
# `niri msg --json outputs` liefert ein dict {name: output}.
data = _run_json(["niri", "msg", "--json", "outputs"])
if not isinstance(data, dict):
return []
result: list[Output] = []
for name, o in sorted(data.items()):
modes: list[tuple[int, int, float]] = []
for md in o.get("modes", []) or []:
modes.append((
int(md.get("width", 0)),
int(md.get("height", 0)),
int(md.get("refresh_rate", 0)) / 1000.0, # niri: mHz -> Hz
))
# logical ist null, wenn der Output deaktiviert ist.
logical = o.get("logical") or None
enabled = logical is not None
# current_mode ist ein Index in modes (null wenn aus / unbekannt).
cur_idx = o.get("current_mode")
if isinstance(cur_idx, int) and 0 <= cur_idx < len(modes):
cw, ch, chz = modes[cur_idx]
elif modes:
cw, ch, chz = modes[0]
else:
cw, ch, chz = 0, 0, 0.0
result.append(Output(
name=name,
make=o.get("make", "") or "",
model=o.get("model", "") or "",
width=cw,
height=ch,
refresh_hz=chz,
x=int(logical.get("x", 0)) if logical else 0,
y=int(logical.get("y", 0)) if logical else 0,
scale=float(logical.get("scale", 1.0)) if logical else 1.0,
transform=_norm_transform(logical.get("transform", "normal")) if logical else "normal",
enabled=enabled,
modes=modes,
))
return result
# -- Apply ----------------------------------------------------------------
def _fmt_hz(hz: float) -> str:
"""Refresh kompakt: '143.973' statt '143.97300'. Ganze Zahlen ohne Punkt.
Drei Nachkommastellen, weil niri die Modes mit mHz-Präzision führt
(z.B. 59.951) und den Mode-String sonst nicht trifft.
"""
s = f"{hz:.3f}".rstrip("0").rstrip(".")
return s or "0"
def _fmt_scale(scale: float) -> str:
s = f"{scale:.2f}".rstrip("0").rstrip(".")
return s or "1"
def apply_argv(
out: Output,
*,
mode: tuple[int, int, float] | None = None,
scale: float | None = None,
transform: str | None = None,
enabled: bool | None = None,
) -> list[str]:
"""argv für GENAU eine Änderung an `out` via `niri msg output`.
Unprivilegiert ausführbar. Genau einer der Keyword-Parameter sollte gesetzt sein.
niri ändert die Output-Config temporär (nicht persistent) — passt zur
bewussten „nur live"-Semantik dieses Moduls.
"""
base = ["niri", "msg", "output", out.name]
if enabled is not None:
return base + (["on"] if enabled else ["off"])
if mode is not None:
w, h, hz = mode
return base + ["mode", f"{w}x{h}@{_fmt_hz(hz)}"]
if scale is not None:
return base + ["scale", _fmt_scale(scale)]
if transform is not None:
return base + ["transform", transform]
return base
# -- Night Light / Farbtemperatur (wlsunset) ------------------------------
#
# TANINUX verwaltet einen EIGENEN systemd-User-Service
# taninux-wlsunset.service (analog zum swayidle-Ansatz in core/power.py).
# wlsunset hat keine Config-Datei — alle Parameter stehen in den ExecStart-Args.
# Karims Compositor-Autostart bleibt unberührt.
# Defaults = Karims Werte aus dem Autostart.
_NL_DEFAULT_DAY = 6500
_NL_DEFAULT_NIGHT = 4000
_NL_DEFAULT_LAT = 47.0
_NL_DEFAULT_LON = 8.3
@dataclass
class NightLight:
enabled: bool
day_temp: int
night_temp: int
lat: float
lon: float
def wlsunset_installed() -> bool:
return shutil.which("wlsunset") is not None
def night_light() -> NightLight:
"""Liest die aktuellen Night-Light-Werte aus der TANINUX-Unit.
enabled = Unit existiert (wir verwalten genau diesen Service). Die
Parameter kommen aus den ExecStart-Args: -t <night>, -T <day>,
-l <lat>, -L <lon>. Fehlt etwas, greifen die Defaults. Robust gegen
fehlende/kaputte Unit.
"""
day = _NL_DEFAULT_DAY
night = _NL_DEFAULT_NIGHT
lat = _NL_DEFAULT_LAT
lon = _NL_DEFAULT_LON
enabled = WLSUNSET_UNIT.is_file()
if enabled:
try:
text = WLSUNSET_UNIT.read_text(errors="ignore")
except OSError:
text = ""
m = re.search(r"^\s*ExecStart\s*=\s*(.+)$", text, re.MULTILINE)
exec_line = m.group(1) if m else ""
def _int_arg(flag: str, fallback: int) -> int:
mm = re.search(rf"(?:^|\s){re.escape(flag)}\s+(\d+)", exec_line)
return int(mm.group(1)) if mm else fallback
def _float_arg(flag: str, fallback: float) -> float:
mm = re.search(
rf"(?:^|\s){re.escape(flag)}\s+(-?[\d.]+)", exec_line
)
try:
return float(mm.group(1)) if mm else fallback
except ValueError:
return fallback
night = _int_arg("-t", night)
day = _int_arg("-T", day)
lat = _float_arg("-l", lat)
lon = _float_arg("-L", lon)
return NightLight(
enabled=enabled, day_temp=day, night_temp=night, lat=lat, lon=lon
)
def _fmt_coord(v: float) -> str:
"""Koordinate kompakt: '47' statt '47.0', '8.3' bleibt '8.3'."""
s = f"{v:.4f}".rstrip("0").rstrip(".")
return s or "0"
def write_night_light(
day_temp: int, night_temp: int, lat: float, lon: float
) -> Path:
"""Schreibt die TANINUX-verwaltete wlsunset-User-Unit. Alle Parameter
landen in der ExecStart-Zeile. Gibt den geschriebenen Pfad zurück."""
wlsunset = shutil.which("wlsunset") or "/usr/bin/wlsunset"
exec_start = (
f"{wlsunset} -l {_fmt_coord(lat)} -L {_fmt_coord(lon)} "
f"-t {int(night_temp)} -T {int(day_temp)}"
)
lines = [
"[Unit]",
"Description=TANINUX-managed wlsunset (night light)",
"Documentation=man:wlsunset(1)",
"PartOf=graphical-session.target",
"",
"[Service]",
"Type=simple",
f"ExecStart={exec_start}",
"Restart=on-failure",
"",
"[Install]",
"WantedBy=default.target",
"",
]
WLSUNSET_UNIT.parent.mkdir(parents=True, exist_ok=True)
WLSUNSET_UNIT.write_text("\n".join(lines))
return WLSUNSET_UNIT
def apply_night_light(enable: bool) -> bool:
"""User-Service anwenden (best-effort, unprivilegiert).
enable → daemon-reload + enable --now + restart.
disable → disable --now. True wenn die Befehle abgesetzt wurden.
"""
try:
subprocess.run(
["systemctl", "--user", "daemon-reload"],
check=False, timeout=8,
)
if enable:
subprocess.run(
["systemctl", "--user", "enable", "--now",
"taninux-wlsunset.service"],
check=False, timeout=8,
)
subprocess.run(
["systemctl", "--user", "restart", "taninux-wlsunset.service"],
check=False, timeout=8,
)
else:
subprocess.run(
["systemctl", "--user", "disable", "--now",
"taninux-wlsunset.service"],
check=False, timeout=8,
)
return True
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
@@ -0,0 +1,45 @@
"""Firewall — einfacher An/Aus-Status. UI-frei. Bevorzugt ufw, sonst firewalld.
Status ist ohne root lesbar (systemctl is-active); Umschalten läuft privilegiert
über den Helper.
"""
from __future__ import annotations
import shutil
import subprocess
def backend() -> str | None:
if shutil.which("ufw"):
return "ufw"
if shutil.which("firewall-cmd"):
return "firewalld"
return None
def installed() -> bool:
return backend() is not None
def _service() -> str:
return "firewalld" if backend() == "firewalld" else "ufw"
def enabled() -> bool:
try:
r = subprocess.run(["systemctl", "is-active", _service()],
capture_output=True, text=True, check=False, timeout=5)
return r.stdout.strip() == "active"
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def set_enabled_argv(on: bool) -> list[str]:
if backend() == "ufw":
return ["ufw", "enable"] if on else ["ufw", "disable"]
action = "enable" if on else "disable"
return ["systemctl", action, "--now", "firewalld"]
def install_argv() -> list[str]:
return ["pacman", "-S", "--needed", "--noconfirm", "ufw"]
@@ -0,0 +1,117 @@
"""Locale + Tastatur-Layout via localectl (systemd).
UI-frei — von TUI und GTK-Frontend nutzbar. Lese-Funktionen laufen
unprivilegiert; die Schreib-Aktionen liefern nur das ARGV (wie bei
core.bootloader), das der Aufrufer privilegiert über den Helper ausführt.
core eskaliert NIE selbst.
"""
from __future__ import annotations
import subprocess
from dataclasses import dataclass
def _run(args: list[str], timeout: int = 5) -> str:
try:
r = subprocess.run(
args, capture_output=True, text=True, check=False, timeout=timeout,
)
return r.stdout if r.returncode == 0 else ""
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
@dataclass
class LocaleStatus:
locale: str # LANG-Wert, z.B. "de_DE.UTF-8"
vc_keymap: str # Konsolen-Keymap, z.B. "de-latin1"
x11_layout: str # XKB-Layout, z.B. "de"
x11_variant: str # XKB-Variante, z.B. "nodeadkeys"
def status() -> LocaleStatus:
"""Parst `localectl status`."""
out = _run(["localectl", "status"])
fields = {"locale": "", "vc_keymap": "", "x11_layout": "", "x11_variant": ""}
for line in out.splitlines():
line = line.strip()
if line.startswith("System Locale:"):
val = line.split(":", 1)[1].strip()
# "LANG=de_DE.UTF-8" (evtl. mehrere, durch Whitespace getrennt)
for part in val.split():
if part.startswith("LANG="):
fields["locale"] = part.split("=", 1)[1]
elif line.startswith("VC Keymap:"):
fields["vc_keymap"] = line.split(":", 1)[1].strip()
elif line.startswith("X11 Layout:"):
fields["x11_layout"] = line.split(":", 1)[1].strip()
elif line.startswith("X11 Variant:"):
fields["x11_variant"] = line.split(":", 1)[1].strip()
return LocaleStatus(**fields)
def list_locales() -> list[str]:
"""Verfügbare (generierte) Locales. Fallback auf `locale -a`."""
out = _run(["localectl", "list-locales"])
if not out:
out = _run(["locale", "-a"])
return [l.strip() for l in out.splitlines() if l.strip()]
def current_timezone() -> str:
"""Aktuelle System-Zeitzone, z.B. "Europe/Zurich". '' bei Fehler."""
return _run(["timedatectl", "show", "-p", "Timezone", "--value"]).strip()
def list_timezones() -> list[str]:
"""Alle bekannten Zeitzonen (timedatectl list-timezones)."""
out = _run(["timedatectl", "list-timezones"])
return [l.strip() for l in out.splitlines() if l.strip()]
def ntp_enabled() -> bool:
"""Ob NTP-Zeitsynchronisation (systemd-timesyncd o.ä.) aktiv ist."""
return _run(["timedatectl", "show", "-p", "NTP", "--value"]).strip() == "yes"
def local_time() -> str:
"""Kurze, lesbare lokale Zeit inkl. Zeitzonen-Kürzel (reiner Read)."""
return _run(["date", "+%Y-%m-%d %H:%M %Z"]).strip()
def list_x11_layouts() -> list[str]:
out = _run(["localectl", "list-x11-keymap-layouts"])
return [l.strip() for l in out.splitlines() if l.strip()]
def list_x11_variants(layout: str) -> list[str]:
"""XKB-Varianten für ein Layout (z.B. nodeadkeys, dvorak). '' = Standard."""
out = _run(["localectl", "list-x11-keymap-variants", layout])
return [""] + [l.strip() for l in out.splitlines() if l.strip()]
# ---- Schreib-Aktionen: nur ARGV liefern (Aufrufer führt privilegiert aus) ----
def set_locale_argv(lang: str) -> list[str]:
return ["localectl", "set-locale", f"LANG={lang}"]
def set_x11_keymap_argv(layout: str, variant: str = "") -> list[str]:
# Syntax: set-x11-keymap LAYOUT [MODEL [VARIANT [OPTIONS]]].
# Variante ohne Model -> leeres Model als Platzhalter durchreichen.
if variant:
return ["localectl", "set-x11-keymap", layout, "", variant]
return ["localectl", "set-x11-keymap", layout]
def set_keymap_argv(keymap: str) -> list[str]:
return ["localectl", "set-keymap", keymap]
def set_timezone_argv(tz: str) -> list[str]:
return ["timedatectl", "set-timezone", tz]
def set_ntp_argv(on: bool) -> list[str]:
return ["timedatectl", "set-ntp", "true" if on else "false"]
@@ -0,0 +1,28 @@
"""Screen lock. UI-frei. niri-only — Locker via swaylock (ext-session-lock)."""
from __future__ import annotations
import shutil
# swaylock spricht das ext-session-lock-Protokoll, das niri implementiert;
# gtklock als Fallback. (hyprlock entfällt — Hyprland-only.)
_LOCKERS = ("swaylock", "gtklock")
def locker() -> str | None:
for name in _LOCKERS:
if shutil.which(name):
return name
return None
def preferred() -> str:
return "swaylock"
def lock_now_argv() -> list[str] | None:
name = locker()
return [name] if name else None
def install_argv() -> list[str]:
return ["pacman", "-S", "--needed", "--noconfirm", preferred()]
@@ -0,0 +1,211 @@
"""Netzwerk via nmcli (NetworkManager). UI-frei.
WiFi an/aus, Status, verfügbare Netze, Verbinden. Für den aktiven User ohne
root (NetworkManager-polkit erlaubt network-control). Schreib-Aktionen liefern
argv (der Aufrufer führt sie via ProcessRunner aus, mit Live-Output).
"""
from __future__ import annotations
import subprocess
from dataclasses import dataclass
def _run(args: list[str], timeout: int = 8) -> str:
try:
r = subprocess.run(["nmcli", *args], capture_output=True, text=True,
check=False, timeout=timeout)
return r.stdout if r.returncode == 0 else ""
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
def available() -> bool:
return bool(_run(["-t", "-f", "RUNNING", "general"]))
def _split(line: str) -> list[str]:
"""nmcli -t Felder splitten, escaped '\\:' respektieren."""
out: list[str] = []
cur = ""
i = 0
while i < len(line):
c = line[i]
if c == "\\" and i + 1 < len(line):
cur += line[i + 1]
i += 2
continue
if c == ":":
out.append(cur)
cur = ""
i += 1
continue
cur += c
i += 1
out.append(cur)
return out
@dataclass
class WifiNet:
ssid: str
signal: int # 0..100
security: str # "" = offen
in_use: bool
def wifi_enabled() -> bool:
return _run(["-t", "-f", "WIFI", "radio"]).strip() == "enabled"
def set_wifi_argv(on: bool) -> list[str]:
return ["nmcli", "radio", "wifi", "on" if on else "off"]
def active_summary() -> str:
"""Kurzbeschreibung der aktiven Verbindung(en)."""
out = _run(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show", "--active"])
parts = []
for line in out.splitlines():
f = _split(line)
if len(f) >= 3 and f[1] != "loopback":
parts.append(f"{f[0]} ({f[2]})")
return ", ".join(parts) if parts else "Not connected"
def wifi_networks(rescan: bool = False) -> list[WifiNet]:
# rescan=False -> NetworkManagers Cache (instant); True -> aktiver Scan (langsam).
out = _run(["-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list",
"--rescan", "yes" if rescan else "no"])
best: dict[str, WifiNet] = {}
for line in out.splitlines():
f = _split(line)
if len(f) < 4:
continue
in_use, ssid, sig, sec = f[0], f[1], f[2], f[3]
if not ssid:
continue
try:
signal = int(sig)
except ValueError:
signal = 0
net = WifiNet(ssid=ssid, signal=signal, security=sec.strip(),
in_use=in_use.strip() == "*")
cur = best.get(ssid)
if cur is None or net.signal > cur.signal or net.in_use:
best[ssid] = net
return sorted(best.values(), key=lambda n: (not n.in_use, -n.signal))
def connect_argv(ssid: str, password: str = "") -> list[str]:
argv = ["nmcli", "device", "wifi", "connect", ssid]
if password:
argv += ["password", password]
return argv
def saved_connections() -> set[str]:
"""Namen gespeicherter NetworkManager-Verbindungen."""
out = _run(["-t", "-f", "NAME", "connection", "show"])
names: set[str] = set()
for line in out.splitlines():
f = _split(line)
if f and f[0]:
names.add(f[0])
return names
def wifi_device() -> str:
"""Erstes Wi-Fi-Device (z.B. 'wlp3s0'), sonst leer."""
out = _run(["-t", "-f", "DEVICE,TYPE", "device"])
for line in out.splitlines():
f = _split(line)
if len(f) >= 2 and f[1].strip() == "wifi":
return f[0]
return ""
def disconnect_argv() -> list[str]:
"""Disconnect des aktiven Wi-Fi via Device. Leer, wenn kein Device."""
dev = wifi_device()
if not dev:
return []
return ["nmcli", "device", "disconnect", dev]
def forget_argv(ssid: str) -> list[str]:
"""Gespeicherte Verbindung löschen."""
return ["nmcli", "connection", "delete", ssid]
# ----------------------------------------------------------------- VPN
@dataclass
class Vpn:
name: str
vtype: str # 'vpn' | 'wireguard'
active: bool
def vpn_connections() -> list[Vpn]:
active = set()
for line in _run(["-t", "-f", "NAME", "connection", "show", "--active"]).splitlines():
if line.strip():
active.add(line.strip())
out: list[Vpn] = []
for line in _run(["-t", "-f", "NAME,TYPE", "connection", "show"]).splitlines():
parts = _split(line)
if len(parts) >= 2 and parts[1] in ("vpn", "wireguard"):
out.append(Vpn(parts[0], parts[1], parts[0] in active))
return out
def vpn_up_argv(name: str) -> list[str]:
return ["nmcli", "connection", "up", name]
def vpn_down_argv(name: str) -> list[str]:
return ["nmcli", "connection", "down", name]
def vpn_import_argv(path: str) -> list[str] | None:
low = path.lower()
if low.endswith(".ovpn"):
vtype = "openvpn"
elif low.endswith(".conf"):
vtype = "wireguard"
else:
return None
return ["nmcli", "connection", "import", "type", vtype, "file", path]
# ----------------------------------------------------------------- DNS
DNS_PRESETS = {
"Automatic": "",
"Cloudflare": "1.1.1.1 1.0.0.1",
"Google": "8.8.8.8 8.8.4.4",
"Quad9": "9.9.9.9 149.112.112.112",
}
def primary_connection() -> str | None:
for line in _run(["-t", "-f", "NAME,TYPE", "connection", "show", "--active"]).splitlines():
parts = _split(line)
if len(parts) >= 2 and parts[1] in ("802-11-wireless", "802-3-ethernet"):
return parts[0]
return None
def get_dns(name: str) -> str:
return _run(["-g", "ipv4.dns", "connection", "show", name]).strip().replace("|", " ")
def set_dns_cmds(name: str, servers: str) -> list[list[str]]:
"""modify + reaktivieren. servers='' = automatisch (DHCP)."""
if servers:
modify = ["nmcli", "connection", "modify", name,
"ipv4.dns", servers, "ipv4.ignore-auto-dns", "yes"]
else:
modify = ["nmcli", "connection", "modify", name,
"ipv4.dns", "", "ipv4.ignore-auto-dns", "no"]
return [modify, ["nmcli", "connection", "up", name]]
@@ -0,0 +1,135 @@
"""Nextcloud — bindet den vorhandenen Desktop-Client ein. UI-frei.
Liest die Konten aus `~/.config/Nextcloud/nextcloud.cfg` (INI, Keys im Abschnitt
`[Accounts]` mit Index-Präfix wie `0\\url`, `0\\dav_user`, `0\\displayName`),
startet den Client, verwaltet den Autostart und liefert die WebDAV-URI, um ein
Konto als Nautilus-Bookmark zu hinterlegen. Alles User-Level, kein root.
"""
from __future__ import annotations
import configparser
import os
import shutil
import subprocess
from dataclasses import dataclass
def _config_home() -> str:
return os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
def _cfg_path() -> str:
return os.path.join(_config_home(), "Nextcloud", "nextcloud.cfg")
def _autostart_path() -> str:
return os.path.join(_config_home(), "autostart", "Nextcloud.desktop")
def client_installed() -> bool:
return shutil.which("nextcloud") is not None
@dataclass
class Account:
name: str
url: str
dav_user: str
def accounts() -> list[Account]:
"""Konten aus nextcloud.cfg. Robust: leere Liste bei Fehler/fehlender Datei."""
cp = configparser.ConfigParser()
cp.optionxform = str # Groß-/Kleinschreibung der Keys erhalten
try:
cp.read(_cfg_path(), encoding="utf-8")
except (OSError, configparser.Error):
return []
if not cp.has_section("Accounts"):
return []
# Keys nach führendem Index gruppieren: "0\url" → idx "0", key "url".
grouped: dict[str, dict[str, str]] = {}
for key, value in cp.items("Accounts"):
if "\\" not in key:
continue
idx, sub = key.split("\\", 1)
grouped.setdefault(idx, {})[sub] = value
out: list[Account] = []
for idx in sorted(grouped):
data = grouped[idx]
url = data.get("url", "").strip()
dav_user = data.get("dav_user", "").strip()
name = data.get("displayName", "").strip() or dav_user or url
if not url and not dav_user:
continue
out.append(Account(name=name, url=url, dav_user=dav_user))
return out
def webdav_uri_from(server_url: str, user: str) -> str:
"""GVfs-WebDAV-URI (davs:// = HTTPS) aus Server-URL + User bauen — ganz
ohne Client. Nimmt rohe Eingaben (mit/ohne https://, mit/ohne Slash)."""
host = server_url.strip()
for prefix in ("https://", "http://"):
if host.startswith(prefix):
host = host[len(prefix):]
break
host = host.rstrip("/")
return f"davs://{host}/remote.php/dav/files/{user.strip()}/"
def webdav_uri(acc: Account) -> str:
"""WebDAV-URI für ein erkanntes Client-Konto (Komfort)."""
return webdav_uri_from(acc.url, acc.dav_user)
def autostart_enabled() -> bool:
path = _autostart_path()
if not os.path.exists(path):
return False
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
if line.strip().lower() == "hidden=true":
return False
except OSError:
return False
return True
_DESKTOP_ENTRY = (
"[Desktop Entry]\n"
"Type=Application\n"
"Name=Nextcloud\n"
"Exec=nextcloud --background\n"
"Icon=Nextcloud\n"
"Terminal=false\n"
"X-GNOME-Autostart-enabled=true\n"
)
def set_autostart(on: bool) -> None:
"""Autostart über eine .desktop-Datei in ~/.config/autostart steuern."""
path = _autostart_path()
if on:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(_DESKTOP_ENTRY)
else:
try:
os.remove(path)
except FileNotFoundError:
pass
except OSError:
pass
def launch() -> None:
"""Client starten, ohne zu blockieren. Fehler werden geschluckt."""
try:
subprocess.Popen(["nextcloud"], start_new_session=True)
except (OSError, subprocess.SubprocessError):
pass
@@ -0,0 +1,81 @@
"""Index aller pacman-bekannten Namen, gegen die Dotfolder gematcht werden."""
from __future__ import annotations
import re
import subprocess
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
# Pfade, deren Basename ein guter Hint auf einen App-Namen ist
_BIN_PREFIXES = ("/usr/bin/", "/usr/local/bin/", "/usr/lib/")
def _normalize(name: str) -> str:
"""App-Namen für Fuzzy-Match normalisieren: lowercase, ohne -_. Trennzeichen."""
return re.sub(r"[-_.\s]", "", name.lower())
@dataclass
class PackageIndex:
"""Set von normalisierten Tokens (Paketnamen + Binary-Namen),
gegen die ein Dotfolder gematcht werden kann."""
tokens: set[str] = field(default_factory=set)
package_names: set[str] = field(default_factory=set)
def matches(self, dirname: str) -> str | None:
"""Gibt den Original-Paketnamen zurück wenn ein Match gefunden wird."""
norm = _normalize(dirname)
if not norm:
return None
if norm in self.tokens:
for pkg in self.package_names:
if _normalize(pkg) == norm:
return pkg
return norm
# Substring-Match (eine Richtung) - z.B. "discord-canary" matcht "discord"
for pkg in self.package_names:
npkg = _normalize(pkg)
if not npkg or len(npkg) < 3:
continue
if npkg in norm or norm in npkg:
return pkg
return None
@lru_cache(maxsize=1)
def build_index() -> PackageIndex:
"""Index einmalig bauen, danach gecached. Liest pacman -Ql."""
idx = PackageIndex()
try:
result = subprocess.run(
["pacman", "-Ql"],
capture_output=True,
text=True,
check=True,
timeout=30,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
return idx
for line in result.stdout.splitlines():
# Format: "<pkgname> <filepath>"
parts = line.split(" ", 1)
if len(parts) != 2:
continue
pkg, path = parts
idx.package_names.add(pkg)
idx.tokens.add(_normalize(pkg))
# Auch Binary-Basenames als Tokens (z.B. "code" für "code-oss")
for prefix in _BIN_PREFIXES:
if path.startswith(prefix):
tail = path[len(prefix):].rstrip("/")
if "/" in tail:
continue # Subverzeichnisse überspringen, nur direkte Binaries
if tail:
idx.tokens.add(_normalize(tail))
break
return idx
@@ -0,0 +1,46 @@
"""Paket-Zähler — pacman + flatpak. UI-frei.
Schnelle Counts fürs Dashboard. pacman-Queries sind billig; flatpak kann etwas
dauern, daher im Aufrufer am besten async laden.
"""
from __future__ import annotations
import shutil
import subprocess
from taninux.core.pacman import installed_packages
def _count(args: list[str], timeout: int = 8) -> int:
try:
r = subprocess.run(args, capture_output=True, text=True,
check=False, timeout=timeout)
if r.returncode != 0:
return 0
return sum(1 for line in r.stdout.splitlines() if line.strip())
except (FileNotFoundError, subprocess.TimeoutExpired):
return 0
def pacman_total() -> int:
return len(installed_packages())
def pacman_explicit() -> int:
return _count(["pacman", "-Qeq"])
def pacman_foreign() -> int:
"""AUR / nicht-Repo-Pakete."""
return _count(["pacman", "-Qmq"])
def flatpak_installed() -> bool:
return shutil.which("flatpak") is not None
def flatpak_apps() -> int:
"""Anzahl installierter Flatpak-Apps; -1 wenn flatpak fehlt."""
if not flatpak_installed():
return -1
return _count(["flatpak", "list", "--app", "--columns=application"])
@@ -0,0 +1,29 @@
"""Shared pacman queries mit Cache-Invalidation.
Zentral statt in jedem Pillar einen Subprocess-Call. Cache wird gecleart
nach jedem write-Aktion (install/remove)."""
from __future__ import annotations
import subprocess
from functools import lru_cache
@lru_cache(maxsize=1)
def installed_packages() -> frozenset[str]:
try:
r = subprocess.run(
["pacman", "-Qq"],
capture_output=True, text=True, check=False, timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return frozenset()
return frozenset(l.strip() for l in r.stdout.splitlines() if l.strip())
def is_installed(name: str) -> bool:
return name in installed_packages()
def invalidate() -> None:
"""Nach pacman -S/-R aufrufen."""
installed_packages.cache_clear()
@@ -0,0 +1,169 @@
"""Panel & Dock — schreibt panel.json + dock-pins, die eww konsumiert. UI-frei.
Vertrag: docs/eww-panel-dock-integration.md. TANINUX fasst ~/eww NIE direkt an;
es schreibt nur die Config-Dateien und feuert optional den eww-Apply-Hook
(scripts/panelcfg.sh apply) — das ist ein eww-BEREITGESTELLTES Skript, kein
Edit. eww zieht sonst per Watcher (≤2 s) nach.
"""
from __future__ import annotations
import json
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from taninux.core.paths import data_dir
MODULES = ("music", "sys", "updates", "net", "bt", "vol")
_DEFAULTS = {
"version": 1,
"dock": {"autohide": True, "icon_size": 42, "hide_delay": 1.2},
"bar": {
"clock_format": "%H:%M %A, %d.%m.%Y",
"modules": {m: True for m in MODULES},
},
# Update-Scope: gilt für Hub UND den eww-Update-Zähler. AUR ist Opt-in
# (braucht paru/base-devel) — Default Repo + Flatpak.
"updates": {"include_aur": False},
}
def _panel_path() -> Path:
return data_dir() / "panel.json"
def _pins_path() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
return Path(base) / "eww" / "dock-pins"
def _apply_script() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
return Path(base) / "eww" / "scripts" / "panelcfg.sh"
# ----------------------------------------------------------- panel.json
def load() -> dict:
"""panel.json lesen, mit Defaults auffüllen (defensiv)."""
cfg = json.loads(json.dumps(_DEFAULTS)) # deep copy
try:
data = json.loads(_panel_path().read_text())
except (OSError, json.JSONDecodeError):
return cfg
if isinstance(data.get("dock"), dict):
cfg["dock"].update({k: v for k, v in data["dock"].items()
if k in cfg["dock"]})
bar = data.get("bar")
if isinstance(bar, dict):
if isinstance(bar.get("clock_format"), str):
cfg["bar"]["clock_format"] = bar["clock_format"]
if isinstance(bar.get("modules"), dict):
for m in MODULES:
if m in bar["modules"]:
cfg["bar"]["modules"][m] = bool(bar["modules"][m])
upd = data.get("updates")
if isinstance(upd, dict) and "include_aur" in upd:
cfg["updates"]["include_aur"] = bool(upd["include_aur"])
return cfg
def save(cfg: dict, *, apply: bool = True) -> None:
"""panel.json schreiben (mit Clamping) und optional eww anwenden lassen."""
dock = cfg.get("dock", {})
try:
icon = int(dock.get("icon_size", 42))
except (TypeError, ValueError):
icon = 42
try:
delay = float(dock.get("hide_delay", 1.2))
except (TypeError, ValueError):
delay = 1.2
out = {
"version": 1,
"dock": {
"autohide": bool(dock.get("autohide", True)),
"icon_size": max(24, min(64, icon)),
"hide_delay": max(0.2, min(5.0, round(delay, 1))),
},
"bar": {
"clock_format": str(cfg.get("bar", {}).get("clock_format")
or _DEFAULTS["bar"]["clock_format"]),
"modules": {
m: bool(cfg.get("bar", {}).get("modules", {}).get(m, True))
for m in MODULES
},
},
"updates": {
"include_aur": bool(cfg.get("updates", {}).get("include_aur", False)),
},
}
p = _panel_path()
try:
p.write_text(json.dumps(out, indent=2))
except OSError:
return
if apply:
apply_now()
def include_aur() -> bool:
"""Ob AUR im Update-/Such-Scope ist (Opt-in). Single source: panel.json."""
return bool(load().get("updates", {}).get("include_aur", False))
def set_include_aur(value: bool) -> None:
"""AUR-Scope umschalten — panel.json aktualisieren + eww anwenden lassen."""
cfg = load()
cfg.setdefault("updates", {})["include_aur"] = bool(value)
save(cfg, apply=True)
def apply_now() -> None:
"""eww-Apply-Hook feuern (best-effort). Skript gehört eww, wird nur ausgeführt."""
script = _apply_script()
if not script.exists():
return
try:
subprocess.run([str(script), "apply"], check=False, timeout=5)
except (FileNotFoundError, subprocess.TimeoutExpired, PermissionError):
pass
# ----------------------------------------------------------- dock-pins
@dataclass
class Pin:
exec: str
wmclass: str
icon: str
def load_pins() -> list[Pin]:
"""dock-pins lesen — Zeilen `exec|class|icon`."""
pins: list[Pin] = []
try:
text = _pins_path().read_text()
except OSError:
return pins
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("|")
if len(parts) >= 3:
pins.append(Pin(parts[0].strip(), parts[1].strip(), parts[2].strip()))
return pins
def save_pins(pins: list[Pin]) -> None:
"""dock-pins komplett neu schreiben (idempotent; eww pollt alle 2 s)."""
lines = [f"{p.exec}|{p.wmclass}|{p.icon}" for p in pins]
path = _pins_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + ("\n" if lines else ""))
except OSError:
pass
@@ -0,0 +1,34 @@
"""XDG-konforme Pfade für taninux."""
from __future__ import annotations
import os
from pathlib import Path
def data_dir() -> Path:
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
d = Path(base) / "taninux"
d.mkdir(parents=True, exist_ok=True)
return d
def trash_dir() -> Path:
d = data_dir() / "trash"
d.mkdir(parents=True, exist_ok=True)
return d
def state_file() -> Path:
return data_dir() / "state.json"
def bundles_dir() -> Path:
d = data_dir() / "bundles"
d.mkdir(parents=True, exist_ok=True)
return d
def snapshots_dir() -> Path:
d = data_dir() / "snapshots"
d.mkdir(parents=True, exist_ok=True)
return d
@@ -0,0 +1,154 @@
"""Pacman-Pakete-Metadaten (name/version/size/reason/repo/desc).
Bevorzugt `expac` für Geschwindigkeit (parse alle 6k pakete in <500ms).
Fallback auf `pacman -Qi` falls expac nicht installiert (~5s)."""
from __future__ import annotations
import re
import shutil
import subprocess
from dataclasses import dataclass
_SIZE_RE = re.compile(r"([\d.]+)\s*([KMGT]i?B|B)")
_UNITS = {
"B": 1, "KiB": 1024, "MiB": 1024**2, "GiB": 1024**3, "TiB": 1024**4,
"KB": 1000, "MB": 1000**2, "GB": 1000**3, "TB": 1000**4,
}
def _parse_size(s: str) -> int:
m = _SIZE_RE.search(s)
if not m:
return 0
val = float(m.group(1))
return int(val * _UNITS.get(m.group(2), 1))
@dataclass
class Package:
name: str
version: str
size_bytes: int
reason: str # 'explicit' / 'dependency'
repo: str # 'core' / 'extra' / 'multilib' / 'aur' (foreign) / 'local'
desc: str = ""
is_orphan: bool = False
def _foreign_set() -> set[str]:
try:
r = subprocess.run(
["pacman", "-Qmq"],
capture_output=True, text=True, check=False, timeout=10,
)
return {l.strip() for l in r.stdout.splitlines() if l.strip()}
except (FileNotFoundError, subprocess.TimeoutExpired):
return set()
def _orphan_set() -> set[str]:
try:
r = subprocess.run(
["pacman", "-Qdtq"],
capture_output=True, text=True, check=False, timeout=10,
)
return {l.strip() for l in r.stdout.splitlines() if l.strip()}
except (FileNotFoundError, subprocess.TimeoutExpired):
return set()
def _via_expac() -> list[Package]:
try:
r = subprocess.run(
["expac", "-Q", "%n\t%v\t%m\t%w\t%r\t%d"],
capture_output=True, text=True, check=False, timeout=30,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
out: list[Package] = []
for line in r.stdout.splitlines():
parts = line.split("\t")
if len(parts) < 5:
continue
try:
size = int(parts[2])
except ValueError:
size = 0
out.append(Package(
name=parts[0],
version=parts[1],
size_bytes=size,
reason=parts[3] or "explicit",
repo=parts[4] or "local",
desc=parts[5] if len(parts) > 5 else "",
))
return out
def _via_pacman() -> list[Package]:
try:
r = subprocess.run(
["pacman", "-Qi"],
capture_output=True, text=True, check=False, timeout=60,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
out: list[Package] = []
cur: dict[str, object] = {}
for line in r.stdout.splitlines():
if not line.strip():
if cur.get("name"):
out.append(Package(
name=str(cur["name"]),
version=str(cur.get("version", "")),
size_bytes=int(cur.get("size", 0)),
reason=str(cur.get("reason", "explicit")),
repo=str(cur.get("repo", "local")),
desc=str(cur.get("desc", "")),
))
cur = {}
continue
if ":" not in line:
continue
key, _, val = line.partition(":")
key = key.strip().lower()
val = val.strip()
if key == "name":
cur["name"] = val
elif key == "version":
cur["version"] = val
elif key == "installed size":
cur["size"] = _parse_size(val)
elif key == "install reason":
cur["reason"] = "explicit" if "explicit" in val.lower() else "dependency"
elif key == "repository":
cur["repo"] = val
elif key == "description":
cur["desc"] = val
if cur.get("name"):
out.append(Package(
name=str(cur["name"]),
version=str(cur.get("version", "")),
size_bytes=int(cur.get("size", 0)),
reason=str(cur.get("reason", "explicit")),
repo=str(cur.get("repo", "local")),
desc=str(cur.get("desc", "")),
))
return out
def all_installed() -> list[Package]:
if shutil.which("expac"):
pkgs = _via_expac()
else:
pkgs = _via_pacman()
foreign = _foreign_set()
orphans = _orphan_set()
for p in pkgs:
if p.name in foreign:
p.repo = "aur"
if p.name in orphans:
p.is_orphan = True
return pkgs
@@ -0,0 +1,271 @@
"""Power: Akku-Info (read) + Idle-Timeouts (niri-only über swayidle).
Akku/AC kommen aus /sys/class/power_supply (reiner Read). Idle-Timeouts
(Bildschirm aus / Suspend) verwaltet TANINUX über swayidle: niri implementiert
das ext-idle-notify-Protokoll, daher ist swayidle der passende Idle-Daemon.
Verwaltet wird ein eigener taninux-swayidle.service (swayidle hat KEINE
Config-Datei — alles steht in den ExecStart-Args); der Bildschirm wird per
`niri msg action power-off-monitors` ab- und wieder angeschaltet.
Die niri-Config (config.kdl) wird nicht angefasst; Autostart läuft über den
systemd-User-Service.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
_PS = Path("/sys/class/power_supply")
SWAYIDLE_UNIT = (
Path.home() / ".config" / "systemd" / "user" / "taninux-swayidle.service"
)
# ----------------------------------------------------------- Akku / AC
@dataclass
class Battery:
name: str
capacity: int # Prozent, -1 = unbekannt
status: str # Charging / Discharging / Full / ?
def _read(p: Path) -> str:
try:
return p.read_text().strip()
except OSError:
return ""
def batteries() -> list[Battery]:
out: list[Battery] = []
if not _PS.is_dir():
return out
for d in sorted(_PS.iterdir()):
if _read(d / "type") == "Battery":
cap = _read(d / "capacity")
out.append(Battery(
name=d.name,
capacity=int(cap) if cap.isdigit() else -1,
status=_read(d / "status") or "?",
))
return out
def ac_online() -> bool | None:
"""True/False wenn ein AC-Adapter gefunden wird, sonst None."""
if not _PS.is_dir():
return None
for d in sorted(_PS.iterdir()):
if _read(d / "type") == "Mains" or d.name.startswith(("AC", "ADP")):
v = _read(d / "online")
if v in ("0", "1"):
return v == "1"
return None
# --------------------------------------------------- Energy-Mode (PPD)
def _out(args: list[str], timeout: int = 5) -> str:
try:
r = subprocess.run(args, capture_output=True, text=True,
check=False, timeout=timeout)
return r.stdout if r.returncode == 0 else ""
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
def ppd_installed() -> bool:
return shutil.which("powerprofilesctl") is not None
def power_profiles() -> list[str]:
"""Verfügbare Profile aus `powerprofilesctl list` (z.B. performance,
balanced, power-saver). Leer wenn Daemon nicht läuft."""
profs: list[str] = []
for line in _out(["powerprofilesctl", "list"]).splitlines():
s = line.strip()
if s.startswith("* "): # aktiv markiert
s = s[2:]
if re.fullmatch(r"[a-z][a-z-]*:", s): # reine "name:"-Zeile
profs.append(s[:-1])
return profs
def current_profile() -> str:
return _out(["powerprofilesctl", "get"]).strip()
def set_profile(name: str) -> None:
try:
subprocess.run(["powerprofilesctl", "set", name],
check=False, timeout=5)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# ------------------------------------------------- Bildschirmhelligkeit
def brightness_available() -> bool:
"""True, wenn brightnessctl da ist UND es ein Backlight-Gerät gibt.
Desktops ohne Backlight → False (Gruppe wird dann weggelassen)."""
if shutil.which("brightnessctl") is None:
return False
# `-c backlight info` gibt rc 0 nur bei vorhandenem Backlight.
try:
r = subprocess.run(
["brightnessctl", "-c", "backlight", "info"],
capture_output=True, text=True, check=False, timeout=5,
)
if r.returncode == 0:
return True
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
# Fallback: device-Liste nach class 'backlight' durchsuchen.
return "class 'backlight'" in _out(["brightnessctl", "-l"])
def brightness_percent() -> int:
"""Aktuelle Helligkeit in Prozent. 0 bei Fehler."""
# machine-readable: device,class,current,percent,max — Feld 4 ist "NN%".
line = _out(["brightnessctl", "-m", "-c", "backlight", "info"]).strip()
if line:
fields = line.splitlines()[0].split(",")
if len(fields) >= 4:
pct = fields[3].rstrip("%")
if pct.isdigit():
return int(pct)
# Fallback: aus get/max berechnen.
cur = _out(["brightnessctl", "-c", "backlight", "get"]).strip()
mx = _out(["brightnessctl", "-c", "backlight", "max"]).strip()
if cur.isdigit() and mx.isdigit() and int(mx) > 0:
return round(int(cur) * 100 / int(mx))
return 0
def set_brightness_argv(pct: int) -> list[str]:
"""argv zum Setzen der Helligkeit (min 1 %, nie 0)."""
return ["brightnessctl", "-c", "backlight", "set",
f"{max(1, min(100, int(pct)))}%"]
# ----------------------------------------------------------- swayidle (niri)
def swayidle_installed() -> bool:
return shutil.which("swayidle") is not None
def _read_idle_swayidle() -> dict[str, int]:
"""Liest screen_off/suspend (Sekunden) aus der ExecStart-Zeile des
taninux-swayidle.service. swayidle hat keine Config-Datei — die Timeouts
stehen als `timeout <sec> '<cmd>'`-Paare in den Args. 0 = aus."""
res = {"screen_off": 0, "suspend": 0}
if not SWAYIDLE_UNIT.is_file():
return res
text = SWAYIDLE_UNIT.read_text(errors="ignore")
m = re.search(r"^\s*ExecStart\s*=\s*(.+)$", text, re.MULTILINE)
if not m:
return res
exec_line = m.group(1)
# Jedes timeout-Paar: timeout <sec> '<cmd>' — der cmd entscheidet das Ziel.
for tm in re.finditer(r"timeout\s+(\d+)\s+(['\"])(.*?)\2", exec_line):
sec = int(tm.group(1))
cmd = tm.group(3).lower()
if "power-off-monitors" in cmd or "dpms" in cmd:
res["screen_off"] = sec
elif "suspend" in cmd:
res["suspend"] = sec
return res
def _write_idle_swayidle(screen_off: int, suspend: int) -> Path:
"""Schreibt die TANINUX-verwaltete swayidle-User-Unit. Nur Listener mit
Wert >0 kommen in die ExecStart-Zeile. Der Bildschirm wird per niri-Action
(DPMS) ab-/angeschaltet; die niri-Config bleibt unberührt."""
swayidle = shutil.which("swayidle") or "/usr/bin/swayidle"
parts = [swayidle, "-w"]
if screen_off > 0:
parts += [
"timeout", str(screen_off),
"'niri msg action power-off-monitors'",
"resume", "'niri msg action power-on-monitors'",
]
if suspend > 0:
parts += ["timeout", str(suspend), "'systemctl suspend'"]
exec_start = " ".join(parts)
lines = [
"[Unit]",
"Description=TANINUX-managed swayidle (idle timeouts)",
"Documentation=man:swayidle(1)",
"PartOf=graphical-session.target",
"",
"[Service]",
"Type=simple",
f"ExecStart={exec_start}",
"Restart=on-failure",
"",
"[Install]",
"WantedBy=default.target",
"",
]
SWAYIDLE_UNIT.parent.mkdir(parents=True, exist_ok=True)
SWAYIDLE_UNIT.write_text("\n".join(lines))
return SWAYIDLE_UNIT
def apply_swayidle() -> bool:
"""daemon-reload + User-Service enablen/(re)starten. True wenn ausgeführt."""
if not swayidle_installed():
return False
try:
subprocess.run(
["systemctl", "--user", "daemon-reload"],
check=False, timeout=8,
)
subprocess.run(
["systemctl", "--user", "enable", "--now",
"taninux-swayidle.service"],
check=False, timeout=8,
)
subprocess.run(
["systemctl", "--user", "restart", "taninux-swayidle.service"],
check=False, timeout=8,
)
return True
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
# -------------------------------------------------- Idle (niri-only)
def idle_daemon_name() -> str:
"""Idle-Daemon für niri — swayidle (spricht ext-idle-notify)."""
return "swayidle"
def idle_pkg() -> str:
"""Paketname für pacman — identisch zum Daemon-Namen."""
return idle_daemon_name()
def idle_daemon_installed() -> bool:
return swayidle_installed()
def read_idle_timeouts() -> dict[str, int]:
"""screen_off/suspend (Sekunden) aus der swayidle-Unit lesen. 0 = aus."""
return _read_idle_swayidle()
def write_idle_config(screen_off: int, suspend: int) -> Path:
"""swayidle-Unit schreiben. 0 = Listener weglassen. Gibt den Pfad zurück."""
return _write_idle_swayidle(screen_off, suspend)
def apply_idle() -> bool:
"""Idle-Daemon anwenden (best-effort, unprivilegiert)."""
return apply_swayidle()
@@ -0,0 +1,125 @@
"""Printers: CUPS-Druckerverwaltung (read + argv für Schreib-Aktionen).
Read kommt aus den CUPS-Clienttools (lpstat). Schreib-Aktionen werden NICHT
hier ausgeführt — wir liefern nur das argv, das der GUI-Aufrufer (privilegiert
via Helper oder unprivilegiert) selbst startet. So bleibt das Modul UI-frei.
Drucker HINZUFÜGEN (Treiberauswahl) ist bewusst nicht enthalten — das gehört
in ein dediziertes System-Tool.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from dataclasses import dataclass
def available() -> bool:
"""True, wenn die CUPS-Clienttools (lpstat) vorhanden sind."""
return shutil.which("lpstat") is not None
@dataclass
class Printer:
name: str
info: str # Location o.ä., leer wenn unbekannt
state: str # "idle" / "printing" / "stopped"
enabled: bool
is_default: bool
def _run(argv: list[str]) -> str:
"""Führt ein read-only Kommando aus, gibt stdout zurück (leer bei Fehler)."""
try:
out = subprocess.run(
argv, capture_output=True, text=True, timeout=10, check=False
)
except (OSError, subprocess.SubprocessError):
return ""
return out.stdout
def _info_map() -> dict[str, str]:
"""Location pro Drucker aus `lpstat -l -p` (optional, best effort)."""
info: dict[str, str] = {}
name: str | None = None
for line in _run(["lpstat", "-l", "-p"]).splitlines():
m = re.match(r"printer (\S+) ", line)
if m:
name = m.group(1)
continue
stripped = line.strip()
if name and stripped.lower().startswith("location:"):
info[name] = stripped.split(":", 1)[1].strip()
return info
def printers() -> list[Printer]:
"""Parst `lpstat -p -d`.
Zeilen sehen so aus:
printer Brother is idle. enabled since …
printer HP_LaserJet disabled since … - (stopped, …)
system default destination: Brother
"""
out = _run(["lpstat", "-p", "-d"])
default = ""
result: list[Printer] = []
for line in out.splitlines():
m = re.match(r"system default destination:\s*(\S+)", line)
if m:
default = m.group(1)
continue
m = re.match(r"printer (\S+) (?:is )?(\S+)", line)
if not m:
continue
name = m.group(1)
word = m.group(2).rstrip(".").lower()
if word == "disabled":
state, enabled = "stopped", False
elif word == "printing":
state, enabled = "printing", True
else:
state, enabled = "idle", True
result.append(Printer(name=name, info="", state=state,
enabled=enabled, is_default=False))
locations = _info_map()
for p in result:
p.info = locations.get(p.name, "")
p.is_default = (p.name == default)
return result
def queue() -> list[str]:
"""Job-Zeilen aus `lpstat -o`. Leere Liste, wenn nichts in der Queue."""
return [ln for ln in _run(["lpstat", "-o"]).splitlines() if ln.strip()]
# ----------------------------------------------------------- argv (Aufrufer führt aus)
def set_default_argv(name: str) -> list[str]:
"""Default-Drucker setzen (root → via Helper)."""
return ["lpadmin", "-d", name]
def enable_argv(name: str) -> list[str]:
"""Drucker freigeben (root → via Helper)."""
return ["cupsenable", name]
def disable_argv(name: str) -> list[str]:
"""Drucker anhalten (root → via Helper)."""
return ["cupsdisable", name]
def remove_argv(name: str) -> list[str]:
"""Drucker entfernen (root → via Helper)."""
return ["lpadmin", "-x", name]
def cancel_all_argv() -> list[str]:
"""Gesamte Druck-Queue leeren (unprivilegiert)."""
return ["cancel", "-a"]
@@ -0,0 +1,51 @@
"""Subprocess-Runner: suspendet die TUI und führt Befehle interaktiv aus.
So kann pacman/paru/sudo seinen eigenen Passwort-Prompt + Progress
direkt im Terminal anzeigen, ohne dass wir parsen müssen.
"""
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from textual.app import App
@dataclass
class Command:
label: str
argv: list[str]
def run_interactive(app: App, commands: list[Command]) -> list[int]:
"""Suspendet die App, führt jeden Befehl nacheinander aus, kehrt zurück.
Returns: liste der exit codes (in Reihenfolge der Befehle).
"""
codes: list[int] = []
with app.suspend():
print("\n" + "" * 64)
print(f" TANINUX — {len(commands)} befehl(e)")
print("" * 64)
for cmd in commands:
print()
print(f"┌── {cmd.label} " + "" * max(0, 56 - len(cmd.label)))
print(f"│ $ {' '.join(cmd.argv)}")
print("" + "" * 63)
try:
rc = subprocess.run(cmd.argv, check=False).returncode
except KeyboardInterrupt:
print("\n[abgebrochen]")
codes.append(130)
break
except FileNotFoundError as e:
print(f"[fehler: {e}]")
rc = 127
codes.append(rc)
print()
print("" * 64)
try:
input(" ENTER zum zurück zu TANINUX … ")
except (EOFError, KeyboardInterrupt):
pass
return codes
@@ -0,0 +1,152 @@
"""Paket-Suche über Repo + AUR (paru) und Flatpak. UI-frei.
`paru -Ss` deckt offizielle Repos UND AUR in einem Aufruf ab; Flatpak wird
separat durchsucht. Ergebnisse werden zu einem einheitlichen `Result` normiert.
Install/Remove laufen als User: paru eskaliert den pacman-Teil selbst über
`--sudo pkexec` (grafischer Polkit-Dialog, kein TTY nötig); Flatpak braucht
ohnehin kein root.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from dataclasses import dataclass, field
MAX_RESULTS = 60
_HEAD = re.compile(r"^(?P<repo>[\w.-]+)/(?P<name>\S+)\s+(?P<ver>\S+)(?P<rest>.*)$")
@dataclass
class Result:
source: str # 'repo' | 'aur' | 'flatpak'
name: str # Paketname (Repo/AUR) bzw. Flatpak-App-ID
version: str
description: str = ""
installed: bool = False
repo: str = "" # Repo-Name bzw. Flatpak-Remote
extra: dict[str, str] = field(default_factory=dict)
def _helper() -> str | None:
for h in ("paru", "yay"):
if shutil.which(h):
return h
return None
def _search_pacman(query: str, include_aur: bool) -> list[Result]:
# AUR nur via paru/yay; ohne AUR plain pacman (kein paru/base-devel nötig).
helper = _helper() if include_aur else None
argv = [helper, "-Ss", query] if helper else ["pacman", "-Ss", query]
try:
r = subprocess.run(
argv, capture_output=True, text=True, check=False, timeout=45,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
if r.returncode not in (0, 1):
return []
out: list[Result] = []
cur: Result | None = None
for line in r.stdout.splitlines():
if line[:1].isspace():
if cur is not None and not cur.description:
cur.description = line.strip()
continue
m = _HEAD.match(line)
if not m:
continue
repo = m.group("repo")
cur = Result(
source="aur" if repo == "aur" else "repo",
name=m.group("name"),
version=m.group("ver"),
installed="[installed" in m.group("rest").lower(),
repo=repo,
)
out.append(cur)
return out
def _flatpak_installed_ids() -> set[str]:
try:
r = subprocess.run(
["flatpak", "list", "--app", "--columns=application"],
capture_output=True, text=True, check=False, timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return set()
return {ln.strip() for ln in r.stdout.splitlines() if ln.strip()}
def _search_flatpak(query: str) -> list[Result]:
if not shutil.which("flatpak"):
return []
try:
r = subprocess.run(
["flatpak", "search",
"--columns=application,version,name,description,remotes", query],
capture_output=True, text=True, check=False, timeout=30,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
if r.returncode != 0:
return []
installed = _flatpak_installed_ids()
out: list[Result] = []
for line in r.stdout.splitlines():
s = line.strip()
if not s or s.lower().startswith("application"):
continue
parts = s.split("\t")
if len(parts) < 1:
continue
app = parts[0]
ver = parts[1] if len(parts) > 1 and parts[1] != "" else "?"
name = parts[2] if len(parts) > 2 else app
desc = parts[3] if len(parts) > 3 else ""
remote = parts[4].split(",")[0] if len(parts) > 4 and parts[4] else "flathub"
out.append(Result(
source="flatpak",
name=app,
version=ver,
description=desc if desc and desc != name else "",
installed=app in installed,
repo=remote,
extra={"title": name},
))
return out
def search(query: str, include_aur: bool | None = None) -> list[Result]:
"""Repo (+AUR falls Opt-in) und Flatpak durchsuchen, zusammengeführt."""
if include_aur is None:
from taninux.core import panel
include_aur = panel.include_aur()
query = query.strip()
if len(query) < 2:
return []
results = _search_pacman(query, include_aur) + _search_flatpak(query)
return results[:MAX_RESULTS]
def install_argv(item: Result) -> tuple[list[str], bool]:
"""(argv, privileged). Repo läuft über den Helper (pacman), AUR über paru."""
if item.source == "flatpak":
return ["flatpak", "install", "--assumeyes", item.repo, item.name], False
if item.source == "aur":
helper = _helper() or "paru"
return [helper, "--sudo", "pkexec", "-S", "--noconfirm", item.name], False
return ["pacman", "-S", "--needed", "--noconfirm", item.name], True
def remove_argv(item: Result) -> tuple[list[str], bool]:
"""(argv, privileged). Entfernen baut nie — Repo+AUR über den Helper."""
if item.source == "flatpak":
return ["flatpak", "uninstall", "--assumeyes", item.name], False
return ["pacman", "-Rns", "--noconfirm", item.name], True
@@ -0,0 +1,153 @@
"""Network Shares — Remote-Bookmarks für den Dateimanager (Nautilus). UI-frei.
Verwaltet Remote-Verbindungen (SFTP/FTP/SMB/WebDAV/NFS) als GTK-3-Bookmarks
in `~/.config/gtk-3.0/bookmarks` plus `gio mount` zum Verbinden. Alles
User-Level: GVfs läuft im Session-Bus, kein root. Lokale `file://`-Bookmarks
werden NIE angefasst — nur Remote-Zeilen (scheme in REMOTE_SCHEMES).
SMB/NFS brauchen die GVfs-Backends `gvfs-smb`/`gvfs-nfs` (eigene Pakete).
"""
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass
REMOTE_SCHEMES = {"sftp", "ftp", "smb", "dav", "davs", "nfs"}
def _config_home() -> str:
return os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
def bookmarks_path() -> str:
return os.path.join(_config_home(), "gtk-3.0", "bookmarks")
@dataclass
class Bookmark:
uri: str
label: str
scheme: str
def _scheme(uri: str) -> str:
return uri.split("://", 1)[0].lower() if "://" in uri else ""
def _read_lines() -> list[str]:
try:
with open(bookmarks_path(), encoding="utf-8") as fh:
return [ln.rstrip("\n") for ln in fh]
except OSError:
return []
def list_remote() -> list[Bookmark]:
"""Alle Remote-Bookmarks (scheme in REMOTE_SCHEMES). file:// bleibt außen vor."""
out: list[Bookmark] = []
for line in _read_lines():
line = line.strip()
if not line:
continue
parts = line.split(maxsplit=1)
uri = parts[0]
label = parts[1] if len(parts) > 1 else uri
sch = _scheme(uri)
if sch in REMOTE_SCHEMES:
out.append(Bookmark(uri=uri, label=label, scheme=sch))
return out
def build_uri(scheme: str, host: str, path: str = "",
user: str = "", port: str = "") -> str:
"""Baut eine GVfs-URI, z. B. `sftp://user@host:2222/srv`.
user/port optional. path wird mit führendem „/" normalisiert.
SMB-Konvention: `smb://host/share` (share = path ohne führenden Slash-Zwang,
wird aber ebenfalls über den Slash drangehängt).
"""
scheme = scheme.strip().lower()
host = host.strip()
user = user.strip()
port = port.strip()
path = path.strip()
authority = host
if user:
authority = f"{user}@{authority}"
if port:
authority = f"{authority}:{port}"
uri = f"{scheme}://{authority}"
if path:
if not path.startswith("/"):
path = "/" + path
uri += path
return uri
def add_bookmark(uri: str, label: str) -> None:
"""Remote-Bookmark hinzufügen (idempotent). Alle anderen Zeilen — inkl.
lokaler file://-Bookmarks — bleiben unverändert erhalten."""
uri = uri.strip()
if not uri:
return
lines = _read_lines()
# Vorhandenen Eintrag mit derselben URI entfernen (Duplikate vermeiden).
kept = [ln for ln in lines if ln.strip().split(maxsplit=1)[:1] != [uri]]
entry = uri if not label.strip() else f"{uri} {label.strip()}"
kept.append(entry)
_write_lines(kept)
def remove_bookmark(uri: str) -> None:
"""Entfernt genau die Bookmark-Zeile(n) mit dieser URI. Alles andere bleibt."""
uri = uri.strip()
lines = _read_lines()
kept = [ln for ln in lines if ln.strip().split(maxsplit=1)[:1] != [uri]]
_write_lines(kept)
def _write_lines(lines: list[str]) -> None:
path = bookmarks_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
# Leere Zeilen am Ende vermeiden, genau ein abschließendes Newline.
body = "\n".join(ln for ln in lines if ln.strip() != "")
with open(path, "w", encoding="utf-8") as fh:
fh.write(body + ("\n" if body else ""))
def mount_argv(uri: str) -> list[str]:
return ["gio", "mount", uri]
def unmount_argv(uri: str) -> list[str]:
return ["gio", "mount", "-u", uri]
def _pkg_installed(name: str) -> bool:
"""True, wenn das Pacman-Paket installiert ist (rc 0). GVfs-Backends sind
Libs ohne eigenes Binary, deshalb Paketabfrage statt shutil.which."""
try:
r = subprocess.run(["pacman", "-Q", name], capture_output=True,
text=True, timeout=6, check=False)
return r.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def smb_available() -> bool:
return _pkg_installed("gvfs-smb")
def nfs_available() -> bool:
return _pkg_installed("gvfs-nfs")
def install_smb_argv() -> list[str]:
return ["pacman", "-S", "--needed", "--noconfirm", "gvfs-smb"]
def install_nfs_argv() -> list[str]:
return ["pacman", "-S", "--needed", "--noconfirm", "gvfs-nfs"]
@@ -0,0 +1,137 @@
"""Snapshot/Drift: speichere den Paket-Stand und vergleiche über Zeit.
Snapshots leben unter ~/.local/share/taninux/snapshots/<ts>.json mit:
- timestamp
- explicit: pacman -Qenq (explicit installiert, native repos)
- aur: pacman -Qmq (foreign — meist AUR)
- deps: pacman -Qdq (als dependency installiert)
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from taninux.core.paths import snapshots_dir
@dataclass
class Snapshot:
id: str # e.g. '20260526T195430'
path: Path
timestamp: str
label: str
explicit: set[str] = field(default_factory=set)
aur: set[str] = field(default_factory=set)
deps: set[str] = field(default_factory=set)
@property
def all_pkgs(self) -> set[str]:
return self.explicit | self.aur | self.deps
def _pacman_qq(args: list[str]) -> set[str]:
try:
r = subprocess.run(
["pacman", *args],
capture_output=True, text=True, check=False, timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return set()
return {l.strip() for l in r.stdout.splitlines() if l.strip()}
def take_snapshot(label: str | None = None) -> Snapshot:
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
iso = datetime.now().isoformat(timespec="seconds")
snapshot_id = ts
path = snapshots_dir() / f"{snapshot_id}.json"
explicit = _pacman_qq(["-Qenq"])
aur = _pacman_qq(["-Qmq"])
deps = _pacman_qq(["-Qdq"])
payload = {
"id": snapshot_id,
"label": label or "manual",
"timestamp": iso,
"explicit": sorted(explicit),
"aur": sorted(aur),
"deps": sorted(deps),
}
path.write_text(json.dumps(payload, indent=2))
return Snapshot(
id=snapshot_id,
path=path,
timestamp=iso,
label=payload["label"],
explicit=explicit,
aur=aur,
deps=deps,
)
def list_snapshots() -> list[Snapshot]:
out: list[Snapshot] = []
for p in sorted(snapshots_dir().glob("*.json")):
try:
data = json.loads(p.read_text())
except (OSError, json.JSONDecodeError):
continue
out.append(Snapshot(
id=data.get("id", p.stem),
path=p,
timestamp=data.get("timestamp", ""),
label=data.get("label", ""),
explicit=set(data.get("explicit", [])),
aur=set(data.get("aur", [])),
deps=set(data.get("deps", [])),
))
return out
def load(snapshot_id: str) -> Snapshot | None:
for s in list_snapshots():
if s.id == snapshot_id:
return s
return None
@dataclass
class Diff:
added_explicit: list[str]
removed_explicit: list[str]
added_aur: list[str]
removed_aur: list[str]
added_deps: list[str]
removed_deps: list[str]
def current_snapshot() -> Snapshot:
return Snapshot(
id="current",
path=Path("/dev/null"),
timestamp=datetime.now().isoformat(timespec="seconds"),
label="now",
explicit=_pacman_qq(["-Qenq"]),
aur=_pacman_qq(["-Qmq"]),
deps=_pacman_qq(["-Qdq"]),
)
def diff(old: Snapshot, new: Snapshot) -> Diff:
def sorted_list(s: set[str]) -> list[str]:
return sorted(s)
return Diff(
added_explicit=sorted_list(new.explicit - old.explicit),
removed_explicit=sorted_list(old.explicit - new.explicit),
added_aur=sorted_list(new.aur - old.aur),
removed_aur=sorted_list(old.aur - new.aur),
added_deps=sorted_list(new.deps - old.deps),
removed_deps=sorted_list(old.deps - new.deps),
)
def latest() -> Snapshot | None:
snaps = list_snapshots()
return snaps[-1] if snaps else None
@@ -0,0 +1,69 @@
"""Persistenter User-State: Whitelist (immer behalten) + Ignored (nicht mehr anzeigen)."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from taninux.core.paths import state_file
@dataclass
class State:
whitelist: set[Path] = field(default_factory=set)
ignored: set[Path] = field(default_factory=set)
# ---- load / save ----
@classmethod
def load(cls) -> "State":
f = state_file()
if not f.exists():
return cls()
try:
data = json.loads(f.read_text())
except (json.JSONDecodeError, OSError):
return cls()
return cls(
whitelist={Path(p) for p in data.get("whitelist", [])},
ignored={Path(p) for p in data.get("ignored", [])},
)
def save(self) -> None:
f = state_file()
payload = {
"version": 1,
"whitelist": sorted(str(p) for p in self.whitelist),
"ignored": sorted(str(p) for p in self.ignored),
}
f.write_text(json.dumps(payload, indent=2))
# ---- queries ----
def is_whitelisted(self, p: Path) -> bool:
return p in self.whitelist
def is_ignored(self, p: Path) -> bool:
return p in self.ignored
# ---- mutations (auto-save) ----
def whitelist_add(self, p: Path) -> None:
self.whitelist.add(p)
self.ignored.discard(p)
self.save()
def whitelist_remove(self, p: Path) -> None:
if p in self.whitelist:
self.whitelist.discard(p)
self.save()
def ignore_add(self, p: Path) -> None:
self.ignored.add(p)
self.whitelist.discard(p)
self.save()
def ignore_remove(self, p: Path) -> None:
if p in self.ignored:
self.ignored.discard(p)
self.save()
@@ -0,0 +1,73 @@
"""Laufwerke und Belegung. UI-frei. Lesen via lsblk; mount/eject via udisksctl
(user-level, polkit-backed — kein root-Helper nötig)."""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
@dataclass
class Volume:
dev: str # /dev/sdX1
size: int # bytes
fstype: str
label: str
mountpoint: str
removable: bool
used: int # bytes (0 wenn nicht gemountet)
use_pct: int # 0..100
def _lsblk() -> list[dict]:
try:
r = subprocess.run(
["lsblk", "-J", "-b", "-o",
"NAME,PATH,SIZE,TYPE,MOUNTPOINT,FSTYPE,LABEL,RM,FSUSED,FSUSE%"],
capture_output=True, text=True, check=False, timeout=8,
)
return json.loads(r.stdout).get("blockdevices", [])
except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError):
return []
def _walk(node: dict, removable: bool, out: list[Volume]) -> None:
rm = removable or node.get("rm", False)
if (node.get("fstype") and node.get("fstype") != "swap"
and node.get("type") in ("part", "crypt", "lvm", "disk")):
pct = node.get("fsuse%") or ""
try:
use_pct = int(str(pct).rstrip("%")) if pct else 0
except ValueError:
use_pct = 0
out.append(Volume(
dev=node.get("path", node.get("name", "")),
size=int(node.get("size") or 0),
fstype=node.get("fstype", ""),
label=node.get("label") or "",
mountpoint=node.get("mountpoint") or "",
removable=bool(rm),
used=int(node.get("fsused") or 0),
use_pct=use_pct,
))
for child in node.get("children", []):
_walk(child, rm, out)
def volumes() -> list[Volume]:
out: list[Volume] = []
for dev in _lsblk():
_walk(dev, False, out)
return out
def mount_argv(dev: str) -> list[str]:
return ["udisksctl", "mount", "-b", dev]
def unmount_argv(dev: str) -> list[str]:
return ["udisksctl", "unmount", "-b", dev]
def eject_argv(dev: str) -> list[str]:
return ["udisksctl", "power-off", "-b", dev]
@@ -0,0 +1,169 @@
"""Streaming-Runner: führt Subprozesse aus, streamt stdout in einen RichLog
innerhalb eines Modals. Kein TUI-suspend — der user sieht alles live."""
from __future__ import annotations
import asyncio
import shlex
from dataclasses import dataclass
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import RichLog, Static
from taninux.core import theme as T
@dataclass
class StreamCommand:
label: str
argv: list[str]
class RunnerScreen(ModalScreen[list[int]]):
"""Modal das Commands sequenziell ausführt + stdout/stderr streamt.
Returns list[int] — ein exit-code pro Command in Reihenfolge."""
BINDINGS = [
Binding("ctrl+c", "cancel", "abbruch"),
Binding("escape", "close", "schliessen", show=True),
Binding("q", "close", "quit", show=False),
]
CSS = f"""
RunnerScreen {{
align: center middle;
background: rgba(0, 0, 0, 0.85);
}}
RunnerScreen #runner-dialog {{
width: 90%;
height: 85%;
padding: 1 2;
background: {T.YORU};
border: round {T.BASE3};
}}
RunnerScreen #runner-title {{
height: 1;
background: {T.YORU};
color: {T.UME};
}}
RunnerScreen #runner-log {{
height: 1fr;
background: {T.YORU};
color: {T.FG_ALT};
border: none;
scrollbar-color: {T.BASE2} {T.YORU};
scrollbar-background: {T.YORU};
scrollbar-corner-color: {T.YORU};
scrollbar-size-vertical: 1;
}}
RunnerScreen #runner-hint {{
height: 1;
background: {T.YORU};
color: {T.BASE4};
margin-top: 1;
}}
"""
def __init__(self, commands: list[StreamCommand]) -> None:
super().__init__()
self.commands = commands
self.codes: list[int] = []
self.done = False
self._current_proc: asyncio.subprocess.Process | None = None
self._cancelled = False
def compose(self) -> ComposeResult:
with Vertical(id="runner-dialog"):
yield Static("", id="runner-title", markup=True)
yield RichLog(id="runner-log", highlight=False, markup=True, wrap=True)
yield Static("", id="runner-hint", markup=True)
def on_mount(self) -> None:
self._set_title(f"running {len(self.commands)} command(s)…")
self._set_hint(f"[{T.BASE5}]ctrl+c[/] [{T.BASE4}]abbruch[/]")
self.run_worker(self._run_all(), exclusive=True)
def _set_title(self, msg: str) -> None:
self.query_one("#runner-title", Static).update(f"[{T.UME}]▲ {msg}[/]")
def _set_hint(self, msg: str) -> None:
self.query_one("#runner-hint", Static).update(msg)
def _log(self, line: str) -> None:
self.query_one("#runner-log", RichLog).write(line)
async def _run_all(self) -> None:
log = self.query_one("#runner-log", RichLog)
for i, cmd in enumerate(self.commands, 1):
if self._cancelled:
self._log(f"[{T.RED}]abgebrochen[/]")
break
log.write("")
log.write(f"[{T.SHINKAI}]┌── [{i}/{len(self.commands)}] {cmd.label}[/]")
log.write(f"[{T.BASE5}]│ $ {shlex.join(cmd.argv)}[/]")
log.write(f"[{T.BASE3}]└────────────────[/]")
code = await self._run_one(cmd)
self.codes.append(code)
color = T.TAKE if code == 0 else T.RED
log.write(f"[{color}]→ exit {code}[/]")
self.done = True
self._current_proc = None
ok = sum(1 for c in self.codes if c == 0)
if self._cancelled:
self._set_title(f"abgebrochen ({ok}/{len(self.codes)} ok)")
elif ok == len(self.codes):
self._set_title(f"fertig — {ok}/{len(self.codes)} ok")
else:
self._set_title(f"fertig mit fehlern — {ok}/{len(self.codes)} ok")
self._set_hint(
f"[{T.BASE5}]ESC[/] [{T.BASE4}]schliessen + zurück[/]"
)
async def _run_one(self, cmd: StreamCommand) -> int:
try:
self._current_proc = await asyncio.create_subprocess_exec(
*cmd.argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
stdin=asyncio.subprocess.DEVNULL,
)
except FileNotFoundError as e:
self._log(f"[{T.RED}]fehler: {e}[/]")
return 127
except Exception as e: # noqa: BLE001
self._log(f"[{T.RED}]start fehlgeschlagen: {e}[/]")
return 1
assert self._current_proc.stdout is not None
try:
async for raw in self._current_proc.stdout:
line = raw.decode("utf-8", errors="replace").rstrip("\n").rstrip("\r")
if line:
# Escape rich markup chars so program output isn't parsed
self._log(line.replace("[", "\\["))
except asyncio.CancelledError:
self._current_proc.terminate()
raise
return await self._current_proc.wait()
def action_cancel(self) -> None:
if self.done:
self.dismiss(self.codes)
return
self._cancelled = True
if self._current_proc is not None:
try:
self._current_proc.terminate()
except ProcessLookupError:
pass
self._set_title("abbruch läuft…")
def action_close(self) -> None:
if self.done:
self.dismiss(self.codes)
# Wenn noch running: ignore (use ctrl+c)
@@ -0,0 +1,45 @@
"""Sudo-Helper: Password-Cache via `sudo -v`."""
from __future__ import annotations
import subprocess
def is_cached() -> bool:
"""Prüft ob sudo-credentials noch im 5-min cache stehen."""
try:
r = subprocess.run(
["sudo", "-n", "-v"],
capture_output=True, check=False, timeout=5,
)
return r.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def cache(password: str) -> bool:
"""Validiert Passwort + cached sudo-Credentials.
Wir nutzen `sudo -S -v -p ''` (lies pw von stdin, nur cache update,
leerer prompt). Returns True wenn akzeptiert."""
try:
r = subprocess.run(
["sudo", "-S", "-v", "-p", ""],
input=password + "\n",
capture_output=True, text=True, check=False, timeout=10,
)
return r.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def keep_alive() -> None:
"""Verlängert den sudo cache nochmal, falls noch gültig.
Praktisch in laufenden Updates die mehrere Minuten dauern können."""
try:
subprocess.run(
["sudo", "-n", "-v"],
capture_output=True, check=False, timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
@@ -0,0 +1,78 @@
"""Sudo-Passwort-Modal — fragt das Passwort einmal ab und gibt es zurück."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Input, Static
from taninux.core import theme as T
class SudoModal(ModalScreen[str | None]):
"""Fragt sudo-passwort. Liefert das Passwort oder None bei abbruch.
Eingabe ist gemaskt. Das Passwort wird NICHT geloggt oder gespeichert —
nur an `sudo -S -v` weitergereicht zum cachen."""
BINDINGS = [
Binding("escape", "cancel", "abbruch"),
]
CSS = f"""
SudoModal {{
align: center middle;
background: rgba(0, 0, 0, 0.6);
}}
SudoModal #sudo-dialog {{
width: auto;
max-width: 70;
height: auto;
padding: 1 3;
background: {T.YORU};
border: round {T.BASE3};
}}
SudoModal Static {{
background: {T.YORU};
margin-bottom: 1;
}}
SudoModal Input {{
background: {T.BASE1};
color: {T.YUKI};
border: round {T.BASE2};
}}
SudoModal Input:focus {{
border: round {T.UME};
}}
SudoModal #sudo-hint {{
margin-top: 1;
margin-bottom: 0;
color: {T.BASE4};
}}
"""
def compose(self) -> ComposeResult:
with Vertical(id="sudo-dialog"):
yield Static(f"[{T.UME}]sudo passwort[/]", markup=True)
yield Static(
f"[{T.BASE5}]wird via sudo -S -v gecached (5 min ttl), "
f"dann updates laufen im TUI.[/]",
markup=True,
)
yield Input(password=True, id="sudo-pw")
yield Static(
f"[{T.BASE5}]ENTER[/] [{T.BASE4}]bestätigen[/] "
f"[{T.BASE5}]ESC[/] [{T.BASE4}]abbrechen[/]",
id="sudo-hint",
markup=True,
)
def on_mount(self) -> None:
self.query_one(Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
self.dismiss(event.value)
def action_cancel(self) -> None:
self.dismiss(None)
@@ -0,0 +1,274 @@
"""Geteilte System-Info-Erkennung — genutzt von health, suggest, kernel."""
from __future__ import annotations
import os
import platform
import re
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
# ----------------------------------------------------------------- base
def hostname() -> str:
return platform.node() or "?"
def kernel_release() -> str:
return platform.release()
def distro_pretty() -> str:
try:
for line in Path("/etc/os-release").read_text().splitlines():
if line.startswith("PRETTY_NAME="):
return line.split("=", 1)[1].strip().strip('"')
except OSError:
pass
return "?"
def uptime_seconds() -> float:
try:
with open("/proc/uptime") as f:
return float(f.read().split()[0])
except OSError:
return 0.0
def load_avg() -> tuple[float, float, float]:
try:
return os.getloadavg()
except OSError:
return (0.0, 0.0, 0.0)
def cpu_model() -> str:
try:
with open("/proc/cpuinfo") as f:
for line in f:
if line.startswith("model name"):
return line.split(":", 1)[1].strip()
except OSError:
pass
return "Unknown CPU"
def cpu_count() -> int:
return os.cpu_count() or 1
def memory() -> dict[str, int]:
"""MemTotal/MemAvailable/SwapTotal/SwapFree etc in Bytes."""
out: dict[str, int] = {}
try:
with open("/proc/meminfo") as f:
for line in f:
parts = line.split()
if len(parts) >= 2:
key = parts[0].rstrip(":")
try:
out[key] = int(parts[1]) * 1024
except ValueError:
continue
except OSError:
pass
return out
def boot_time_seconds() -> float | None:
"""systemd-analyze boot duration."""
try:
r = subprocess.run(
["systemd-analyze", "time"],
capture_output=True, text=True, check=False, timeout=5,
)
m = re.search(r"=\s*([\d.]+)s", r.stdout)
if m:
return float(m.group(1))
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
return None
# ----------------------------------------------------------------- hardware
@dataclass
class GPU:
vendor: str # "nvidia" / "amd" / "intel" / "unknown"
model: str
pci_slot: str
driver: str | None = None
def _detect_gpu_vendor(text: str) -> str:
t = text.lower()
if "nvidia" in t:
return "nvidia"
if "amd" in t or "radeon" in t or "ati " in t:
return "amd"
if "intel" in t:
return "intel"
return "unknown"
def gpus() -> list[GPU]:
"""Parse `lspci -k` für VGA/3D/Display controller."""
try:
r = subprocess.run(
["lspci", "-k"],
capture_output=True, text=True, check=False, timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
found: list[GPU] = []
current: GPU | None = None
# lspci -k Format: jede Top-Level-Zeile beginnt mit "BB:DD.F" (bus:device.function)
# Sub-Zeilen ("Kernel driver…", "Kernel modules…") sind eingerückt.
for line in r.stdout.splitlines():
# Neue Top-Level-Zeile?
if line and not line.startswith((" ", "\t")) and ":" in line.split()[0]:
# Reset: alte GPU bekommt keine driver-zeile mehr von hier
current = None
if any(k in line for k in (
"VGA compatible controller",
"3D controller",
"Display controller",
)):
parts = line.split(":", 2)
if len(parts) >= 3:
pci = parts[0].strip()
model = parts[2].strip()
current = GPU(
vendor=_detect_gpu_vendor(model),
model=model,
pci_slot=pci,
driver=None,
)
found.append(current)
elif current and line.lstrip().startswith("Kernel driver in use:"):
current.driver = line.split(":", 1)[1].strip()
return found
def audio_server() -> str:
"""pipewire / pulse / ? (best-effort heuristic)."""
uid = os.getuid()
runtime = Path(f"/run/user/{uid}")
if (runtime / "pipewire-0").exists():
return "pipewire"
if (runtime / "pulse").exists():
return "pulse"
return "?"
@dataclass
class NetIface:
name: str
state: str # "UP" / "DOWN" / "UNKNOWN"
addrs: list[str] = field(default_factory=list)
def network_interfaces() -> list[NetIface]:
try:
r = subprocess.run(
["ip", "-br", "addr"],
capture_output=True, text=True, check=False, timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
out: list[NetIface] = []
for line in r.stdout.splitlines():
parts = line.split()
if len(parts) < 2:
continue
name = parts[0]
state = parts[1]
addrs = parts[2:] if len(parts) > 2 else []
out.append(NetIface(name=name, state=state, addrs=addrs))
return out
@dataclass
class Disk:
name: str
size_bytes: int
model: str
rotational: bool
def disks() -> list[Disk]:
try:
r = subprocess.run(
["lsblk", "-d", "-b", "-n", "-o", "NAME,SIZE,ROTA,MODEL"],
capture_output=True, text=True, check=False, timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
out: list[Disk] = []
for line in r.stdout.splitlines():
parts = line.split(None, 3)
if len(parts) < 3:
continue
try:
size = int(parts[1])
except ValueError:
continue
out.append(Disk(
name=parts[0],
size_bytes=size,
rotational=parts[2] == "1",
model=parts[3].strip() if len(parts) > 3 else "",
))
return out
# ----------------------------------------------------------------- services / logs
def systemctl_failed() -> list[str]:
try:
r = subprocess.run(
["systemctl", "list-units", "--failed", "--no-legend", "--plain"],
capture_output=True, text=True, check=False, timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
return [
line.split()[0]
for line in r.stdout.splitlines()
if line.strip()
]
def journal_errors_today() -> int:
try:
r = subprocess.run(
["journalctl", "-p", "3", "--since", "today", "-q", "--no-pager"],
capture_output=True, text=True, check=False, timeout=10,
)
return len([l for l in r.stdout.splitlines() if l.strip()])
except (FileNotFoundError, subprocess.TimeoutExpired):
return -1
def journal_errors_boot() -> int:
try:
r = subprocess.run(
["journalctl", "-p", "3", "-b", "-q", "--no-pager"],
capture_output=True, text=True, check=False, timeout=10,
)
return len([l for l in r.stdout.splitlines() if l.strip()])
except (FileNotFoundError, subprocess.TimeoutExpired):
return -1
# ----------------------------------------------------------------- binaries
def has_binary(name: str) -> bool:
return shutil.which(name) is not None
@@ -0,0 +1,29 @@
"""Mountain (Fuji) Palette.
Reine Farben-Konstanten, keine textual-Dependency — damit auch CLI-Code
und Scanner ohne installed textual importieren können.
Das Textual-`Theme`-Objekt lebt in `taninux.app.MOUNTAIN_THEME`.
"""
from __future__ import annotations
# ---- Mountain Fuji Palette (adaptiert von mountain-theme/Mountain) ----
# Backgrounds
YORU = "#0f0f0f" # bg
BASE0 = "#191919" # subtle alt bg
BASE1 = "#262626" # hover / selection / modeline
BASE2 = "#393939" # divider
BASE3 = "#4c4c4c" # muted border / comment
# Foregrounds
YUKI = "#f0f0f0" # fg
FG_ALT = "#e7e7e7"
BASE4 = "#767676" # secondary text
BASE5 = "#a0a0a0" # tertiary text
# Accents — bewusst reduziert auf 4 muted hues + ein gedämpftes Rot
UME = "#8f8aac" # violet — title / active / primary
TAKE = "#8aac8b" # green — safe / success
SHINKAI = "#8a98ac" # blue — section headers / secondary
CHIKYU = "#aca98a" # yellow — sizes / warning
RED = "#ac8a8c" # red — destructive (gedämpft)
@@ -0,0 +1,128 @@
"""Safe-Trash: Pfade wandern in einen Timestamp-Slot mit Manifest, statt rm.
Restore und Purge sind vom User aus CLI/TUI ansteuerbar.
"""
from __future__ import annotations
import json
import os
import shutil
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from taninux.core.paths import trash_dir
@dataclass
class TrashEntry:
id: str # slot directory name
slot: Path # absolute path of the slot
original: Path # where it came from
trashed_at: str # ISO timestamp
size_bytes: int
def _slot_for(name: str) -> Path:
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
base = trash_dir() / f"{ts}-{name}"
slot = base
n = 1
while slot.exists():
slot = trash_dir().joinpath(f"{ts}-{name}-{n}")
n += 1
return slot
def _dir_size(p: Path) -> int:
if p.is_symlink():
return 0
if p.is_file():
try:
return p.stat(follow_symlinks=False).st_size
except OSError:
return 0
if not p.is_dir():
return 0
total = 0
for root, _dirs, files in os.walk(p, followlinks=False):
for f in files:
try:
total += os.stat(os.path.join(root, f), follow_symlinks=False).st_size
except OSError:
continue
return total
def move_to_trash(path: Path) -> TrashEntry:
"""Atomically move `path` into a new trash slot. Returns the entry."""
if not path.exists():
raise FileNotFoundError(path)
size = _dir_size(path)
slot = _slot_for(path.name)
slot.mkdir(parents=True)
payload_dir = slot / path.name
shutil.move(str(path), str(payload_dir))
manifest = {
"original": str(path),
"trashed_at": datetime.now().isoformat(timespec="seconds"),
"size_bytes": size,
"payload": path.name,
}
(slot / "manifest.json").write_text(json.dumps(manifest, indent=2))
return TrashEntry(
id=slot.name,
slot=slot,
original=path,
trashed_at=manifest["trashed_at"],
size_bytes=size,
)
def list_trash() -> list[TrashEntry]:
entries: list[TrashEntry] = []
for slot in sorted(trash_dir().iterdir()):
if not slot.is_dir():
continue
mf = slot / "manifest.json"
if not mf.exists():
continue
try:
data = json.loads(mf.read_text())
except (json.JSONDecodeError, OSError):
continue
entries.append(TrashEntry(
id=slot.name,
slot=slot,
original=Path(data["original"]),
trashed_at=data.get("trashed_at", ""),
size_bytes=int(data.get("size_bytes", 0)),
))
return entries
def restore(slot_id: str) -> Path:
slot = trash_dir() / slot_id
if not slot.is_dir():
raise FileNotFoundError(slot_id)
manifest = json.loads((slot / "manifest.json").read_text())
original = Path(manifest["original"])
payload = slot / manifest.get("payload", original.name)
if original.exists():
raise FileExistsError(f"original path already exists: {original}")
original.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(payload), str(original))
(slot / "manifest.json").unlink()
slot.rmdir()
return original
def purge(slot_id: str) -> None:
slot = trash_dir() / slot_id
if not slot.is_dir():
raise FileNotFoundError(slot_id)
shutil.rmtree(slot)
@@ -0,0 +1,135 @@
"""Wallpaper-Verwaltung über waypaper (Backend swaybg). UI-frei.
Liest die waypaper-Config (~/.config/waypaper/config.ini), liefert den
Hintergrundordner, das aktuelle Bild, erkannte Monitore und die flache Liste
der Bilddateien. Setzen läuft über die waypaper-CLI (argv für ProcessRunner).
WICHTIG: Dieses Modul lädt NIEMALS Bilddaten — nur Pfade. Das Skalieren /
Rendern der Thumbnails passiert ausschließlich in der GUI (RAM-kontrolliert).
"""
from __future__ import annotations
import configparser
import os
import shutil
from pathlib import Path
_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
def _config_path() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
return Path(base) / "waypaper" / "config.ini"
def _expand(value: str) -> str:
"""`~` und Umgebungs-Tilde expandieren, Whitespace strippen."""
return os.path.expanduser(value.strip())
def config() -> dict[str, str]:
"""Liest [Settings] aus der waypaper-Config. Defensiv: leeres dict bei Fehler.
`folder`/`wallpaper` werden bei der weiteren Auswertung expandiert; hier
bleiben die Rohwerte (mehrzeilige Werte wie `monitors`/`wallpaper` erhalten
ihre Zeilenumbrüche).
"""
path = _config_path()
if not path.exists():
return {}
parser = configparser.ConfigParser()
try:
parser.read(path, encoding="utf-8")
except (configparser.Error, OSError):
return {}
if not parser.has_section("Settings"):
return {}
out: dict[str, str] = {}
for key in ("folder", "wallpaper", "backend", "monitors"):
if parser.has_option("Settings", key):
out[key] = parser.get("Settings", key)
return out
def wallpaper_dir() -> Path:
"""Expandierter Hintergrundordner; Fallback ~/Pictures."""
folder = config().get("folder", "").strip()
if folder:
return Path(_expand(folder))
return Path(os.path.expanduser("~/Pictures"))
def set_folder(path: str) -> None:
"""Hintergrundordner in waypapers config.ini setzen (andere Keys bleiben)."""
cfg_path = _config_path()
parser = configparser.ConfigParser()
try:
if cfg_path.exists():
parser.read(cfg_path, encoding="utf-8")
except (configparser.Error, OSError):
pass
if not parser.has_section("Settings"):
parser.add_section("Settings")
parser.set("Settings", "folder", path)
try:
cfg_path.parent.mkdir(parents=True, exist_ok=True)
with cfg_path.open("w", encoding="utf-8") as f:
parser.write(f)
except OSError:
pass
def current() -> str:
"""Aktuell gesetztes Wallpaper (expandiert) oder "" wenn keiner.
waypaper kann pro Monitor ein Bild speichern (mehrzeiliger Wert); wir nehmen
die erste nicht-leere Zeile als „das" aktuelle Bild.
"""
raw = config().get("wallpaper", "")
for line in raw.splitlines():
line = line.strip()
if line:
return _expand(line)
return ""
def monitors() -> list[str]:
"""["All"] + erkannte Monitornamen aus dem (mehrzeiligen) monitors-Block."""
out = ["All"]
raw = config().get("monitors", "")
for line in raw.splitlines():
name = line.strip()
if name and name != "All" and name not in out:
out.append(name)
return out
def list_wallpapers() -> list[Path]:
"""Bilddateien im wallpaper_dir (flach, case-insensitive), nach Name sortiert.
Lädt KEINE Bilddaten — gibt nur Pfade zurück.
"""
base = wallpaper_dir()
try:
entries = [
p for p in base.iterdir()
if p.is_file() and p.suffix.lower() in _EXTS
]
except OSError:
return []
return sorted(entries, key=lambda p: p.name.lower())
def set_argv(path, monitor: str = "All") -> list[str]:
argv = ["waypaper", "--wallpaper", str(path)]
if monitor and monitor != "All":
argv += ["--monitor", monitor]
return argv
def random_argv() -> list[str]:
return ["waypaper", "--random"]
def available() -> bool:
return shutil.which("waypaper") is not None
@@ -0,0 +1,13 @@
from __future__ import annotations
from taninux.flatpak.screen import FlatpakView
from taninux.pillars.base import Pillar
FLATPAK = Pillar(
key="f",
label="flatpak",
tagline="apps & runtimes",
icon="",
factory=FlatpakView,
)
@@ -0,0 +1,341 @@
"""Flatpak-Pillar: installierte Apps + Runtimes verwalten."""
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from textual import work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.widgets import DataTable, Static, Tab, Tabs
from taninux.core import theme as T
from taninux.core.confirm import ConfirmModal
from taninux.core.runner import Command, run_interactive
from taninux.maintain.scanners.pacman import _parse_size
@dataclass
class FlatpakItem:
ref: str # full ref like 'org.gnome.Calculator/x86_64/stable'
app_id: str # 'org.gnome.Calculator'
branch: str
arch: str
version: str
size_bytes: int
origin: str # remote name like 'flathub'
kind: str # 'app' or 'runtime'
def _flatpak_available() -> bool:
try:
subprocess.run(["flatpak", "--version"],
capture_output=True, check=False, timeout=5)
return True
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def _list_kind(kind: str) -> list[FlatpakItem]:
if not _flatpak_available():
return []
flag = "--app" if kind == "app" else "--runtime"
try:
r = subprocess.run(
["flatpak", "list", flag,
"--columns=application,branch,arch,version,size,origin"],
capture_output=True, text=True, check=False, timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
items: list[FlatpakItem] = []
for line in r.stdout.splitlines():
s = line.strip()
if not s or s.startswith("Application"):
continue
parts = s.split("\t")
if len(parts) < 3:
continue
app_id = parts[0]
branch = parts[1]
arch = parts[2]
version = parts[3] if len(parts) > 3 else ""
size_str = parts[4] if len(parts) > 4 else ""
origin = parts[5] if len(parts) > 5 else ""
items.append(FlatpakItem(
ref=f"{app_id}/{arch}/{branch}",
app_id=app_id,
branch=branch,
arch=arch,
version=version,
size_bytes=_parse_size(size_str) if size_str else 0,
origin=origin,
kind=kind,
))
return items
def _human_size(n: int) -> str:
f = float(n)
for unit in ("B", "K", "M", "G", "T"):
if f < 1024 or unit == "T":
return f"{f:.0f}{unit}" if unit == "B" else f"{f:.1f}{unit}"
f /= 1024
return f"{f:.1f}T"
class FlatpakView(Vertical):
"""Flatpak Apps + Runtimes verwalten (list, sizes, remove)."""
DEFAULT_CSS = f"""
FlatpakView {{
background: {T.YORU};
color: {T.YUKI};
layout: vertical;
}}
FlatpakView Tabs {{
background: {T.YORU};
height: 3;
}}
FlatpakView Tab {{
background: {T.YORU};
color: {T.BASE4};
padding: 0 2;
}}
FlatpakView Tab.-active {{
color: {T.UME};
}}
FlatpakView Underline {{
background: {T.YORU};
color: {T.BASE2};
}}
FlatpakView Underline > .underline--bar {{
background: {T.YORU};
color: {T.UME};
}}
FlatpakView #f-stats {{
height: 1;
padding: 0 2;
background: {T.YORU};
color: {T.BASE5};
}}
FlatpakView #f-table {{
height: 1fr;
background: {T.YORU};
color: {T.FG_ALT};
border: none;
overflow-x: hidden;
scrollbar-size-vertical: 1;
scrollbar-color: {T.BASE2} {T.YORU};
scrollbar-background: {T.YORU};
scrollbar-corner-color: {T.YORU};
}}
FlatpakView #f-table:focus {{
border: none;
}}
FlatpakView DataTable > .datatable--header {{
background: {T.YORU};
color: {T.SHINKAI};
}}
FlatpakView DataTable > .datatable--cursor {{
background: {T.BASE1};
color: {T.YUKI};
}}
FlatpakView DataTable > .datatable--hover {{
background: {T.YORU};
}}
FlatpakView DataTable > .datatable--even-row {{
background: {T.YORU};
}}
FlatpakView DataTable > .datatable--odd-row {{
background: {T.YORU};
}}
FlatpakView #f-flash {{
height: 1;
padding: 0 2;
background: {T.YORU};
color: {T.BASE4};
}}
"""
BINDINGS = [
Binding("r", "rescan", "rescan"),
Binding("x", "remove", "entfernen"),
Binding("u", "update_all", "alle updaten", show=False),
Binding("ctrl+a", "remove_unused", "unused runtimes"),
Binding("j", "cursor_down", "down", show=False),
Binding("k", "cursor_up", "up", show=False),
]
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.current_tab = "app"
self.items_by_tab: dict[str, list[FlatpakItem]] = {"app": [], "runtime": []}
def compose(self) -> ComposeResult:
yield Tabs(
Tab("apps", id="app"),
Tab("runtimes", id="runtime"),
id="f-tabs",
)
yield Static("", id="f-stats", markup=True)
yield DataTable(id="f-table", cursor_type="row", zebra_stripes=False)
yield Static("", id="f-flash", markup=True)
def on_mount(self) -> None:
if not _flatpak_available():
self._flash("flatpak ist nicht installiert", color=T.RED)
return
t = self.query_one("#f-table", DataTable)
t.add_column("app-id", key="appid")
t.add_column("branch", width=10, key="branch")
t.add_column("version", width=18, key="version")
t.add_column("grösse", width=10, key="size")
t.add_column("origin", width=14, key="origin")
# ---- actions ----
def action_rescan(self) -> None:
self._flash(f"scanne flatpak {self.current_tab}s…")
self._rescan_worker(self.current_tab)
@work(thread=True, exclusive=True, group="flatpak-scan")
def _rescan_worker(self, kind: str) -> None:
items = _list_kind(kind)
self.app.call_from_thread(self._after_rescan, kind, items)
def _after_rescan(self, kind: str, items: list[FlatpakItem]) -> None:
self.items_by_tab[kind] = items
if kind == self.current_tab:
self._refresh_view()
self._flash(f"{len(items)} {kind}s")
def action_remove(self) -> None:
cur = self._current()
if cur is None:
return
body = (
f"flatpak entfernen?\n\n"
f" {cur.app_id}\n"
f" branch: {cur.branch}\n"
f" grösse: {_human_size(cur.size_bytes)}"
)
self.app.push_screen(
ConfirmModal(f"uninstall {cur.app_id}?", body, danger=True),
lambda ok: self._after_remove(ok, cur),
)
def _after_remove(self, ok: bool | None, item: FlatpakItem) -> None:
if not ok:
self._flash("abgebrochen")
return
codes = run_interactive(
self.app,
[Command(
label=f"uninstall {item.app_id}",
argv=["flatpak", "uninstall", "-y", item.app_id],
)],
)
success = codes and codes[0] == 0
self._flash(
f"{item.app_id} entfernt" if success else "uninstall fehlgeschlagen",
color=T.TAKE if success else T.RED,
)
self.action_rescan()
def action_remove_unused(self) -> None:
body = "alle unused runtimes via `flatpak uninstall --unused -y` entfernen?"
self.app.push_screen(
ConfirmModal("unused runtimes weg?", body, danger=False),
self._after_remove_unused,
)
def _after_remove_unused(self, ok: bool | None) -> None:
if not ok:
self._flash("abgebrochen")
return
codes = run_interactive(
self.app,
[Command(
label="flatpak uninstall --unused",
argv=["flatpak", "uninstall", "--unused", "-y"],
)],
)
success = codes and codes[0] == 0
self._flash(
"unused runtimes weg" if success else "fehlgeschlagen",
color=T.TAKE if success else T.RED,
)
self.action_rescan()
def action_update_all(self) -> None:
codes = run_interactive(
self.app,
[Command(label="flatpak update", argv=["flatpak", "update", "-y"])],
)
success = codes and codes[0] == 0
self._flash("update fertig" if success else "update fehlgeschlagen",
color=T.TAKE if success else T.RED)
self.action_rescan()
def action_cursor_down(self) -> None:
self.query_one("#f-table", DataTable).action_cursor_down()
def action_cursor_up(self) -> None:
self.query_one("#f-table", DataTable).action_cursor_up()
# ---- helpers ----
@property
def items(self) -> list[FlatpakItem]:
return self.items_by_tab.get(self.current_tab, [])
def _current(self) -> FlatpakItem | None:
t = self.query_one("#f-table", DataTable)
r = t.cursor_row
if r is None or r < 0 or r >= len(self.items):
return None
return self.items[r]
def _flash(self, msg: str, color: str | None = None) -> None:
color = color or T.BASE4
self.query_one("#f-flash", Static).update(f"[{color}]{msg}[/]")
def _refresh_view(self) -> None:
items = self.items
t = self.query_one("#f-table", DataTable)
t.clear()
total_size = sum(i.size_bytes for i in items)
kind_label = "apps" if self.current_tab == "app" else "runtimes"
stats = (
f"[{T.YUKI}]{len(items)}[/] [{T.BASE5}]{kind_label}[/]"
+ (f" [{T.BASE3}]·[/] [{T.CHIKYU}]{_human_size(total_size)}[/] [{T.BASE5}]installiert[/]"
if total_size else "")
)
self.query_one("#f-stats", Static).update(stats)
for i in items:
size_styled = (
f"[{T.CHIKYU}]{_human_size(i.size_bytes)}[/]"
if i.size_bytes else f"[{T.BASE3}]—[/]"
)
t.add_row(
f"[{T.YUKI}]{i.app_id}[/]",
f"[{T.BASE5}]{i.branch}[/]",
f"[{T.BASE5}]{i.version}[/]",
size_styled,
f"[{T.BASE5}]{i.origin}[/]",
)
# ---- events ----
def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
if event.tab is None:
return
self.current_tab = event.tab.id
if not self.items_by_tab.get(self.current_tab):
self.action_rescan()
else:
self._refresh_view()
@@ -0,0 +1,86 @@
# TANINUX GTK-Frontend
GTK4 + libadwaita Desktop-App neben der Textual-TUI. Teilt sich die
`taninux.core`-Engine (sysinfo / pacman / bootloader / snapshot …).
## Start
```sh
# System-Abhängigkeiten (empfohlen statt pip-Build von PyGObject):
sudo pacman -S python-gobject gtk4 libadwaita
# starten
python -m taninux.gui
# oder nach `uv pip install -e .`:
taninux-gtk
```
## Architektur
```
gui/
app.py Adw.Application + main()
window.py Adw.NavigationSplitView: Sidebar (Pillar-Liste) + Content-Stack
process.py async Gio.Subprocess-Runner (non-blocking), pkexec für root
console.py LogConsole + RunnerCard — Button → privilegierter Befehl → Live-Output
pages/
__init__.py PAGES-Registry (key, label, icon, builder) — Pendant zur PILLARS-Liste
overview.py read-only, komplett aus core.sysinfo
update.py `pacman -Syu` via pkexec, Live-Stream (Async-Showcase)
kernel.py installierte Kernel + bootloader, linux-lts via pkexec installieren
settings.py SwitchRow/ComboRow/SpinRow/ColorScheme — das libadwaita-Vokabular
```
Neue Seite = Modul in `pages/` mit `build() -> Gtk.Widget` + ein Eintrag in
`PAGES`. Genau wie ein neuer Pillar in der TUI.
## Privilege Escalation — dedizierter Helper
`process.ProcessRunner(... privileged=True)` führt root-Befehle **nicht** mehr
als `pkexec pacman …` (= beliebiges root) aus, sondern schleust das argv durch
einen dedizierten Helper:
```
pkexec /usr/lib/taninux/taninux-helper <argv…>
```
Der Helper (`helper/taninux-helper`) validiert das argv gegen eine feste
**Allowlist** und lehnt alles andere mit Exit ≠ 0 ab. Erlaubt sind nur:
| Kommando | Operationen | erlaubte Flags |
|------------|----------------------------------------------|---------------------------------------------|
| `pacman` | `-S` `-Sy` `-Su` `-Syu` `-R` `-Rs` `-Rns` | `--noconfirm` `--needed` `--noprogressbar` |
| `bootctl` | `set-default` `set-oneshot` | — |
| `paccache` | `-r` `-rk1` `-rk0` `-ruk0` | `-k` `0``3` `--remove` `--uninstalled` |
Operanden (Paketnamen, Boot-Einträge) sind frei — Tokens mit führendem `-`,
die nicht oben stehen, werden abgelehnt. Es läuft KEINE Shell, der Helper
`execvp()`t exakt das übergebene argv. Die argv-Schnittstelle nach aussen
(`RunnerCard(label, ["pacman","-Syu"], privileged=True)`) bleibt unverändert.
Findet `process.py` `/usr/lib/taninux/taninux-helper` nicht, fällt es auf den
Helper im Repo zurück (`gui/helper/taninux-helper`) — praktisch für Dev ohne
Installation.
### Installation (Helper + Policy)
```sh
sudo install -Dm755 src/taninux/gui/helper/taninux-helper \
/usr/lib/taninux/taninux-helper
sudo cp src/taninux/gui/polkit/ch.gabrielevarano.Taninux.policy \
/usr/share/polkit-1/actions/
```
Die Policy (`action id = ch.gabrielevarano.taninux.maintain`,
`exec.path = /usr/lib/taninux/taninux-helper`, `allow_active = auth_admin_keep`)
sorgt für **einmal Passwort pro Session**, danach gecached.
### polkit-Auth-Agent
Damit pkexec **ohne** Terminal nach dem Passwort fragen kann, muss ein
polkit-Auth-Agent laufen. Auf wlroots/eww-Setups ist der oft nicht automatisch
da — starte einen in deiner Autostart-Config:
- `polkit-gnome` Agent, oder
- `lxqt-policykit` (`/usr/lib/lxqt-policykit/lxqt-policykit-agent`), oder
- `mate-polkit` / `hyprpolkitagent`
@@ -0,0 +1,10 @@
"""TANINUX GTK-Frontend (GTK4 + libadwaita).
Zweites Frontend neben der Textual-TUI — teilt sich dieselbe `taninux.core`-
Engine (sysinfo / pacman / bootloader / snapshot …). Die TUI bleibt für
Terminal/SSH, das GTK-Frontend ist die Desktop-Verwaltungs-App, die sich in
ein GTK-Setup (eww-Panel + Dock) einfügt.
"""
from __future__ import annotations
APP_ID = "ch.gabrielevarano.Taninux"
@@ -0,0 +1,3 @@
from taninux.gui.app import main
raise SystemExit(main())
@@ -0,0 +1,36 @@
"""Fuji-Akzent-Definitionen — reine Daten, KEINE GTK-Abhängigkeit.
Bewusst GTK-frei, damit sowohl das GTK-Frontend (gui.style) als auch der
Persistenz-Writer (gui.config) und externe Consumer (z.B. die eww-Bar, die
gui.json liest) dieselbe Quelle nutzen. Hex-Werte kommen aus core.theme,
damit GTK-App und TUI identisch sind.
"""
from __future__ import annotations
from dataclasses import dataclass
from taninux.core import theme as T
@dataclass(frozen=True)
class Accent:
key: str
label: str
hex: str
# Reihenfolge = Anzeige-Reihenfolge der Swatches.
FUJI_ACCENTS: list[Accent] = [
Accent("ume", "Ume · violet", T.UME),
Accent("shinkai", "Shinkai · blue", T.SHINKAI),
Accent("take", "Take · green", T.TAKE),
Accent("chikyu", "Chikyū · yellow", T.CHIKYU),
Accent("beni", "Beni · red", T.RED),
]
_BY_KEY = {a.key: a for a in FUJI_ACCENTS}
DEFAULT_ACCENT = FUJI_ACCENTS[0].key
def by_key(key: str) -> Accent:
return _BY_KEY.get(key, _BY_KEY[DEFAULT_ACCENT])
@@ -0,0 +1,54 @@
"""Adw.Application-Einstiegspunkte fürs GTK-Frontend.
Zwei Apps auf derselben Codebasis (teilen core/ + die GUI-Bausteine):
tsettings System Settings — Kernel/Connectivity/Hardware/…
thub Software Hub — Pakete suchen/installieren, Updates…
Jede ist nur ein dünner Shell, der seine Sektionsliste an MainWindow gibt.
"""
from __future__ import annotations
import sys
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gio # noqa: E402
from taninux.gui import APP_ID, style
from taninux.gui.pages import HUB_SECTIONS, SETTINGS_SECTIONS
from taninux.gui.window import MainWindow
class _ShellApp(Adw.Application):
def __init__(self, app_id: str, title: str, sections) -> None:
super().__init__(application_id=app_id, flags=Gio.ApplicationFlags.DEFAULT_FLAGS)
self._title = title
self._sections = sections
self._window: MainWindow | None = None
def do_activate(self) -> None:
if self._window is None:
style.apply_persisted() # gespeichertes Farbschema + Fuji-Akzent
self._window = MainWindow(self, self._sections, self._title)
self._window.present()
def main_settings(argv: list[str] | None = None) -> int:
app = _ShellApp(f"{APP_ID}.Settings", "System Settings", SETTINGS_SECTIONS)
return app.run(argv if argv is not None else sys.argv)
def main_hub(argv: list[str] | None = None) -> int:
app = _ShellApp(f"{APP_ID}.SoftwareHub", "Software Hub", HUB_SECTIONS)
return app.run(argv if argv is not None else sys.argv)
# Rückwärtskompatibel: `taninux-gtk` / `python -m taninux.gui` -> System Settings.
main = main_settings
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,44 @@
"""Persistenter Appearance-State fürs GTK-Frontend.
Eigene kleine JSON neben dem TUI-State (core.state ist whitelist-fokussiert,
das hier ist reine GUI-Präferenz). Liegt in der XDG-data-dir von taninux.
"""
from __future__ import annotations
import json
from taninux.core.paths import data_dir
from taninux.gui.accents import by_key
_DEFAULTS = {"color_scheme": "system", "accent": "ume"}
def _path():
return data_dir() / "gui.json"
def load() -> dict:
try:
data = json.loads(_path().read_text())
except (json.JSONDecodeError, OSError):
return dict(_DEFAULTS)
return {**_DEFAULTS, **{k: data[k] for k in _DEFAULTS if k in data}}
def save(color_scheme: str, accent: str) -> None:
# accent_hex/-label gleich mit auflösen, damit externe Consumer (eww-Bar)
# nur diese eine JSON lesen müssen — kein Key→Hex-Mapping nachbauen.
acc = by_key(accent)
try:
_path().write_text(json.dumps(
{
"version": 1,
"color_scheme": color_scheme,
"accent": accent,
"accent_hex": acc.hex,
"accent_label": acc.label,
},
indent=2,
))
except OSError:
pass
@@ -0,0 +1,42 @@
"""Wiederverwendbare Bestätigungs-Dialoge fürs GTK-Frontend.
Vor destruktiven Aktionen (Pakete entfernen, Cache leeren, Bootloader ändern)
zeigen die Seiten einen `Adw.AlertDialog` und rufen den Callback nur bei
„Fortfahren". So liegt das Bestätigungsmuster an EINER Stelle.
"""
from __future__ import annotations
from typing import Callable
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
def confirm(
parent: Gtk.Widget,
heading: str,
body: str,
on_confirm: Callable[[], None],
) -> None:
"""Zeigt einen Bestätigungs-Dialog. Ruft `on_confirm()`, wenn der Nutzer
„Fortfahren" wählt; bei „Abbrechen" passiert nichts.
`parent` ist irgendein Widget im Fenster — der Dialog hängt sich an dessen
Root (Gtk.Window) an. Nicht-blockierend (libadwaita-Async-Pattern).
"""
dialog = Adw.AlertDialog(heading=heading, body=body)
dialog.add_response("cancel", "Cancel")
dialog.add_response("confirm", "Continue")
dialog.set_response_appearance("confirm", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response("cancel")
dialog.set_close_response("cancel")
def _on_response(_dlg: Adw.AlertDialog, response: str) -> None:
if response == "confirm":
on_confirm()
dialog.connect("response", _on_response)
dialog.present(parent)
@@ -0,0 +1,123 @@
"""Scrollbare Monospace-Log-Konsole + Run-Button — wiederverwendbar.
Kapselt das Muster „Button drücken → privilegierten Befehl async ausführen →
Output live mitlesen". Genutzt von der Update- und Kernel-Seite.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.gui import confirm as confirm_mod
from taninux.gui.process import ProcessRunner
class LogConsole(Gtk.Box):
"""TextView in einem ScrolledWindow, das automatisch nach unten scrollt."""
def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.add_css_class("card")
self._view = Gtk.TextView(editable=False, cursor_visible=False, monospace=True)
self._view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
self._view.set_left_margin(10)
self._view.set_right_margin(10)
self._view.set_top_margin(8)
self._view.set_bottom_margin(8)
self._buffer = self._view.get_buffer()
# Höhe auf ~5 Zeilen begrenzt (vorher 220px = riesiges Feld), scrollbar.
scroller = Gtk.ScrolledWindow(hexpand=True)
scroller.set_min_content_height(112)
scroller.set_max_content_height(112)
scroller.set_propagate_natural_height(False)
scroller.set_child(self._view)
self.set_vexpand(False)
self.append(scroller)
def clear(self) -> None:
self._buffer.set_text("")
def append_line(self, text: str) -> None:
end = self._buffer.get_end_iter()
self._buffer.insert(end, text + "\n")
# ans Ende scrollen
mark = self._buffer.create_mark(None, self._buffer.get_end_iter(), False)
self._view.scroll_mark_onscreen(mark)
self._buffer.delete_mark(mark)
def collapsible_log(console: LogConsole, *, title: str = "Log") -> Gtk.Expander:
"""Konsole in einem zugeklappten, dezenten „Log"-Disclosure."""
exp = Gtk.Expander(label=title)
exp.set_margin_top(6)
exp.set_child(console)
return exp
class RunnerCard(Gtk.Box):
"""Konsole + Action-Button, verdrahtet mit einem ProcessRunner."""
def __init__(
self,
button_label: str,
argv: list[str],
*,
privileged: bool = True,
confirm: tuple[str, str] | None = None,
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=12)
self._argv = argv
self._privileged = privileged
self._confirm = confirm
self._runner = ProcessRunner()
self._runner.connect("line", lambda _r, line: self._console.append_line(line))
self._runner.connect("done", self._on_done)
self._button = Gtk.Button(label=button_label)
self._button.add_css_class("suggested-action")
self._button.add_css_class("pill")
self._button.set_halign(Gtk.Align.START)
self._button.connect("clicked", self._on_click)
self.append(self._button)
self._console = LogConsole()
self.append(collapsible_log(self._console))
def _run(self) -> None:
self._button.set_sensitive(False)
self._console.clear()
self._runner.run(self._argv, privileged=self._privileged)
def _on_click(self, _button: Gtk.Button) -> None:
if self._confirm is not None:
heading, body = self._confirm
confirm_mod.confirm(self._button, heading, body, self._run)
else:
self._run()
def _on_done(self, _runner: ProcessRunner, code: int) -> None:
self._console.append_line("")
self._console.append_line(
f"✔ done (exit {code})" if code == 0 else f"✘ exit {code}"
)
self._button.set_sensitive(True)
def log_expander(console: LogConsole, *, title: str = "Log",
expanded: bool = False) -> Adw.PreferencesGroup:
"""Verpackt eine LogConsole in eine einklappbare „Log"-Zeile (Pfeil).
Standardmäßig zugeklappt; aufgeklappt zeigt sie die auf ~5 Zeilen
begrenzte, scrollbare Konsole. Rückgabe = PreferencesGroup (für page.add()).
Ersetzt das frühere „große schwarze Log-Feld" auf jeder Seite.
"""
group = Adw.PreferencesGroup()
exp = collapsible_log(console, title=title)
exp.set_expanded(expanded)
group.add(exp)
return group
@@ -0,0 +1,256 @@
"""Desktop-Look: GTK3-Theme, Icon-Theme, System-Font — wie nwg-look.
Alles User-Level (kein root). Robuster Doppel-Weg wie nwg-look: wir setzen
sowohl die gsettings-Keys (org.gnome.desktop.interface) ALS AUCH die
~/.config/gtk-{3,4}.0/settings.ini — so greift es unter GNOME (gsettings)
genauso wie unter niri (settings.ini), ohne laufenden Daemon.
Hinweis: libadwaita-Apps ignorieren das GTK-*Theme* bewusst (nur GTK3/alte
GTK4-Apps folgen). Icon-Theme und Font gelten dagegen auch für libadwaita.
"""
from __future__ import annotations
import configparser
import subprocess
from pathlib import Path
_IFACE = "org.gnome.desktop.interface"
_SKIP_ICONS = {"default", "hicolor", "locolor"}
# ------------------------------------------------------------- discovery
def _theme_dirs() -> list[Path]:
return [
Path("/usr/share/themes"),
Path.home() / ".themes",
Path.home() / ".local/share/themes",
]
def _icon_dirs() -> list[Path]:
return [
Path("/usr/share/icons"),
Path.home() / ".local/share/icons",
Path.home() / ".icons",
]
def list_gtk_themes() -> list[str]:
"""Theme-Verzeichnisse mit gtk-3.0/ oder gtk-4.0/ Unterordner."""
names: set[str] = set()
for d in _theme_dirs():
if not d.is_dir():
continue
for t in d.iterdir():
if t.is_dir() and any((t / s).is_dir() for s in ("gtk-3.0", "gtk-4.0")):
names.add(t.name)
return sorted(names, key=str.lower)
def list_icon_themes() -> list[str]:
"""Echte Icon-Themes (index.theme mit nicht-leerem Directories=)."""
names: set[str] = set()
for d in _icon_dirs():
if not d.is_dir():
continue
for t in d.iterdir():
if t.name in _SKIP_ICONS:
continue
idx = t / "index.theme"
if not idx.is_file():
continue
try:
text = idx.read_text(errors="ignore")
except OSError:
continue
for line in text.splitlines():
s = line.strip()
if s.startswith("Directories=") and s.split("=", 1)[1].strip():
names.add(t.name)
break
return sorted(names, key=str.lower)
def list_cursor_themes() -> list[str]:
"""Cursor-Themes = Verzeichnisse mit einem cursors/ Unterordner."""
names: set[str] = set()
for d in _icon_dirs():
if not d.is_dir():
continue
for t in d.iterdir():
if t.is_dir() and (t / "cursors").is_dir():
names.add(t.name)
return sorted(names, key=str.lower)
# ------------------------------------------------------------- read state
def _gget(key: str) -> str:
try:
r = subprocess.run(
["gsettings", "get", _IFACE, key],
capture_output=True, text=True, check=False, timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
return r.stdout.strip().strip("'\"") if r.returncode == 0 else ""
def current() -> dict[str, str]:
return {
"gtk_theme": _gget("gtk-theme"),
"icon_theme": _gget("icon-theme"),
"font": _gget("font-name"),
"cursor_theme": _gget("cursor-theme"),
"cursor_size": _gget("cursor-size"),
}
# ------------------------------------------------------------- apply
def _gset(key: str, value: str) -> None:
try:
subprocess.run(
["gsettings", "set", _IFACE, key, value],
check=False, timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
def _write_ini(key: str, value: str) -> None:
"""Schreibt [Settings] key=value in gtk-3.0 und gtk-4.0 settings.ini."""
for sub in ("gtk-3.0", "gtk-4.0"):
d = Path.home() / ".config" / sub
d.mkdir(parents=True, exist_ok=True)
ini = d / "settings.ini"
cp = configparser.ConfigParser()
cp.optionxform = str # Key-Case erhalten
if ini.exists():
try:
cp.read(ini)
except configparser.Error:
pass
if "Settings" not in cp:
cp["Settings"] = {}
cp["Settings"][key] = value
try:
with ini.open("w") as f:
cp.write(f)
except OSError:
pass
def apply_gtk_theme(name: str) -> None:
_gset("gtk-theme", name)
_write_ini("gtk-theme-name", name)
def apply_icon_theme(name: str) -> None:
_gset("icon-theme", name)
_write_ini("gtk-icon-theme-name", name)
def apply_font(name: str) -> None:
_gset("font-name", name)
_write_ini("gtk-font-name", name)
def apply_cursor_theme(name: str) -> None:
_gset("cursor-theme", name)
_write_ini("gtk-cursor-theme-name", name)
def apply_cursor_size(size: int) -> None:
_gset("cursor-size", str(size))
_write_ini("gtk-cursor-theme-size", str(size))
# ------------------------------------------------------------- titlebar buttons
#
# button-layout (Schema org.gnome.desktop.wm.preferences) bestimmt, welche
# Knöpfe in der Titlebar erscheinen und auf welcher Seite. Format "<links>:<rechts>",
# Buttons komma-getrennt aus {minimize,maximize,close,appmenu,icon}.
# Beispiele: ":minimize,maximize,close" (alle rechts), "close:" (nur Close links).
# libadwaita respektiert diesen Key (auch diese App).
_WM = "org.gnome.desktop.wm.preferences"
def _wm_get(key: str) -> str:
try:
r = subprocess.run(
["gsettings", "get", _WM, key],
capture_output=True, text=True, check=False, timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
return r.stdout.strip().strip("'\"") if r.returncode == 0 else ""
def _wm_set(key: str, value: str) -> None:
try:
subprocess.run(
["gsettings", "set", _WM, key, value],
check=False, timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
def _read_ini(key: str) -> str:
"""Liest [Settings] key aus gtk-4.0 (bevorzugt) bzw. gtk-3.0 settings.ini."""
for sub in ("gtk-4.0", "gtk-3.0"):
ini = Path.home() / ".config" / sub / "settings.ini"
if not ini.is_file():
continue
cp = configparser.ConfigParser()
cp.optionxform = str
try:
cp.read(ini)
except configparser.Error:
continue
if cp.has_option("Settings", key):
return cp.get("Settings", key)
return ""
def _parse_layout(raw: str) -> dict:
left_part, _, right_part = raw.partition(":")
left = {b.strip() for b in left_part.split(",") if b.strip()}
right = {b.strip() for b in right_part.split(",") if b.strip()}
wanted = ("minimize", "maximize", "close")
side = "left" if sum(b in left for b in wanted) > sum(b in right for b in wanted) else "right"
present = left | right
return {
"minimize": "minimize" in present,
"maximize": "maximize" in present,
"close": "close" in present,
"side": side,
}
def window_buttons() -> dict:
"""Effektives Decoration-Layout → {minimize, maximize, close, side}.
Quelle ist `gtk-decoration-layout` aus settings.ini (das, was GTK wirklich
nutzt — der gnome-gsettings-Key allein greift auf portal-losen Sessions
NICHT). Wenn noch nichts gesetzt: GTK-Default (alle drei, rechts).
"""
raw = _read_ini("gtk-decoration-layout")
if not raw or ":" not in raw:
return {"minimize": True, "maximize": True, "close": True, "side": "right"}
return _parse_layout(raw)
def set_window_buttons(minimize: bool, maximize: bool, close: bool, side: str) -> None:
"""Schreibt das Decoration-Layout. WIRKSAM ist `gtk-decoration-layout` in
settings.ini (greift bei NEU geöffneten Fenstern). Zusätzlich der
gnome-gsettings-Key, falls ein Settings-Portal ihn live übersetzt."""
joined = ",".join(
b for b, on in (("minimize", minimize), ("maximize", maximize), ("close", close)) if on
)
value = joined + ":" if side == "left" else ":" + joined
_write_ini("gtk-decoration-layout", value) # wirksam (settings.ini)
_wm_set("button-layout", value) # zusätzlich (gsettings)
@@ -0,0 +1,33 @@
"""eww-Reload — Bar nimmt neuen Akzent sofort über gui.json auf.
Best-effort: re-evaluiert eww-Config (liest ihrerseits gui.json), verändert
eww selbst NICHT direkt. Das eww-Setup gehört Karims separater Config in ~/eww.
Reines stdlib (subprocess), keine GTK-Importe.
"""
from __future__ import annotations
import subprocess
def reload_if_running() -> bool:
"""Löst `eww reload` aus, falls der eww-Daemon läuft. Gibt True zurück,
wenn `eww reload` ausgeführt wurde, sonst False. Best-effort: fängt
FileNotFoundError/TimeoutExpired ab und crasht nie."""
try:
running = subprocess.run(
["pgrep", "-x", "eww"], check=False, timeout=5,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
if running.returncode != 0:
return False # Daemon läuft nicht
try:
subprocess.run(
["eww", "reload"], check=False, timeout=5,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
return True
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""TANINUX root-Helper — wird via pkexec mit erhöhten Rechten gestartet.
Installationspfad: /usr/lib/taninux/taninux-helper
(passend zur polkit-Action ch.gabrielevarano.taninux.maintain, deren
exec.path genau auf diesen Pfad zeigt.)
Idee: Statt `pkexec pacman …` direkt laufen zu lassen (= beliebiges root!),
reicht das GTK-Frontend sein argv durch DIESEN Helper. Der prüft das Kommando
gegen eine feste ALLOWLIST und lehnt alles andere ab. So bleibt die
argv-basierte Schnittstelle (`["pacman","-Syu"]`, `["bootctl","set-default",…]`)
nach aussen unverändert, aber root kann nur noch die erlaubten Operationen.
Aufruf:
taninux-helper <cmd> [args…] → validieren + ausführen (exec)
taninux-helper --check <cmd> [..] → nur validieren, Exit 0/2 (für Tests)
Keine externen Deps — reines Stdlib, damit der Helper auch ohne installiertes
taninux-Paket aus dem Repo heraus läuft.
"""
from __future__ import annotations
import os
import sys
# ALLOWLIST: erste argv-Komponente → erlaubte Operationen.
#
# Pro Kommando definieren wir, welche der nachfolgenden Tokens als Sub-/Flag
# zulässig sind. Tokens, die mit "-" beginnen, müssen in "flags" stehen
# (oder einer der erlaubten Operatoren sein). Nicht-Flag-Tokens gelten als
# Operanden (Paketnamen, Boot-Einträge) und werden NICHT eingeschränkt —
# pacman/bootctl validieren die selbst, und sie können kein anderes Programm
# starten.
#
# Wichtig: KEINE Shell, kein eval. Wir führen via os.execvp() exakt das
# übergebene argv aus, ohne Interpretation durch eine Shell.
ALLOWLIST: dict[str, dict[str, set[str]]] = {
# Pakete (de)installieren + Vollupgrade. --noconfirm erlaubt, damit das
# GUI-Frontend nicht in einem TTY-Prompt hängen bleibt.
"pacman": {
"ops": {"-S", "-Syu", "-Su", "-Sy", "-R", "-Rs", "-Rns"},
"flags": {"--noconfirm", "--needed", "--noprogressbar"},
},
# Firewall an/aus (ufw). Nur enable/disable — keine Regel-Manipulation.
"ufw": {
"ops": {"enable", "disable"},
"flags": set(),
},
# Benutzerverwaltung. useradd bewusst NUR mit -m/-c (KEIN -G/-u/-p/-o →
# keine Gruppen-/UID-Injektion); Admin-Rechte laufen separat über gpasswd
# (zu/aus wheel). passwd nur -e (Reset beim 1. Login erzwingen, kein Setzen).
"useradd": {
"ops": set(),
"flags": {"-m", "-c"},
},
"userdel": {
"ops": set(),
"flags": {"-r"},
},
"gpasswd": {
"ops": {"-a", "-d"},
"flags": set(),
},
"passwd": {
"ops": {"-e"},
"flags": set(),
},
# Bootloader-Default / einmaliger Boot (systemd-boot).
"bootctl": {
"ops": {"set-default", "set-oneshot"},
"flags": set(),
},
# Paket-Cache aufräumen.
"paccache": {
"ops": {"-r", "-rk1", "-rk0", "-ruk0"},
"flags": {"-k", "0", "1", "2", "3", "--remove", "--uninstalled"},
},
# System-Locale + Tastatur-Layout (systemd). Operanden (LANG=…, Layout,
# Variante) sind frei; localectl validiert sie selbst.
"localectl": {
"ops": {"set-locale", "set-keymap", "set-x11-keymap"},
"flags": set(),
},
# Zeitzone + NTP (systemd-timesyncd). Operanden (Zeitzone, true/false)
# sind freie Operanden; timedatectl validiert sie selbst. set-time o.ä.
# ist bewusst NICHT erlaubt.
"timedatectl": {
"ops": {"set-timezone", "set-ntp", "set-local-rtc"},
"flags": set(),
},
# Dienste verwalten (z.B. power-profiles-daemon enablen). Unit-Namen sind
# freie Operanden; nur diese Verben sind erlaubt — kein mask/edit/o.ä.
"systemctl": {
"ops": {"enable", "disable", "start", "stop", "restart", "reenable"},
"flags": {"--now"},
},
# CUPS-Druckerverwaltung. lpadmin: Default setzen (-d) / Drucker entfernen
# (-x); -p/-E nur als Flags zugelassen (kein Hinzufügen mit Treiber).
"lpadmin": {
"ops": {"-d", "-x"},
"flags": {"-p", "-E"},
},
# Drucker an-/abschalten. KEINE Op — nur ein Operand (der Druckername).
"cupsenable": {
"ops": set(),
"flags": set(),
},
"cupsdisable": {
"ops": set(),
"flags": set(),
},
# Journal aufräumen (vacuum). Nur feste, ungefährliche Vacuum-Flags.
"journalctl": {
"ops": set(),
"flags": {"--vacuum-time=2weeks", "--vacuum-size=200M"},
},
# Snapshots (Timeshift). Braucht für ALLES root, auch --list. Snapshot-
# Namen/Kommentare sind freie Operanden (kein "-" am Anfang).
"timeshift": {
"ops": {"--list", "--create", "--delete", "--delete-all", "--restore", "--check"},
"flags": {"--snapshot", "--comments", "--yes", "--scripted", "--tags"},
},
}
class Rejected(Exception):
"""Argv hat die Allowlist nicht passiert."""
def validate(argv: list[str]) -> list[str]:
"""Prüft argv gegen die ALLOWLIST. Gibt das (unveränderte) argv zurück
oder wirft Rejected mit einer Begründung."""
if not argv:
raise Rejected("leeres kommando")
cmd = argv[0]
rule = ALLOWLIST.get(cmd)
if rule is None:
raise Rejected(f"kommando nicht erlaubt: {cmd!r}")
allowed_ops = rule["ops"]
allowed_flags = rule["flags"]
saw_op = False
for tok in argv[1:]:
if tok in allowed_ops:
saw_op = True
continue
if tok in allowed_flags:
continue
if tok.startswith("-"):
# ein Flag/Operator, der weder Operation noch erlaubtes Flag ist
raise Rejected(f"option nicht erlaubt für {cmd}: {tok!r}")
# ein Operand (Paketname, Boot-Eintrag) — keine Optionen-Injektion
# möglich, da kein "-" am Anfang. Operanden sind frei.
# Kommandos mit erlaubten Operationen müssen eine davon nennen. Kommandos
# OHNE Ops (z.B. cupsenable/cupsdisable: nur Operand = Druckername) sind
# auch ohne Op gültig.
if allowed_ops and not saw_op:
raise Rejected(f"keine erlaubte operation für {cmd} angegeben")
# gpasswd darf NUR die wheel-Gruppe verwalten (Admin-Toggle) — keine
# beliebige Gruppen-Mitgliedschaft (z.B. root).
if cmd == "gpasswd" and (not argv or argv[-1] != "wheel"):
raise Rejected("gpasswd nur für die wheel-Gruppe erlaubt")
return argv
def main(raw_argv: list[str]) -> int:
check_only = False
args = raw_argv[1:]
if args and args[0] == "--check":
check_only = True
args = args[1:]
try:
argv = validate(args)
except Rejected as err:
print(f"taninux-helper: abgelehnt: {err}", file=sys.stderr)
return 2
if check_only:
print(f"taninux-helper: ok: {' '.join(argv)}")
return 0
# erlaubtes Kommando ausführen — execvp ersetzt den Helper-Prozess,
# damit pkexec den echten Exit-Code des Kommandos durchreicht.
try:
os.execvp(argv[0], argv)
except OSError as err: # Programm nicht gefunden o.ä.
print(f"taninux-helper: exec fehlgeschlagen: {err}", file=sys.stderr)
return 127
if __name__ == "__main__":
sys.exit(main(sys.argv))
@@ -0,0 +1,149 @@
"""Seiten-Registry fürs GTK-Frontend.
Seiten sind in logische SECTIONS gruppiert (Sidebar zeigt Überschriften).
PAGES ist die flache Ableitung (für Stack-Lookup / Navigation).
Neue Seite = Modul in pages/ mit build() + Eintrag in einer Section unten.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from gi.repository import Gtk
from taninux.gui.pages import (
about,
accessibility,
audio,
backup,
bluetooth,
bundles,
dashboard,
defaultapps,
display,
firewall,
hub_home,
kernel,
locale,
lock,
maintain,
network,
panel,
power,
printers,
settings,
shares,
storage,
store,
update,
users,
vpn,
)
@dataclass(frozen=True)
class Page:
key: str
label: str
icon: str
build: Callable[[], Gtk.Widget]
keywords: tuple[str, ...] = ()
@dataclass(frozen=True)
class Section:
title: str | None # None = keine Überschrift (Home)
pages: list[Page]
# -- System Settings — Hardware/System/Personalisierung --------------------
SETTINGS_SECTIONS: list[Section] = [
Section(None, [
Page("dashboard", "Dashboard", "go-home-symbolic", dashboard.build,
("overview", "status", "summary", "system", "home")),
]),
Section("System", [
Page("about", "About", "help-about-symbolic", about.build,
("about", "system", "info", "version", "hardware", "cpu", "memory")),
Page("kernel", "Kernel", "application-x-firmware-symbolic", kernel.build,
("kernel", "boot", "bootloader", "lts", "prune", "headers")),
Page("backup", "Timeshift", "drive-multidisk-symbolic", backup.build,
("backup", "backups", "snapshot", "timeshift", "restore", "btrfs")),
Page("storage", "Storage", "drive-harddisk-symbolic", storage.build,
("storage", "disk", "drive", "mount", "usb", "space", "eject")),
Page("users", "Users", "system-users-symbolic", users.build,
("users", "account", "password", "login", "name")),
Page("defaultapps", "Default Apps", "emblem-default-symbolic", defaultapps.build,
("default", "apps", "browser", "mail", "open with", "associations")),
]),
Section("Connectivity", [
Page("network", "Network", "network-wireless-symbolic", network.build,
("network", "wifi", "wi-fi", "internet", "connection", "ethernet")),
Page("vpn", "VPN", "network-vpn-symbolic", vpn.build,
("vpn", "wireguard", "openvpn", "dns", "privacy", "tunnel")),
Page("bluetooth", "Bluetooth", "bluetooth-symbolic", bluetooth.build,
("bluetooth", "pair", "devices", "headset")),
Page("shares", "Network Shares", "folder-remote-symbolic", shares.build,
("shares", "smb", "samba", "sftp", "ftp", "webdav", "nextcloud",
"nfs", "mount", "nautilus", "cloud")),
]),
Section("Hardware", [
Page("display", "Display", "video-display-symbolic", display.build,
("display", "monitor", "resolution", "scale", "refresh", "rotation",
"night light", "color temperature")),
Page("audio", "Audio", "audio-volume-high-symbolic", audio.build,
("audio", "sound", "volume", "output", "input", "microphone",
"per-app", "pavucontrol")),
Page("power", "Power", "battery-full-symbolic", power.build,
("power", "battery", "energy", "profile", "brightness", "suspend",
"idle", "screen off", "sleep")),
Page("printers", "Printers", "printer-symbolic", printers.build,
("printer", "cups", "print")),
]),
Section("Privacy & Security", [
Page("firewall", "Firewall", "security-high-symbolic", firewall.build,
("firewall", "ufw", "security", "incoming", "block", "ports")),
Page("lock", "Screen Lock", "system-lock-screen-symbolic", lock.build,
("lock", "screen", "lockscreen", "security", "password")),
]),
Section("Personalization", [
Page("panel", "Panel & Dock", "view-dual-symbolic", panel.build,
("panel", "dock", "bar", "eww", "taskbar")),
Page("locale", "Region & Time", "input-keyboard-symbolic", locale.build,
("region", "time", "timezone", "date", "language", "locale",
"keyboard", "layout", "ntp")),
Page("settings", "Appearance", "emblem-system-symbolic", settings.build,
("appearance", "theme", "accent", "color scheme", "dark", "light",
"icons", "font", "cursor", "wallpaper", "window buttons", "gtk")),
Page("accessibility", "Accessibility", "preferences-desktop-accessibility-symbolic",
accessibility.build,
("accessibility", "a11y", "large text", "contrast", "animations", "zoom")),
]),
]
# -- Software Hub — Paketverwaltung ----------------------------------------
HUB_SECTIONS: list[Section] = [
Section(None, [
Page("home", "Home", "go-home-symbolic", hub_home.build,
("home", "recommended", "discover", "suggestions", "featured")),
Page("store", "Browse", "system-search-symbolic", store.build,
("search", "install", "remove", "packages", "pacman", "aur",
"flatpak", "software", "apps")),
]),
Section("Packages", [
Page("update", "Updates", "software-update-available-symbolic", update.build,
("update", "upgrade", "pacman", "flatpak", "aur", "packages")),
Page("maintain", "Cleanup", "user-trash-symbolic", maintain.build,
("cleanup", "clean", "cache", "orphans", "trash", "disk space",
"journal", "coredumps")),
Page("bundles", "Bundles", "application-x-addon-symbolic", bundles.build,
("bundles", "packages", "install", "recommended", "suggestions",
"dev", "gaming")),
]),
]
# Default-Sektionen = System Settings (Rückwärtskompatibilität).
SECTIONS: list[Section] = SETTINGS_SECTIONS
# Flache Liste über BEIDE Apps (für _select / Lookup / Tooling).
PAGES: list[Page] = [p for s in (SETTINGS_SECTIONS + HUB_SECTIONS) for p in s.pages]
@@ -0,0 +1,66 @@
"""About — Überblick über System und Hardware (read-only)."""
from __future__ import annotations
import gi
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.core import sysinfo
def _ver() -> str:
try:
from importlib.metadata import version
return version("taninux")
except Exception: # noqa: BLE001
return "0.2.0"
def _row(title: str, value: str) -> Adw.ActionRow:
row = Adw.ActionRow(title=title)
lbl = Gtk.Label(label=value, xalign=1.0, wrap=True)
lbl.add_css_class("dim-label")
lbl.set_selectable(True)
row.add_suffix(lbl)
return row
def _fmt_gib(b: int) -> str:
return f"{b / 1024**3:.1f} GiB" if b else ""
def _uptime(sec: float) -> str:
m = int(sec // 60)
d, h, m = m // 1440, (m % 1440) // 60, m % 60
if d:
return f"{d}d {h}h {m}m"
return f"{h}h {m}m" if h else f"{m}m"
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
mem = sysinfo.memory()
gpus = sysinfo.gpus()
g_sys = Adw.PreferencesGroup(title="System")
g_sys.add(_row("Device", sysinfo.hostname()))
g_sys.add(_row("Operating system", sysinfo.distro_pretty()))
g_sys.add(_row("Kernel", sysinfo.kernel_release()))
g_sys.add(_row("TANINUX", _ver()))
page.add(g_sys)
g_hw = Adw.PreferencesGroup(title="Hardware")
g_hw.add(_row("Processor", f"{sysinfo.cpu_model()} · {sysinfo.cpu_count()} cores"))
g_hw.add(_row("Memory", _fmt_gib(mem.get("MemTotal", 0))))
if gpus:
for i, gpu in enumerate(gpus):
title = "Graphics" if i == 0 else ""
g_hw.add(_row(title, gpu.model))
page.add(g_hw)
g_st = Adw.PreferencesGroup(title="Status")
g_st.add(_row("Uptime", _uptime(sysinfo.uptime_seconds())))
page.add(g_st)
return page
@@ -0,0 +1,35 @@
"""Accessibility — Lesbarkeit und Bewegung (gsettings, user-level)."""
from __future__ import annotations
import gi
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.core import accessibility as a11y
def _switch(title: str, subtitle: str, getter, setter) -> Adw.SwitchRow:
row = Adw.SwitchRow(title=title, subtitle=subtitle)
row.set_active(getter())
row.connect("notify::active", lambda r, _p: setter(r.get_active()))
return row
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
g_see = Adw.PreferencesGroup(title="Seeing")
g_see.add(_switch("Large text", "Increase the size of text",
a11y.large_text, a11y.set_large_text))
g_see.add(_switch("High contrast", "Make windows and buttons stand out",
a11y.high_contrast, a11y.set_high_contrast))
page.add(g_see)
g_motion = Adw.PreferencesGroup(title="Motion")
g_motion.add(_switch("Reduce animations", "Minimise motion across the system",
lambda: not a11y.animations(),
lambda on: a11y.set_animations(not on)))
page.add(g_motion)
return page
@@ -0,0 +1,203 @@
"""App-Detailansicht (Adw.Dialog) im GNOME-Software-Stil.
Zeigt Icon · Name · Beschreibung · Version/Größe/Lizenz/Website und lässt die
Installationsquelle wählen (Repo · Flatpak · AUR falls aktiviert). Quellen und
Metadaten kommen async aus core.appinfo.
"""
from __future__ import annotations
import threading
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, GLib, Gtk # noqa: E402
from taninux.core import appinfo, panel
from taninux.gui.console import LogConsole, log_expander
from taninux.gui.process import ProcessRunner
def _icon_name(name: str, fallback: str = "package-x-generic-symbolic") -> str:
theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default())
return name if (theme and theme.has_icon(name)) else fallback
def open_app_detail(parent: Gtk.Widget, *, title: str, icon: str,
repo_name: str | None = None, flatpak_id: str | None = None,
remote: str = "flathub", on_changed=None) -> None:
dialog = Adw.Dialog()
dialog.set_title(title)
dialog.set_content_width(540)
dialog.set_content_height(640)
console = LogConsole()
runner = ProcessRunner()
runner.connect("line", lambda _r, line: console.append_line(line))
cand: list[appinfo.Detail] = []
page = Adw.PreferencesPage()
tv = Adw.ToolbarView()
tv.add_top_bar(Adw.HeaderBar())
tv.set_content(page)
dialog.set_child(tv)
# -- Kopf: Icon + Name + Summary --------------------------------------
hg = Adw.PreferencesGroup()
head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=16)
head.set_margin_top(8)
head.set_margin_bottom(8)
img = Gtk.Image.new_from_icon_name(_icon_name(icon))
img.set_pixel_size(72)
head.append(img)
tbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
tbox.set_valign(Gtk.Align.CENTER)
name_lbl = Gtk.Label(label=title, xalign=0.0)
name_lbl.add_css_class("title-1")
summary_lbl = Gtk.Label(label="", xalign=0.0, wrap=True)
summary_lbl.add_css_class("dim-label")
tbox.append(name_lbl)
tbox.append(summary_lbl)
head.append(tbox)
hg.add(head)
page.add(hg)
# -- Quelle + Aktion --------------------------------------------------
sg = Adw.PreferencesGroup(title="Install")
source_combo = Adw.ComboRow(title="Source")
sg.add(source_combo)
action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
action_box.set_margin_top(10)
action_btn = Gtk.Button(label="Install")
action_btn.add_css_class("suggested-action")
action_btn.add_css_class("pill")
action_btn.set_sensitive(False)
spinner = Adw.Spinner()
spinner.set_valign(Gtk.Align.CENTER)
spinner.set_visible(False)
action_box.append(action_btn)
action_box.append(spinner)
sg.add(action_box)
page.add(sg)
# -- Details ----------------------------------------------------------
dg = Adw.PreferencesGroup(title="Details")
page.add(dg)
detail_rows: list[Gtk.Widget] = []
page.add(log_expander(console))
def _selected() -> appinfo.Detail | None:
idx = source_combo.get_selected()
return cand[idx] if 0 <= idx < len(cand) else None
def _static_row(label: str, value: str, *, link: bool = False) -> Gtk.Widget:
row = Adw.ActionRow(title=label)
if link:
btn = Gtk.LinkButton(uri=value, label=value)
btn.set_valign(Gtk.Align.CENTER)
row.add_suffix(btn)
else:
lab = Gtk.Label(label=value, xalign=1.0, wrap=True)
lab.add_css_class("dim-label")
lab.set_selectable(True)
row.add_suffix(lab)
return row
def _show_details(d: appinfo.Detail) -> None:
for r in detail_rows:
dg.remove(r)
detail_rows.clear()
summary_lbl.set_label(d.summary or "")
fields = [
("Version", d.version),
("Download size", d.size),
("Installed size", d.installed_size),
("License", d.license),
]
for lab, val in fields:
if val:
r = _static_row(lab, val)
dg.add(r)
detail_rows.append(r)
if d.url:
r = _static_row("Website", d.url, link=True)
dg.add(r)
detail_rows.append(r)
if d.installed:
action_btn.set_label("Remove")
action_btn.remove_css_class("suggested-action")
action_btn.add_css_class("destructive-action")
else:
action_btn.set_label("Install")
action_btn.remove_css_class("destructive-action")
action_btn.add_css_class("suggested-action")
action_btn.set_sensitive(True)
def _on_source(_combo, _pspec=None) -> None:
d = _selected()
if d:
_show_details(d)
source_combo.connect("notify::selected", _on_source)
def _on_action(_b) -> None:
d = _selected()
if not d:
return
argv, priv = (appinfo.remove_argv if d.installed else appinfo.install_argv)(d)
action_btn.set_sensitive(False)
spinner.set_visible(True)
console.clear()
runner.run(argv, privileged=priv)
action_btn.connect("clicked", _on_action)
def on_done(_r, code: int) -> None:
console.append_line("✔ done" if code == 0 else f"✘ exit {code}")
spinner.set_visible(False)
if callable(on_changed):
on_changed()
_load() # Quellen/Installed-Status neu ermitteln
runner.connect("done", on_done)
def _populate(found: list[appinfo.Detail]) -> None:
cand.clear()
cand.extend(found)
if not found:
source_combo.set_model(Gtk.StringList.new(["Not available"]))
source_combo.set_sensitive(False)
action_btn.set_sensitive(False)
summary_lbl.set_label("This app could not be found in any enabled source.")
return
source_combo.set_sensitive(True)
labels = []
for d in found:
lab = appinfo.SOURCE_LABEL.get(d.source, d.source)
labels.append(f"{lab} · {d.version}" if d.version else lab)
source_combo.set_model(Gtk.StringList.new(labels))
# bevorzugt eine bereits installierte Quelle vorwählen
sel = next((i for i, d in enumerate(found) if d.installed), 0)
source_combo.set_selected(sel)
_show_details(found[sel])
def _load() -> None:
include_aur = panel.include_aur()
def worker() -> None:
try:
found = appinfo.candidates(
repo_name=repo_name, flatpak_id=flatpak_id,
remote=remote, include_aur=include_aur,
)
except Exception: # noqa: BLE001
found = []
GLib.idle_add(lambda: (_populate(found), False)[1])
threading.Thread(target=worker, daemon=True).start()
_load()
dialog.present(parent)
@@ -0,0 +1,197 @@
"""Audio — pavucontrol-artige Steuerung (Output/Input) über core.audio (pactl).
Pro Richtung: Geräte-Auswahl (= setzt Default), Lautstärke-Slider, Mute.
Alles User-Level, kein root. Live-Updates von aussen (anderes Tool ändert
Volume) holt man per Refresh — eine pactl-subscribe-Schleife wäre der nächste
Ausbau.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.core import audio
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
if not audio.available():
group = Adw.PreferencesGroup(title="Audio")
group.add(Adw.ActionRow(
title="No audio server",
subtitle="pactl found no PulseAudio/PipeWire server.",
))
page.add(group)
return page
page.add(_audio_group(
"sink", "Output",
"audio-volume-high-symbolic", "audio-volume-muted-symbolic",
))
page.add(_audio_group(
"source", "Input",
"audio-input-microphone-symbolic", "microphone-disabled-symbolic",
))
page.add(_apps_group())
return page
def _apps_group() -> Adw.PreferencesGroup:
"""Per-App-Lautstärke der gerade abspielenden Sink-Inputs."""
group = Adw.PreferencesGroup(title="Applications")
refresh = Gtk.Button(icon_name="view-refresh-symbolic")
refresh.add_css_class("flat")
refresh.set_valign(Gtk.Align.CENTER)
refresh.set_tooltip_text("Refresh")
group.set_header_suffix(refresh)
rows: list[Gtk.Widget] = []
def populate() -> None:
for r in rows:
group.remove(r)
rows.clear()
streams = audio.playback_streams()
if not streams:
row = Adw.ActionRow(title="No apps are playing audio")
group.add(row)
rows.append(row)
return
for st in streams:
rows.append(_stream_row(st))
group.add(rows[-1])
refresh.connect("clicked", lambda _b: populate())
populate()
return group
def _stream_row(st: audio.Stream) -> Adw.ActionRow:
icon_on = "audio-volume-high-symbolic"
icon_off = "audio-volume-muted-symbolic"
row = Adw.ActionRow(title=st.app)
adj = Gtk.Adjustment(value=0, lower=0, upper=150, step_increment=1,
page_increment=5)
scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj)
scale.set_hexpand(True)
scale.set_size_request(200, -1)
scale.set_draw_value(False)
scale.set_valign(Gtk.Align.CENTER)
scale.add_mark(100, Gtk.PositionType.BOTTOM, None)
mute_btn = Gtk.ToggleButton()
mute_btn.add_css_class("flat")
mute_btn.set_valign(Gtk.Align.CENTER)
row.add_suffix(scale)
row.add_suffix(mute_btn)
# -- Initialwerte VOR connect setzen (kein Initial-Fire) -----------
adj.set_value(min(st.volume, 150))
mute_btn.set_active(st.muted)
mute_btn.set_icon_name(icon_off if st.muted else icon_on)
def on_vol(a: Gtk.Adjustment) -> None:
audio.set_stream_volume(st.index, int(a.get_value()))
def on_mute(b: Gtk.ToggleButton) -> None:
m = b.get_active()
audio.set_stream_mute(st.index, m)
b.set_icon_name(icon_off if m else icon_on)
adj.connect("value-changed", on_vol)
mute_btn.connect("toggled", on_mute)
return row
def _audio_group(kind: str, title: str, icon_on: str, icon_off: str) -> Adw.PreferencesGroup:
devices = audio.list_devices(kind)
group = Adw.PreferencesGroup(title=title)
if not devices:
group.add(Adw.ActionRow(title="No devices found"))
return group
names = [d.name for d in devices]
descs = [d.description for d in devices]
default = audio.get_default(kind)
state = {"name": default if default in names else names[0]}
# -- Geräte-Auswahl (= Default setzen) ----------------------------
combo = Adw.ComboRow(title="Device")
combo.set_model(Gtk.StringList.new(descs))
combo.set_selected(names.index(state["name"]))
# -- Lautstärke-Zeile: Slider + % + Mute --------------------------
vol_row = Adw.ActionRow(title="Volume")
adj = Gtk.Adjustment(value=0, lower=0, upper=150, step_increment=1,
page_increment=5)
scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj)
scale.set_hexpand(True)
scale.set_size_request(220, -1)
scale.set_draw_value(False)
scale.set_valign(Gtk.Align.CENTER)
scale.add_mark(100, Gtk.PositionType.BOTTOM, None) # 100%-Markierung
pct = Gtk.Label()
pct.add_css_class("dim-label")
pct.add_css_class("numeric")
pct.set_width_chars(4)
mute_btn = Gtk.ToggleButton()
mute_btn.add_css_class("flat")
mute_btn.set_valign(Gtk.Align.CENTER)
vol_row.add_suffix(scale)
vol_row.add_suffix(pct)
vol_row.add_suffix(mute_btn)
guard = {"sync": False}
def load(name: str) -> None:
guard["sync"] = True
v = audio.get_volume(kind, name)
m = audio.get_muted(kind, name)
adj.set_value(min(v, 150))
pct.set_label(f"{v}%")
mute_btn.set_active(m)
mute_btn.set_icon_name(icon_off if m else icon_on)
guard["sync"] = False
load(state["name"])
def on_combo(c: Adw.ComboRow, _p) -> None:
i = c.get_selected()
if 0 <= i < len(names):
state["name"] = names[i]
audio.set_default(kind, names[i])
load(names[i])
def on_vol(a: Gtk.Adjustment) -> None:
if guard["sync"]:
return
v = int(a.get_value())
pct.set_label(f"{v}%")
audio.set_volume(kind, state["name"], v)
def on_mute(b: Gtk.ToggleButton) -> None:
if guard["sync"]:
return
m = b.get_active()
audio.set_mute(kind, state["name"], m)
b.set_icon_name(icon_off if m else icon_on)
combo.connect("notify::selected", on_combo)
adj.connect("value-changed", on_vol)
mute_btn.connect("toggled", on_mute)
group.add(combo)
group.add(vol_row)
return group
@@ -0,0 +1,219 @@
"""Backups-Seite — Time-Machine-artige Snapshots über Timeshift.
ALLE Timeshift-Aufrufe (auch --list) brauchen root → laufen privilegiert via
pkexec durch den taninux-helper. Restore ist sehr destruktiv (stellt das System
wieder her, ggf. Reboot) → mit Bestätigungsdialog.
"""
from __future__ import annotations
import gi
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.core import backup
from taninux.gui import confirm
from taninux.gui.console import LogConsole, log_expander
from taninux.gui.process import ProcessRunner
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
if not backup.available():
_build_install(page)
return page
# Gemeinsame Konsole + Action-Runner (create/delete/restore). Nach jeder
# Aktion wird die Snapshot-Liste neu geladen.
console = LogConsole()
action_runner = ProcessRunner()
action_runner.connect("line", lambda _r, line: console.append_line(line))
# Snapshots-Gruppe (wird in-place neu befüllt nach jeder Aktion).
g_snaps = Adw.PreferencesGroup(
title="Snapshots",
description="Restore reverts the system and may require a reboot.",
)
rows: list[Gtk.Widget] = []
def reload_snapshots() -> None:
_load_snapshots(g_snaps, rows, console, action_runner, reload_snapshots)
def on_action_done(_r: ProcessRunner, code: int) -> None:
console.append_line("")
console.append_line("✔ done (exit 0)" if code == 0 else f"✘ exit {code}")
reload_snapshots()
action_runner.connect("done", on_action_done)
# -- Create-Gruppe ------------------------------------------------
page.add(_create_group(console, action_runner))
# -- Snapshots-Gruppe (initial laden) -----------------------------
page.add(g_snaps)
reload_snapshots()
# -- Log-Gruppe ---------------------------------------------------
page.add(log_expander(console))
return page
def _build_install(page: Adw.PreferencesPage) -> None:
"""Fallback-UI, wenn timeshift nicht installiert ist."""
from taninux.gui.console import RunnerCard
group = Adw.PreferencesGroup(
title="Timeshift not installed",
description="Backups use Timeshift for system snapshots.",
)
card = RunnerCard(
"Install timeshift",
["pacman", "-S", "--needed", "--noconfirm", "timeshift"],
privileged=True,
)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box.set_margin_top(6)
box.append(card)
group.add(box)
page.add(group)
def _create_group(console: LogConsole, runner: ProcessRunner) -> Adw.PreferencesGroup:
"""EntryRow für Kommentar + Button zum Erstellen eines Snapshots."""
group = Adw.PreferencesGroup(title="Create snapshot")
entry = Adw.EntryRow(title="Comment")
group.add(entry)
button = Gtk.Button(label="Create snapshot")
button.add_css_class("suggested-action")
button.add_css_class("pill")
button.set_halign(Gtk.Align.START)
def on_click(_b: Gtk.Button) -> None:
if runner.running:
return
console.clear()
runner.run(backup.create_argv(entry.get_text()), privileged=True)
button.connect("clicked", on_click)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box.set_margin_top(6)
box.append(button)
group.add(box)
return group
def _load_snapshots(
group: Adw.PreferencesGroup,
rows: list[Gtk.Widget],
console: LogConsole,
action_runner: ProcessRunner,
reload: "callable",
) -> None:
"""Lädt `timeshift --list` privilegiert über einen EIGENEN Runner, sammelt
die Ausgabe in einem Buffer und baut bei `done` die Zeilen neu auf."""
# bestehende Zeilen entfernen
for row in rows:
group.remove(row)
rows.clear()
spinner_row = Adw.ActionRow(title="Loading snapshots…")
spinner = Gtk.Spinner()
spinner.start()
spinner_row.add_prefix(spinner)
group.add(spinner_row)
rows.append(spinner_row)
buffer: list[str] = []
list_runner = ProcessRunner()
list_runner.connect("line", lambda _r, line: buffer.append(line))
def on_done(_r: ProcessRunner, code: int) -> None:
spinner.stop()
group.remove(spinner_row)
rows.remove(spinner_row)
if code != 0:
err_row = Adw.ActionRow(title="Could not load snapshots")
group.add(err_row)
rows.append(err_row)
return
snapshots = backup.parse_list("\n".join(buffer))
if not snapshots:
empty_row = Adw.ActionRow(title="No snapshots yet")
group.add(empty_row)
rows.append(empty_row)
return
for snap in snapshots:
subtitle = " ".join(p for p in (snap.tags, snap.description) if p)
row = Adw.ActionRow(title=snap.name, subtitle=subtitle or None)
row.add_suffix(_restore_button(snap.name, console, action_runner, reload))
row.add_suffix(_delete_button(snap.name, console, action_runner, reload))
group.add(row)
rows.append(row)
list_runner.connect("done", on_done)
list_runner.run(backup.list_argv(), privileged=True)
def _restore_button(
name: str,
console: LogConsole,
runner: ProcessRunner,
reload: "callable",
) -> Gtk.Button:
button = Gtk.Button(label="Restore")
button.add_css_class("destructive-action")
button.set_valign(Gtk.Align.CENTER)
def do_run() -> None:
if runner.running:
return
console.clear()
runner.run(backup.restore_argv(name), privileged=True)
def on_click(_b: Gtk.Button) -> None:
confirm.confirm(
button,
"Restore this snapshot?",
f"This reverts the entire system to {name}. All changes since then "
"will be lost and a reboot may be required. Runs as root via pkexec.",
do_run,
)
button.connect("clicked", on_click)
return button
def _delete_button(
name: str,
console: LogConsole,
runner: ProcessRunner,
reload: "callable",
) -> Gtk.Button:
button = Gtk.Button(label="Delete")
button.add_css_class("destructive-action")
button.set_valign(Gtk.Align.CENTER)
def do_run() -> None:
if runner.running:
return
console.clear()
runner.run(backup.delete_argv(name), privileged=True)
def on_click(_b: Gtk.Button) -> None:
confirm.confirm(
button,
"Delete this snapshot?",
f"Snapshot {name} will be permanently removed. Runs as root via pkexec.",
do_run,
)
button.connect("clicked", on_click)
return button
@@ -0,0 +1,173 @@
"""Bluetooth — über core.bluetooth (bluetoothctl / BlueZ).
Power-Toggle, Scan (timed), Geräteliste mit Connect/Disconnect. User-Level,
kein root. Live-Output im Log.
"""
from __future__ import annotations
import threading
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, GLib, Gtk # noqa: E402
from taninux.core import bluetooth
from taninux.gui import confirm
from taninux.gui.console import LogConsole, log_expander
from taninux.gui.process import ProcessRunner
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
if not bluetooth.available():
g = Adw.PreferencesGroup(title="Bluetooth")
g.add(Adw.ActionRow(
title="No Bluetooth controller",
subtitle="bluetoothctl found no adapter / BlueZ not running.",
))
page.add(g)
return page
console = LogConsole()
runner = ProcessRunner()
runner.connect("line", lambda _r, ln: console.append_line(ln))
# -- Power + Scan --------------------------------------------------
g_ctrl = Adw.PreferencesGroup(title="Bluetooth")
power_sw = Adw.SwitchRow(title="Bluetooth", subtitle="Power the adapter")
power_sw.set_active(bluetooth.powered())
def on_power(sw: Adw.SwitchRow, _p) -> None:
runner.run(bluetooth.set_power_argv(sw.get_active()))
power_sw.connect("notify::active", on_power)
g_ctrl.add(power_sw)
page.add(g_ctrl)
# -- Geräte (async) ------------------------------------------------
g_dev = Adw.PreferencesGroup(
title="Devices",
description="Pairing PIN-protected devices may need a Bluetooth agent "
"and can fail here — check the log.",
)
scan_btn = Gtk.Button(icon_name="view-refresh-symbolic")
scan_btn.add_css_class("flat")
scan_btn.set_valign(Gtk.Align.CENTER)
scan_btn.set_tooltip_text("Scan for devices (8 s)")
g_dev.set_header_suffix(scan_btn)
page.add(g_dev)
rows: list[Gtk.Widget] = []
# Nach erfolgreichem Pair automatisch Trust nachschieben. Da der Runner
# nur EINEN Prozess gleichzeitig führt, wird die Folge-Aktion erst im
# nächsten `done` ausgeführt. Liste = [argv, …], FIFO.
pending: list[list[str]] = []
def _btn(label: str, *, css: str | None = None) -> Gtk.Button:
b = Gtk.Button(label=label)
b.add_css_class("flat")
if css:
b.add_css_class(css)
b.set_valign(Gtk.Align.CENTER)
return b
def _clear() -> None:
for r in rows:
g_dev.remove(r)
rows.clear()
def _render(devs: list[bluetooth.BtDevice]) -> None:
_clear()
if not devs:
empty = Adw.ActionRow(title="No devices",
subtitle="Hit rescan to discover nearby devices.")
g_dev.add(empty)
rows.append(empty)
return
for d in devs:
sub = "connected" if d.connected else ("paired" if d.paired else "available")
if d.paired and d.trusted:
sub += " · trusted"
row = Adw.ActionRow(title=d.name, subtitle=sub)
row.add_prefix(Gtk.Image.new_from_icon_name("bluetooth-symbolic"))
if not d.paired:
# Unbekanntes Gerät: Pair (und danach automatisch Trust).
pair = _btn("Pair", css="suggested-action")
pair.connect("clicked", lambda _b, m=d.mac: _pair(m))
row.add_suffix(pair)
else:
if d.connected:
primary = _btn("Disconnect")
primary.connect("clicked",
lambda _b, m=d.mac: _run(bluetooth.disconnect_argv(m)))
else:
primary = _btn("Connect", css="suggested-action")
primary.connect("clicked",
lambda _b, m=d.mac: _run(bluetooth.connect_argv(m)))
row.add_suffix(primary)
forget = _btn("Forget", css="destructive-action")
forget.connect("clicked",
lambda _b, m=d.mac, n=d.name: _forget(m, n))
row.add_suffix(forget)
g_dev.add(row)
rows.append(row)
def _run(argv: list[str]) -> None:
console.clear()
runner.run(argv)
def _pair(mac: str) -> None:
# Pair zuerst; Trust wird nach erfolgreichem Pair nachgereicht.
pending.append(bluetooth.trust_argv(mac))
_run(bluetooth.pair_argv(mac))
def _forget(mac: str, name: str) -> None:
confirm.confirm(
page,
heading="Forget device?",
body=f"Remove the pairing for {name}. You will have to pair it again.",
on_confirm=lambda: _run(bluetooth.remove_argv(mac)),
)
def _load() -> None:
def worker() -> None:
devs = bluetooth.devices()
GLib.idle_add(lambda: (_render(devs), False)[1])
threading.Thread(target=worker, daemon=True).start()
def _scan() -> None:
_clear()
spin_row = Adw.ActionRow(title="Scanning … (8 s)")
sp = Adw.Spinner()
sp.set_valign(Gtk.Align.CENTER)
spin_row.add_prefix(sp)
g_dev.add(spin_row)
rows.append(spin_row)
console.clear()
runner.run(bluetooth.scan_argv(8))
scan_btn.connect("clicked", lambda _b: _scan())
def on_done(_r: ProcessRunner, code: int) -> None:
console.append_line("✔ done" if code == 0 else f"✘ exit {code}")
power_sw.set_active(bluetooth.powered())
# Verkettete Folge-Aktion (z. B. Trust nach Pair) nur bei Erfolg.
if code == 0 and pending:
nxt = pending.pop(0)
runner.run(nxt)
return
pending.clear() # bei Fehler offene Kette verwerfen
_load() # Liste nach jeder Aktion (Scan/Connect) auffrischen
runner.connect("done", on_done)
GLib.idle_add(lambda: (_load(), False)[1])
# -- Log -----------------------------------------------------------
page.add(log_expander(console))
return page
@@ -0,0 +1,140 @@
"""Bundles — deklarative Paket-Sets über bundles.loader.
Pro Bundle eine ExpanderRow mit installiert/fehlend-Status pro Paket und einem
„Install missing"-Button (pacman -S --needed via Helper). Status wird hier
selbst berechnet (kein Import aus der Textual-screen.py). Nach jedem Lauf wird
neu berechnet.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.bundles.loader import all_bundles
from taninux.core import pacman
from taninux.gui import confirm
from taninux.gui.console import LogConsole, log_expander
from taninux.gui.process import ProcessRunner
from taninux.suggest import rules
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
console = LogConsole()
runner = ProcessRunner()
runner.connect("line", lambda _r, ln: console.append_line(ln))
g_rec = Adw.PreferencesGroup(
title="Recommended",
description="System-aware suggestions for your hardware and tools.",
)
page.add(g_rec)
g = Adw.PreferencesGroup(title="Bundles")
page.add(g)
rows: list[Gtk.Widget] = []
rec_rows: list[Gtk.Widget] = []
def _clear() -> None:
for r in rows:
g.remove(r)
rows.clear()
def _install_pkgs(pkgs: list[str], parent: Gtk.Widget, title: str) -> None:
def do() -> None:
console.clear()
runner.run(["pacman", "-S", "--needed", "--noconfirm", *pkgs], privileged=True)
confirm.confirm(
parent, f"Install {title}",
"Install via pacman: " + ", ".join(pkgs), do,
)
def _install_one(pkg: str, parent: Gtk.Widget) -> None:
_install_pkgs([pkg], parent, pkg)
def _render() -> None:
_clear()
inst = pacman.installed_packages()
for b in all_bundles():
n_inst = sum(1 for p in b.packages if p in inst)
total = len(b.packages)
complete = n_inst == total
row = Adw.ExpanderRow(title=b.label, subtitle=b.tagline)
# Count-Badge als Suffix (statt "Install missing"-Button).
badge = Gtk.Label(label=f"{n_inst}/{total}")
badge.add_css_class("numeric")
badge.add_css_class("success" if complete else "dim-label")
badge.add_css_class("caption-heading")
badge.set_valign(Gtk.Align.CENTER)
row.add_suffix(badge)
# Pro Paket eine Zeile: installiert = dim-Tag, fehlend = Install-Button.
for pkg in b.packages:
pr = Adw.ActionRow(title=pkg)
if pkg in inst:
tag = Gtk.Label(label="installed")
tag.add_css_class("dim-label")
tag.add_css_class("caption")
tag.set_valign(Gtk.Align.CENTER)
pr.add_suffix(tag)
else:
ib = Gtk.Button(label="Install")
ib.add_css_class("suggested-action")
ib.set_valign(Gtk.Align.CENTER)
ib.connect("clicked", lambda _b, p=pkg: _install_one(p, _b))
pr.add_suffix(ib)
row.add_row(pr)
g.add(row)
rows.append(row)
def _render_rec() -> None:
for r in rec_rows:
g_rec.remove(r)
rec_rows.clear()
sugg = rules.all_suggestions(set(pacman.installed_packages()))
if not sugg:
row = Adw.ActionRow(title="Nothing recommended",
subtitle="Your system looks complete.")
g_rec.add(row)
rec_rows.append(row)
return
for s in sorted(sugg, key=lambda x: x.priority):
row = Adw.ActionRow(title=s.title or s.reason, subtitle=s.reason)
btn = Gtk.Button(label="Install")
btn.add_css_class("suggested-action")
btn.set_valign(Gtk.Align.CENTER)
btn.set_tooltip_text(", ".join(s.packages))
btn.connect(
"clicked",
lambda _b, p=list(s.packages), t=s.title or "packages":
_install_pkgs(p, _b, t),
)
row.add_suffix(btn)
g_rec.add(row)
rec_rows.append(row)
def _refresh() -> None:
_render_rec()
_render()
def on_done(_r: ProcessRunner, code: int) -> None:
console.append_line("✔ done" if code == 0 else f"✘ exit {code}")
pacman.installed_packages.cache_clear()
_refresh()
runner.connect("done", on_done)
# -- Log ----------------------------------------------------------
page.add(log_expander(console))
_refresh()
return page
@@ -0,0 +1,281 @@
"""Dashboard — Startseite als responsives Card-Grid.
Kein abgespecktes Listen-Klon der System-Seite, sondern Karten mit großen
Werten, Level-Bars und Akzenten. Read-only aus den core-Modulen; die langsamen
Teile (Updates, Flatpak) laden async und füllen ihre Karten nach.
"""
from __future__ import annotations
import json
import threading
from pathlib import Path
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, GLib, Gtk # noqa: E402
from taninux.core import audio, network, packages, power, sysinfo
def _bytes(n: int) -> str:
f = float(n)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if f < 1024 or unit == "TiB":
return f"{f:.0f} {unit}" if unit == "B" else f"{f:.1f} {unit}"
f /= 1024
return f"{f:.1f} TiB"
def _uptime(secs: float) -> str:
s = int(secs)
d, s = divmod(s, 86400)
h, s = divmod(s, 3600)
m, _ = divmod(s, 60)
parts = []
if d:
parts.append(f"{d}d")
if h:
parts.append(f"{h}h")
parts.append(f"{m}m")
return " ".join(parts)
# ----------------------------------------------------------- card builders
def _shell(icon: str, title: str) -> tuple[Gtk.Widget, Gtk.Box]:
"""Karten-Hülle (.card) + innerer Content-Box. Gibt (card, content)."""
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
card.add_css_class("card")
card.set_valign(Gtk.Align.START)
inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
inner.set_margin_top(16)
inner.set_margin_bottom(16)
inner.set_margin_start(16)
inner.set_margin_end(16)
head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
img = Gtk.Image.new_from_icon_name(icon)
img.add_css_class("dim-label")
head.append(img)
t = Gtk.Label(label=title, xalign=0.0)
t.add_css_class("caption-heading")
t.add_css_class("dim-label")
head.append(t)
inner.append(head)
card.append(inner)
return card, inner
def _metric(icon: str, title: str, value: str, *, sub: str | None = None,
frac: float | None = None, value_css: str | None = None):
"""Stat-Karte: großer Wert + optional Untertitel + Level-Bar.
Gibt (card, value_label, sub_label) — Labels für async-Updates."""
card, inner = _shell(icon, title)
v = Gtk.Label(label=value, xalign=0.0)
v.add_css_class("title-2")
if value_css:
v.add_css_class(value_css)
inner.append(v)
s = Gtk.Label(label=sub or "", xalign=0.0)
s.add_css_class("dim-label")
s.add_css_class("caption")
s.set_wrap(True)
s.set_visible(bool(sub))
inner.append(s)
if frac is not None:
lb = Gtk.LevelBar()
lb.set_min_value(0.0)
lb.set_max_value(1.0)
lb.set_value(max(0.0, min(1.0, frac)))
lb.set_margin_top(6)
inner.append(lb)
return card, v, s
def _info(icon: str, title: str, items: list[tuple[str, str]]) -> Gtk.Widget:
"""Info-Karte mit mehreren Label/Wert-Zeilen."""
card, inner = _shell(icon, title)
grid = Gtk.Grid()
grid.set_row_spacing(4)
grid.set_column_spacing(16)
grid.set_margin_top(2)
for r, (k, val) in enumerate(items):
kl = Gtk.Label(label=k, xalign=0.0)
kl.add_css_class("dim-label")
vl = Gtk.Label(label=val, xalign=1.0, hexpand=True)
vl.set_selectable(True)
vl.set_ellipsize(3) # END
grid.attach(kl, 0, r, 1, 1)
grid.attach(vl, 1, r, 1, 1)
inner.append(grid)
return card
def _timeshift_summary() -> tuple[str, str]:
"""(value, sub) aus der world-readable Timeshift-Config — ohne root."""
cfg = Path("/etc/timeshift/timeshift.json")
if not cfg.is_file():
return "Not set up", "Timeshift not configured"
try:
data = json.loads(cfg.read_text())
except (OSError, json.JSONDecodeError):
return "", "config unreadable"
mode = "BTRFS" if data.get("btrfs_mode") == "true" else "rsync"
sched = [k[len("schedule_"):] for k, v in data.items()
if k.startswith("schedule_") and v == "true"]
sub = ("schedule: " + ", ".join(sched)) if sched else "manual snapshots only"
return mode, sub
def build() -> Gtk.Widget:
flow = Gtk.FlowBox()
flow.set_selection_mode(Gtk.SelectionMode.NONE)
flow.set_homogeneous(True)
flow.set_min_children_per_line(2)
flow.set_max_children_per_line(2)
flow.set_column_spacing(12)
flow.set_row_spacing(12)
flow.set_valign(Gtk.Align.START)
# -- System (Info-Karte) ------------------------------------------
flow.append(_info("computer-symbolic", "System", [
("Host", sysinfo.hostname()),
("Distro", sysinfo.distro_pretty()),
("Kernel", sysinfo.kernel_release()),
("Uptime", _uptime(sysinfo.uptime_seconds())),
]))
# -- Memory -------------------------------------------------------
mem = sysinfo.memory()
total = mem.get("MemTotal", 0)
used = total - mem.get("MemAvailable", 0)
if total:
flow.append(_metric(
"drive-harddisk-symbolic", "Memory", _bytes(used),
sub=f"of {_bytes(total)} used", frac=used / total,
)[0])
# -- CPU load -----------------------------------------------------
la = sysinfo.load_avg()
cpus = sysinfo.cpu_count()
flow.append(_metric(
"utilities-system-monitor-symbolic", "CPU load", f"{la[0]:.2f}",
sub=f"{cpus} cores · 5m {la[1]:.2f} · 15m {la[2]:.2f}",
frac=la[0] / cpus if cpus else 0,
)[0])
# -- Updates (async) ----------------------------------------------
upd_card, upd_v, upd_s = _metric(
"software-update-available-symbolic", "Updates", "",
sub="checking", value_css="accent",
)
flow.append(upd_card)
# -- Packages (pacman sync, flatpak async) ------------------------
pk_total = packages.pacman_total()
pkg_card, pkg_v, pkg_s = _metric(
"package-x-generic-symbolic", "Packages", str(pk_total),
sub=f"{packages.pacman_foreign()} AUR · {packages.pacman_explicit()} explicit · flatpak …",
)
flow.append(pkg_card)
# -- Backups (Timeshift, unprivilegiert aus config) ---------------
bk_val, bk_sub = _timeshift_summary()
flow.append(_metric("drive-multidisk-symbolic", "Backups", bk_val, sub=bk_sub)[0])
# -- Network ------------------------------------------------------
flow.append(_metric(
"network-wireless-symbolic", "Network",
network.active_summary(), sub="NetworkManager",
)[0])
# -- Audio --------------------------------------------------------
ds = audio.get_default("sink")
if ds:
vol = audio.get_volume("sink", ds)
muted = audio.get_muted("sink", ds)
name = next((d.description for d in audio.list_devices("sink")
if d.name == ds), ds)
flow.append(_metric(
"audio-volume-high-symbolic", "Audio",
"muted" if muted else f"{vol}%", sub=name,
)[0])
# -- Graphics -----------------------------------------------------
gpus = sysinfo.gpus()
if gpus:
g0 = gpus[0]
sub = g0.model[:42] + (f" · {g0.driver}" if g0.driver else "")
flow.append(_metric(
"video-display-symbolic", "Graphics", g0.vendor.upper(), sub=sub,
)[0])
# -- Storage ------------------------------------------------------
disks = sysinfo.disks()
if disks:
total = sum(d.size_bytes for d in disks)
flow.append(_metric(
"drive-multidisk-symbolic", "Storage", _bytes(total),
sub=f"{len(disks)} drive(s)",
)[0])
# -- Health -------------------------------------------------------
failed = sysinfo.systemctl_failed()
je = sysinfo.journal_errors_boot()
if not failed:
h_sub = "no failed units" + (f" · {je} journal errors" if je > 0 else "")
else:
h_sub = ", ".join(failed)
flow.append(_metric(
"emblem-ok-symbolic" if not failed else "dialog-warning-symbolic",
"Health",
"Healthy" if not failed else f"{len(failed)} failed",
sub=h_sub,
value_css="success" if not failed else "error",
)[0])
# -- async: Updates + Flatpak nachladen ---------------------------
def worker() -> None:
fp = packages.flatpak_apps()
try:
from taninux.update.sources import pacman as upd_src
n = len(upd_src.fetch())
except Exception: # pragma: no cover
n = -1
def deliver() -> bool:
if n < 0:
upd_v.set_label("")
upd_s.set_visible(False)
elif n == 0:
upd_v.set_label("0")
upd_v.remove_css_class("accent")
upd_v.add_css_class("success")
upd_s.set_label("up to date")
else:
upd_v.set_label(str(n))
upd_s.set_label("updates available")
fp_txt = "no flatpak" if fp < 0 else f"{fp} flatpak"
pkg_s.set_label(
f"{packages.pacman_foreign()} AUR · "
f"{packages.pacman_explicit()} explicit · {fp_txt}"
)
return False
GLib.idle_add(deliver)
threading.Thread(target=worker, daemon=True).start()
page = Adw.PreferencesPage()
grp = Adw.PreferencesGroup()
grp.add(flow)
page.add(grp)
return page
@@ -0,0 +1,48 @@
"""Default apps — Standard-Anwendung pro Kategorie wählen (user-level)."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gtk # noqa: E402
from taninux.core import defaultapps
def _icon(name: str, fallback: str) -> str:
theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default())
return name if (name and theme and theme.has_icon(name)) else fallback
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
group = Adw.PreferencesGroup(title="Default applications")
page.add(group)
for label, mime, fallback in defaultapps.CATEGORIES:
current, apps = defaultapps.apps_for(mime)
row = Adw.ComboRow(title=label)
row.add_prefix(Gtk.Image.new_from_icon_name(fallback))
if not apps:
row.set_subtitle("No app installed")
row.set_sensitive(False)
group.add(row)
continue
names = [a.name for a in apps]
row.set_model(Gtk.StringList.new(names))
if current:
sel = next((i for i, a in enumerate(apps) if a.id == current.id), 0)
row.set_selected(sel)
def on_sel(r, _p, apps=apps, mime=mime) -> None:
i = r.get_selected()
if 0 <= i < len(apps):
defaultapps.set_default(apps[i].id, mime)
row.connect("notify::selected", on_sel)
group.add(row)
return page
@@ -0,0 +1,212 @@
"""Display — Monitor-/Output-Einstellungen, niri-only über native IPC.
Pro Output: An/Aus, Auflösung+Refresh, Scale, Rotation. Alles live über
core.display (`niri msg output`), unprivilegiert. Bewusst KEINE Persistenz in
die niri-Config (config.kdl).
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.core import compositor, display
from taninux.gui.console import RunnerCard
from taninux.gui.process import ProcessRunner
# gängige Scale-Stufen
_SCALES = [1.0, 1.25, 1.5, 1.75, 2.0]
_ROTATIONS = ["normal", "90", "180", "270"]
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
if compositor.detect() == compositor.UNKNOWN:
group = Adw.PreferencesGroup(title="Display")
group.add(Adw.ActionRow(
title="Display settings aren't available in this session",
))
page.add(group)
return page
outs = display.outputs()
if not outs:
group = Adw.PreferencesGroup(title="Display")
group.add(Adw.ActionRow(title="No monitors detected"))
page.add(group)
return page
# Änderungen sind sofort am Bildschirm sichtbar (nicht persistent) — kein Log.
runner = ProcessRunner()
def apply(**kwargs) -> None:
out = kwargs.pop("out")
runner.run(display.apply_argv(out, **kwargs), privileged=False)
for out in outs:
page.add(_output_group(out, apply))
page.add(_night_light_group())
return page
def _output_group(out: display.Output, apply) -> Adw.PreferencesGroup:
desc = f"{out.make} {out.model}".strip() or None
group = Adw.PreferencesGroup(title=out.name, description=desc)
# -- Enabled -----------------------------------------------------------
enable_row = Adw.SwitchRow(title="Enabled")
enable_row.set_active(out.enabled)
enable_row.connect(
"notify::active",
lambda r, _p: apply(out=out, enabled=r.get_active()),
)
group.add(enable_row)
# -- Resolution --------------------------------------------------------
# eindeutige (w,h,hz) absteigend sortiert; aktueller Modus vorwählen.
seen: set[tuple[int, int, float]] = set()
modes: list[tuple[int, int, float]] = []
for m in out.modes:
key = (m[0], m[1], round(m[2], 2))
if key not in seen:
seen.add(key)
modes.append(m)
cur_mode = (out.width, out.height, out.refresh_hz)
cur_key = (out.width, out.height, round(out.refresh_hz, 2))
if cur_key not in seen and out.width:
modes.insert(0, cur_mode)
modes.sort(key=lambda m: (m[0], m[1], m[2]), reverse=True)
if modes:
labels = [f"{w}x{h} @ {hz:.2f}Hz" for (w, h, hz) in modes]
res_row = Adw.ComboRow(title="Resolution")
res_row.set_model(Gtk.StringList.new(labels))
sel = next(
(i for i, m in enumerate(modes)
if (m[0], m[1], round(m[2], 2)) == cur_key),
0,
)
res_row.set_selected(sel)
# connect NACH set_selected, damit Initial nicht feuert
res_row.connect(
"notify::selected",
lambda r, _p: apply(out=out, mode=modes[r.get_selected()]),
)
group.add(res_row)
# -- Scale -------------------------------------------------------------
scales = list(_SCALES)
if not any(abs(s - out.scale) < 1e-3 for s in scales):
scales.append(out.scale)
scales.sort()
scale_row = Adw.ComboRow(title="Scale")
scale_row.set_model(Gtk.StringList.new([f"{s:g}" for s in scales]))
sidx = next(
(i for i, s in enumerate(scales) if abs(s - out.scale) < 1e-3), 0,
)
scale_row.set_selected(sidx)
scale_row.connect(
"notify::selected",
lambda r, _p: apply(out=out, scale=scales[r.get_selected()]),
)
group.add(scale_row)
# -- Rotation ----------------------------------------------------------
rot_row = Adw.ComboRow(title="Rotation")
rot_row.set_model(Gtk.StringList.new(_ROTATIONS))
ridx = _ROTATIONS.index(out.transform) if out.transform in _ROTATIONS else 0
rot_row.set_selected(ridx)
rot_row.connect(
"notify::selected",
lambda r, _p: apply(out=out, transform=_ROTATIONS[r.get_selected()]),
)
group.add(rot_row)
return group
def _night_light_group() -> Adw.PreferencesGroup:
group = Adw.PreferencesGroup(
title="Night light",
description="Warm the screen colours in the evening.",
)
if not display.wlsunset_installed():
group.add(Adw.ActionRow(title="Install to enable night light"))
group.add(RunnerCard(
"Install",
["pacman", "-S", "--needed", "--noconfirm", "wlsunset"],
privileged=True,
))
return group
nl = display.night_light()
enable_row = Adw.SwitchRow(title="Night light")
enable_row.set_active(nl.enabled)
group.add(enable_row)
night_row = Adw.SpinRow.new_with_range(2500, 6500, 100)
night_row.set_title("Night temperature")
night_row.set_subtitle("Kelvin")
night_row.set_value(nl.night_temp)
group.add(night_row)
day_row = Adw.SpinRow.new_with_range(3000, 6500, 100)
day_row.set_title("Day temperature")
day_row.set_subtitle("Kelvin")
day_row.set_value(nl.day_temp)
group.add(day_row)
lat_row = Adw.EntryRow(title="Latitude")
lat_row.set_text(f"{nl.lat:g}")
group.add(lat_row)
lon_row = Adw.EntryRow(title="Longitude")
lon_row.set_text(f"{nl.lon:g}")
group.add(lon_row)
status = Gtk.Label(xalign=0.0, wrap=True)
status.add_css_class("dim-label")
def _parse_float(text: str, fallback: float) -> float:
try:
return float(text.strip().replace(",", "."))
except (ValueError, AttributeError):
return fallback
def _on_apply(_btn: Gtk.Button) -> None:
lat = _parse_float(lat_row.get_text(), nl.lat)
lon = _parse_float(lon_row.get_text(), nl.lon)
path = display.write_night_light(
day_temp=int(day_row.get_value()),
night_temp=int(night_row.get_value()),
lat=lat,
lon=lon,
)
enabled = enable_row.get_active()
ok = display.apply_night_light(enabled)
state = "enabled" if enabled else "disabled"
if ok:
status.set_text(f"Night light {state}. Service unit: {path}")
else:
status.set_text(f"Wrote {path}, but systemctl --user failed.")
apply_btn = Gtk.Button(label="Apply")
apply_btn.add_css_class("suggested-action")
apply_btn.add_css_class("pill")
apply_btn.set_halign(Gtk.Align.START)
apply_btn.set_margin_top(6)
apply_btn.connect("clicked", _on_apply)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
box.set_margin_top(6)
box.append(apply_btn)
box.append(status)
group.add(box)
return group
@@ -0,0 +1,50 @@
"""Firewall — An/Aus (ufw/firewalld via Helper)."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.core import firewall
from taninux.gui.console import RunnerCard
from taninux.gui.process import ProcessRunner
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
group = Adw.PreferencesGroup(title="Firewall")
page.add(group)
if not firewall.installed():
group.add(Adw.ActionRow(title="Install to turn on the firewall"))
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box.set_margin_top(6)
box.append(RunnerCard("Install", firewall.install_argv(), privileged=True))
group.add(box)
return page
runner = ProcessRunner()
status = Gtk.Label(xalign=0.0)
status.add_css_class("dim-label")
row = Adw.SwitchRow(
title="Firewall",
subtitle="Block unsolicited incoming connections",
)
row.set_active(firewall.enabled())
def on_toggle(r, _p) -> None:
runner.run(firewall.set_enabled_argv(r.get_active()), privileged=True)
row.connect("notify::active", on_toggle)
runner.connect("done", lambda _r, code: status.set_label(
"✔ Updated" if code == 0 else f"✘ failed (exit {code})"))
group.add(row)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
box.set_margin_top(6)
box.append(status)
group.add(box)
return page
@@ -0,0 +1,130 @@
"""Home — App-Store-Startseite im GNOME-Software-Stil.
Kuratierte Endnutzer-Apps (core/catalog), nach Kategorie gruppiert, als
klickbare Icon-Tiles. Klick öffnet die Detailansicht (Info + Quellenwahl +
Install). Async geladen.
"""
from __future__ import annotations
import threading
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, GLib, Gtk # noqa: E402
from taninux.core import catalog, pacman
from taninux.gui.pages.appdetail import open_app_detail
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
groups: list[Adw.PreferencesGroup] = []
intro = Adw.PreferencesGroup(
title="Recommended apps",
description="Popular apps for everyday use — click for details.",
)
refresh = Gtk.Button(icon_name="view-refresh-symbolic")
refresh.add_css_class("flat")
refresh.set_valign(Gtk.Align.CENTER)
intro.set_header_suffix(refresh)
page.add(intro)
def _icon_name(app: catalog.App) -> str:
theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default())
if theme and theme.has_icon(app.icon):
return app.icon
return catalog.CATEGORY_ICON.get(app.category, "system-software-install-symbolic")
def _open(app: catalog.App) -> None:
open_app_detail(
page,
title=app.name,
icon=app.icon,
repo_name=app.pkg if app.source == "repo" else None,
flatpak_id=app.pkg if app.source == "flatpak" else None,
on_changed=_load,
)
def _tile(app: catalog.App) -> Gtk.Widget:
tile = Gtk.Button()
tile.add_css_class("card")
tile.connect("clicked", lambda _b, a=app: _open(a))
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14)
row.set_margin_top(14)
row.set_margin_bottom(14)
row.set_margin_start(14)
row.set_margin_end(14)
icon = Gtk.Image.new_from_icon_name(_icon_name(app))
icon.set_pixel_size(48)
row.append(icon)
text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, hexpand=True)
text.set_valign(Gtk.Align.CENTER)
name = Gtk.Label(label=app.name, xalign=0.0)
name.add_css_class("title-4")
summary = Gtk.Label(label=app.summary, xalign=0.0, wrap=True)
summary.add_css_class("dim-label")
summary.add_css_class("caption")
summary.set_lines(2)
summary.set_ellipsize(3)
text.append(name)
text.append(summary)
row.append(text)
tile.set_child(row)
return tile
def _category_group(category: str, apps: list[catalog.App]) -> Adw.PreferencesGroup:
group = Adw.PreferencesGroup(title=category)
flow = Gtk.FlowBox()
flow.set_selection_mode(Gtk.SelectionMode.NONE)
flow.set_max_children_per_line(2)
flow.set_min_children_per_line(1)
flow.set_column_spacing(12)
flow.set_row_spacing(12)
flow.set_homogeneous(True)
for app in apps:
flow.append(_tile(app))
group.add(flow)
return group
def _render(apps: list[catalog.App]) -> None:
for g in groups:
page.remove(g)
groups.clear()
if not apps:
g = Adw.PreferencesGroup()
g.add(Adw.StatusPage(
icon_name="emblem-ok-symbolic",
title="You're all set",
description="All recommended apps are already installed.",
))
page.add(g)
groups.append(g)
return
by_cat: dict[str, list[catalog.App]] = {}
for app in apps:
by_cat.setdefault(app.category, []).append(app)
for cat in catalog.CATEGORY_ORDER:
if cat in by_cat:
g = _category_group(cat, by_cat[cat])
page.add(g)
groups.append(g)
def _load() -> None:
def worker() -> None:
try:
pac = set(pacman.installed_packages())
fp = catalog._flatpak_installed_ids()
apps = catalog.recommended(pac, fp)
except Exception: # noqa: BLE001
apps = []
GLib.idle_add(lambda: (_render(apps), False)[1])
threading.Thread(target=worker, daemon=True).start()
refresh.connect("clicked", lambda _b: (pacman.invalidate(), _load()))
GLib.idle_add(lambda: (_load(), False)[1])
return page
@@ -0,0 +1,138 @@
"""Kernel — installierte Kernel, installierbare Alternativen, Bootloader."""
from __future__ import annotations
import gi
gi.require_version("Adw", "1")
from gi.repository import Adw, GLib, Gtk # noqa: E402
from taninux.core import bootloader, pacman, sysinfo
from taninux.gui.console import LogConsole, log_expander
from taninux.gui.process import ProcessRunner
# Paketname → Anzeigename
KNOWN_KERNELS: dict[str, str] = {
"linux": "Linux (stable)",
"linux-lts": "Linux LTS (longterm)",
"linux-zen": "Linux Zen (desktop-tuned)",
"linux-hardened": "Linux Hardened (security)",
"linux-rt": "Linux RT (realtime)",
"linux-rt-lts": "Linux RT LTS (realtime longterm)",
}
def _is_running(pkg: str, running: str) -> bool:
suffix = pkg.removeprefix("linux").lstrip("-")
tag = "-".join(running.split("-")[1:]) if "-" in running else ""
return (pkg == "linux" and not tag) or (bool(suffix) and suffix in running)
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
console = LogConsole()
runner = ProcessRunner()
runner.connect("line", lambda _r, line: console.append_line(line))
groups: list[Adw.PreferencesGroup] = []
log_group = log_expander(console)
def _install(pkg: str, btn: Gtk.Button) -> None:
btn.set_sensitive(False)
btn.set_label("Installing…")
console.clear()
runner.run(["pacman", "-S", "--needed", "--noconfirm", pkg, f"{pkg}-headers"],
privileged=True)
def _set_default(bl, combo: Adw.ComboRow, btn: Gtk.Button) -> None:
idx = combo.get_selected()
if not (0 <= idx < len(bl.entries)):
return
argv = bootloader.set_default_argv(bl, bl.entries[idx])
if not argv:
console.append_line("⚠ not supported for this bootloader")
return
btn.set_sensitive(False)
console.clear()
runner.run(argv, privileged=True)
def _render() -> None:
for g in groups:
page.remove(g)
groups.clear()
page.remove(log_group)
installed = pacman.installed_packages()
running = sysinfo.kernel_release()
g_inst = Adw.PreferencesGroup(title="Installed kernels",
description=f"Running: {running}")
any_inst = False
for pkg, label in KNOWN_KERNELS.items():
if pkg in installed:
any_inst = True
row = Adw.ActionRow(title=label, subtitle=pkg)
if _is_running(pkg, running):
badge = Gtk.Label(label="active")
badge.add_css_class("success")
badge.add_css_class("caption-heading")
badge.set_valign(Gtk.Align.CENTER)
row.add_suffix(badge)
g_inst.add(row)
if not any_inst:
g_inst.add(Adw.ActionRow(title="No known kernel packages found"))
page.add(g_inst)
groups.append(g_inst)
available = [(p, l) for p, l in KNOWN_KERNELS.items() if p not in installed]
if available:
g_avail = Adw.PreferencesGroup(title="Available to install")
for pkg, label in available:
row = Adw.ActionRow(title=label, subtitle=f"{pkg} · {pkg}-headers")
btn = Gtk.Button(label="Install")
btn.add_css_class("suggested-action")
btn.set_valign(Gtk.Align.CENTER)
btn.connect("clicked", lambda _b, p=pkg: _install(p, _b))
row.add_suffix(btn)
g_avail.add(row)
page.add(g_avail)
groups.append(g_avail)
bl = bootloader.detect()
g_boot = Adw.PreferencesGroup(title="Bootloader")
g_boot.add(_static_row("Type", bl.kind))
g_boot.add(_static_row("Default entry", bl.default_entry or ""))
if bl.kind == "systemd-boot" and bl.entries:
combo = Adw.ComboRow(title="Default boot entry",
model=Gtk.StringList.new([e.name for e in bl.entries]))
for i, e in enumerate(bl.entries):
if bootloader.is_default(bl, e):
combo.set_selected(i)
break
btn = Gtk.Button(label="Set")
btn.add_css_class("suggested-action")
btn.set_valign(Gtk.Align.CENTER)
btn.connect("clicked", lambda _b, c=combo: _set_default(bl, c, _b))
combo.add_suffix(btn)
g_boot.add(combo)
page.add(g_boot)
groups.append(g_boot)
page.add(log_group)
def on_done(_r: ProcessRunner, code: int) -> None:
console.append_line("✔ done" if code == 0 else f"✘ exit {code}")
pacman.invalidate()
_render()
runner.connect("done", on_done)
page.add(log_group)
GLib.idle_add(lambda: (_render(), False)[1])
return page
def _static_row(title: str, value: str) -> Adw.ActionRow:
row = Adw.ActionRow(title=title)
label = Gtk.Label(label=value)
label.add_css_class("dim-label")
label.set_selectable(True)
row.add_suffix(label)
return row
@@ -0,0 +1,247 @@
"""Region & Input — System-Locale (Sprache) + Tastatur-Layout via localectl.
Lese-Werte kommen aus core.localization (unprivilegiert). Das Setzen läuft
privilegiert über den Helper (localectl steht in dessen Allowlist).
Ehrlicher Hinweis: niri nimmt sein aktives Tastaturlayout aus der EIGENEN
Config (config.kdl, input/keyboard). `localectl set-x11-keymap` setzt das
System-Default (Konsole, X11-Apps, XKB) — nicht zwingend das Live-Layout in
niri. Die Konsolen-Keymap (`set-keymap`) und das Locale gelten dagegen voll
system-weit.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.core import localization
from taninux.gui import confirm
from taninux.gui.process import ProcessRunner
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
page.add(_language_group())
page.add(_datetime_group())
page.add(_keyboard_group())
return page
# -- gemeinsamer Button+Konsole-Block (argv erst zur Klickzeit bekannt) -----
def _action_block(button_label: str):
"""Liefert (box, button, runner, status). Caller verdrahtet button 'clicked'."""
runner = ProcessRunner()
button = Gtk.Button(label=button_label)
button.add_css_class("suggested-action")
button.add_css_class("pill")
button.set_halign(Gtk.Align.START)
status = Gtk.Label(xalign=0.0, wrap=True)
status.add_css_class("dim-label")
def on_done(_r: ProcessRunner, code: int) -> None:
status.set_label("✔ Applied" if code == 0 else f"✘ failed (exit {code})")
button.set_sensitive(True)
runner.connect("done", on_done)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
box.set_margin_top(6)
box.append(button)
box.append(status)
return box, button, runner, status
# -- Sprache --------------------------------------------------------------
def _language_group() -> Adw.PreferencesGroup:
st = localization.status()
locales = localization.list_locales()
group = Adw.PreferencesGroup(
title="Language",
description="Applies on next login.",
)
combo = Adw.ComboRow(title="System language")
combo.set_model(Gtk.StringList.new(locales or ["(none generated)"]))
combo.set_enable_search(True)
if st.locale in locales:
combo.set_selected(locales.index(st.locale))
group.add(combo)
box, button, runner, _status = _action_block("Apply language")
def on_click(_b: Gtk.Button) -> None:
idx = combo.get_selected()
if not locales or idx < 0 or idx >= len(locales):
return
locale = locales[idx]
def do_run() -> None:
button.set_sensitive(False)
runner.run(localization.set_locale_argv(locale), privileged=True)
confirm.confirm(
button,
"Change system language?",
f"Sets LANG={locale}. Applies on next login.",
do_run,
)
button.connect("clicked", on_click)
group.add(box)
return group
# -- Datum & Zeit ---------------------------------------------------------
def _datetime_group() -> Adw.PreferencesGroup:
timezones = localization.list_timezones()
cur_tz = localization.current_timezone()
group = Adw.PreferencesGroup(title="Date and Time")
# Aktuelle lokale Zeit (read-only).
cur = Adw.ActionRow(title="Current")
cur_label = Gtk.Label(label=localization.local_time() or "")
cur_label.add_css_class("dim-label")
cur_label.set_selectable(True)
cur.add_suffix(cur_label)
group.add(cur)
# Zeitzone wählen + privilegiert anwenden.
tz_combo = Adw.ComboRow(title="Time zone")
tz_combo.set_model(Gtk.StringList.new(timezones or ["(none)"]))
tz_combo.set_enable_search(True)
if cur_tz in timezones:
tz_combo.set_selected(timezones.index(cur_tz))
group.add(tz_combo)
box, button, runner, status = _action_block("Apply time zone")
def on_click(_b: Gtk.Button) -> None:
idx = tz_combo.get_selected()
if not timezones or idx < 0 or idx >= len(timezones):
return
tz = timezones[idx]
def do_run() -> None:
button.set_sensitive(False)
runner.run(localization.set_timezone_argv(tz), privileged=True)
confirm.confirm(
button,
"Change system time zone?",
f"Sets the system time zone to {tz}.",
do_run,
)
button.connect("clicked", on_click)
# Automatische Zeit (NTP) — meldet in dieselbe Status-Zeile.
ntp_runner = ProcessRunner()
ntp_runner.connect(
"done",
lambda _r, code: status.set_label("✔ Applied" if code == 0 else f"✘ exit {code}"),
)
ntp_row = Adw.SwitchRow(
title="Automatic time (NTP)",
subtitle="Synchronize the clock over the network.",
)
ntp_row.set_active(localization.ntp_enabled())
def on_ntp(_row: Adw.SwitchRow, _pspec) -> None:
ntp_runner.run(localization.set_ntp_argv(ntp_row.get_active()), privileged=True)
ntp_row.connect("notify::active", on_ntp)
group.add(ntp_row)
group.add(box)
return group
# -- Tastatur -------------------------------------------------------------
def _keyboard_group() -> Adw.PreferencesGroup:
st = localization.status()
layouts = localization.list_x11_layouts()
group = Adw.PreferencesGroup(title="Keyboard")
# Ist-Zustand sichtbar machen (gerade weil niri davon abweichen kann).
cur = Adw.ActionRow(title="Current (system)")
cur_val = f"{st.x11_layout or ''}"
if st.x11_variant:
cur_val += f" · {st.x11_variant}"
cur_val += f" · console: {st.vc_keymap or ''}"
cur_label = Gtk.Label(label=cur_val)
cur_label.add_css_class("dim-label")
cur_label.set_selectable(True)
cur.add_suffix(cur_label)
group.add(cur)
layout_combo = Adw.ComboRow(title="Layout")
layout_combo.set_model(Gtk.StringList.new(layouts or ["(none)"]))
layout_combo.set_enable_search(True)
if st.x11_layout in layouts:
layout_combo.set_selected(layouts.index(st.x11_layout))
group.add(layout_combo)
variant_combo = Adw.ComboRow(title="Variant")
group.add(variant_combo)
def _selected_layout() -> str:
idx = layout_combo.get_selected()
return layouts[idx] if layouts and 0 <= idx < len(layouts) else ""
variants_state: dict[str, list[str]] = {"list": [""]}
def _reload_variants(*_a) -> None:
layout = _selected_layout()
variants = localization.list_x11_variants(layout) if layout else [""]
variants_state["list"] = variants
# "" als "default" anzeigen
labels = ["default" if v == "" else v for v in variants]
variant_combo.set_model(Gtk.StringList.new(labels))
# aktuelle Variante vorwählen, falls sie zum Layout passt
if st.x11_variant in variants:
variant_combo.set_selected(variants.index(st.x11_variant))
else:
variant_combo.set_selected(0)
layout_combo.connect("notify::selected", _reload_variants)
_reload_variants()
box, button, runner, _status = _action_block("Apply layout")
def on_click(_b: Gtk.Button) -> None:
layout = _selected_layout()
if not layout:
return
vidx = variant_combo.get_selected()
variants = variants_state["list"]
variant = variants[vidx] if 0 <= vidx < len(variants) else ""
def do_run() -> None:
button.set_sensitive(False)
runner.run(
localization.set_x11_keymap_argv(layout, variant), privileged=True
)
confirm.confirm(
button,
"Change keyboard layout?",
f"Sets the system XKB layout to {layout}.",
do_run,
)
button.connect("clicked", on_click)
group.add(box)
return group
@@ -0,0 +1,39 @@
"""Screen lock — jetzt sperren + Locker installieren."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.core import lock
from taninux.gui.console import RunnerCard
from taninux.gui.process import ProcessRunner
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
group = Adw.PreferencesGroup(title="Screen lock")
page.add(group)
if not lock.locker():
group.add(Adw.ActionRow(title="Install to enable screen locking"))
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box.set_margin_top(6)
box.append(RunnerCard("Install", lock.install_argv(), privileged=True))
group.add(box)
return page
runner = ProcessRunner()
row = Adw.ActionRow(
title="Lock now",
subtitle="Auto-lock timing is set under Power.",
)
btn = Gtk.Button(label="Lock")
btn.add_css_class("pill")
btn.set_valign(Gtk.Align.CENTER)
btn.connect("clicked", lambda _b: runner.run(lock.lock_now_argv() or []))
row.add_suffix(btn)
group.add(row)
return page
@@ -0,0 +1,394 @@
"""Cleanup — System aufräumen über die Maintain-Scanner.
Portiert die UI-freien Scanner (taninux.maintain) in eine libadwaita-Seite.
Scannen läuft async im Worker-Thread; Findings werden per GLib.idle_add
gerendert. Destruktive Aktionen gehen ausschliesslich über core.trash
(move-to-trash, kein echtes rm) — das Sicherheitsnetz der App.
trash-Scanner (dotfolders, fp-data, aur-cache): User-Dateien → Trash/Keep.
view-Scanner (orphans, cache, …): nur anzeigen + Hinweis-Befehl zum Kopieren.
"""
from __future__ import annotations
import threading
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, GLib, Gtk # noqa: E402
from taninux.core import trash
from taninux.core.state import State
from taninux.gui import confirm
from taninux.gui.console import LogConsole, log_expander
from taninux.gui.process import ProcessRunner
from taninux.maintain.scanners import SCANNERS, ScannerSpec
from taninux.maintain.types import Classification, Finding
# Klassifizierung → CSS-Klasse für den farbigen Punkt + englisches Label.
_DOT_CLASS = {
Classification.SAFE: "success",
Classification.LIKELY_ORPHAN: "warning",
Classification.UNKNOWN: "dim-label",
Classification.ACTIVE: "dim-label",
Classification.WHITELISTED: "accent",
}
_EN_LABEL = {
Classification.SAFE: "safe",
Classification.LIKELY_ORPHAN: "likely orphan",
Classification.UNKNOWN: "unknown",
Classification.WHITELISTED: "kept",
Classification.ACTIVE: "in use",
}
_RECLAIMABLE = (Classification.SAFE, Classification.LIKELY_ORPHAN)
_MAX_ROWS = 50 # pro Scanner, gegen UI-Overload
def _clean_argv(spec_id: str, findings: list[Finding]):
"""Sicheres One-Click-Clean für view-Scanner → (argv, privileged) | None.
Nur ungefährliche, nicht-interaktive Befehle. pacnew (interaktiver Merge)
und kernels (rm, gefährlich) bleiben bewusst Hinweis-only.
"""
if spec_id == "cache":
return ["paccache", "-ruk0"], True
if spec_id == "journal":
return ["journalctl", "--vacuum-time=2weeks"], True
if spec_id == "flatpak-runtimes":
return ["flatpak", "uninstall", "--unused", "--assumeyes"], False
if spec_id == "orphans":
pkgs = [f.matched_package or f.path.name for f in findings]
pkgs = [p for p in pkgs if p]
return (["pacman", "-Rns", "--noconfirm", *pkgs], True) if pkgs else None
return None
def _human(n: int) -> str:
"""Bytes menschlich (B/KiB/MiB/GiB)."""
size = float(n)
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if size < 1024 or unit == "TiB":
if unit == "B":
return f"{int(size)} {unit}"
return f"{size:.1f} {unit}"
size /= 1024
return f"{n} B" # unerreichbar
def _dot(cls: Classification) -> Gtk.Label:
dot = Gtk.Label(label="")
dot.add_css_class(_DOT_CLASS.get(cls, "dim-label"))
dot.set_valign(Gtk.Align.CENTER)
return dot
def _legend() -> Adw.ActionRow:
"""Erklärt die farbigen Punkte."""
row = Adw.ActionRow(title="Legend")
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14)
box.set_valign(Gtk.Align.CENTER)
for cls in (Classification.SAFE, Classification.LIKELY_ORPHAN,
Classification.UNKNOWN):
item = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
item.append(_dot(cls))
lbl = Gtk.Label(label=_EN_LABEL[cls])
lbl.add_css_class("dim-label")
lbl.add_css_class("caption")
item.append(lbl)
box.append(item)
row.add_suffix(box)
return row
def _flat_btn(label: str, cb) -> Gtk.Button:
btn = Gtk.Button(label=label)
btn.add_css_class("flat")
btn.set_valign(Gtk.Align.CENTER)
btn.connect("clicked", lambda _b: cb())
return btn
def _fill_advice(advice: str, first: Finding | None) -> str:
"""Platzhalter {pkg}/{path} mit dem ersten Finding füllen."""
if first is None:
return advice
pkg = first.matched_package or first.path.name
return advice.replace("{pkg}", pkg).replace("{path}", str(first.path))
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
# -- Summary (oben) -----------------------------------------------
g_summary = Adw.PreferencesGroup(title="Cleanup")
rescan_btn = Gtk.Button(icon_name="view-refresh-symbolic")
rescan_btn.add_css_class("flat")
rescan_btn.set_valign(Gtk.Align.CENTER)
rescan_btn.set_tooltip_text("Rescan")
g_summary.set_header_suffix(rescan_btn)
summary_row = Adw.ActionRow(title="Reclaimable", subtitle="Scanning …")
summary_spin = Adw.Spinner()
summary_spin.set_valign(Gtk.Align.CENTER)
summary_row.add_prefix(summary_spin)
g_summary.add(summary_row)
g_summary.add(_legend())
page.add(g_summary)
console = LogConsole()
runner = ProcessRunner()
runner.connect("line", lambda _r, ln: console.append_line(ln))
# Dynamische Scanner-Gruppen (zwischen Summary und Trash) ---------
scanner_groups: list[Gtk.Widget] = []
# -- Trash-Gruppe (unten) -----------------------------------------
g_trash = Adw.PreferencesGroup(
title="Trash",
description="Files moved here are recoverable, nothing is deleted.",
)
trash_rows: list[Gtk.Widget] = []
page.add(g_trash)
# -- Log-Gruppe (ganz unten, für Clean-Befehle) -------------------
g_log = log_expander(console)
page.add(g_log)
def _clean_run(argv: list[str], priv: bool, label: str) -> None:
def go() -> None:
console.clear()
runner.run(argv, privileged=priv)
confirm.confirm(rescan_btn, f"Clean {label}",
"Run: " + " ".join(argv), go)
# ----------------------------------------------------------------
def _clipboard_set(text: str) -> None:
disp = Gdk.Display.get_default()
if disp is not None:
disp.get_clipboard().set(text)
def _clear_scanner_groups() -> None:
for grp in scanner_groups:
page.remove(grp)
scanner_groups.clear()
# -- Trash-Liste --------------------------------------------------
def _refresh_trash() -> None:
for r in trash_rows:
g_trash.remove(r)
trash_rows.clear()
entries = trash.list_trash()
if not entries:
empty = Adw.ActionRow(
title="Trash is empty",
subtitle="Trashed items will appear here.",
)
g_trash.add(empty)
trash_rows.append(empty)
return
for entry in entries:
row = Adw.ActionRow(
title=str(entry.original),
subtitle=f"{_human(entry.size_bytes)} · {entry.trashed_at}",
)
def _restore(eid: str = entry.id) -> None:
try:
trash.restore(eid)
except (FileExistsError, FileNotFoundError, OSError):
pass
_refresh_trash()
def _purge(eid: str = entry.id, orig: str = str(entry.original)) -> None:
def do() -> None:
try:
trash.purge(eid)
except (FileNotFoundError, OSError):
pass
_refresh_trash()
confirm.confirm(
rescan_btn,
"Purge permanently",
f"Delete {orig} for good. This cannot be undone.",
do,
)
row.add_suffix(_flat_btn("Restore", _restore))
purge_b = _flat_btn("Purge", _purge)
purge_b.add_css_class("destructive-action")
row.add_suffix(purge_b)
g_trash.add(row)
trash_rows.append(row)
# -- eine Scanner-Gruppe rendern ----------------------------------
def _build_group(spec: ScannerSpec, findings: list[Finding]) -> Gtk.Widget:
findings = sorted(findings, key=lambda f: f.sort_key)
reclaim = sum(f.size_bytes for f in findings
if f.classification in _RECLAIMABLE)
shown = findings[:_MAX_ROWS]
hidden = len(findings) - len(shown)
desc = f"{len(findings)} findings · {_human(reclaim)} reclaimable"
if hidden > 0:
desc += f" · showing first {_MAX_ROWS}"
if spec.actions == "view" and spec.advice:
desc += "\n" + _fill_advice(spec.advice, findings[0] if findings else None)
grp = Adw.PreferencesGroup(title=spec.label, description=desc)
# view-Scanner: Header mit Copy-Button + (falls sicher) Clean-Button.
if spec.actions == "view":
hdr = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
if spec.advice:
cmd = _fill_advice(spec.advice, findings[0] if findings else None)
copy = Gtk.Button(icon_name="edit-copy-symbolic")
copy.add_css_class("flat")
copy.set_valign(Gtk.Align.CENTER)
copy.set_tooltip_text("Copy command")
copy.connect("clicked", lambda _b, c=cmd: _clipboard_set(c))
hdr.append(copy)
clean = _clean_argv(spec.id, findings)
if clean is not None:
argv, priv = clean
cb = Gtk.Button(label="Clean")
cb.add_css_class("destructive-action")
cb.set_valign(Gtk.Align.CENTER)
cb.connect("clicked",
lambda _b, a=argv, p=priv, lb=spec.label: _clean_run(a, p, lb))
hdr.append(cb)
if hdr.get_first_child() is not None:
grp.set_header_suffix(hdr)
# Findings in eine scrollbare ListBox: ~5 Zeilen sichtbar, dann scrollen.
listbox = Gtk.ListBox()
listbox.add_css_class("boxed-list")
listbox.set_selection_mode(Gtk.SelectionMode.NONE)
for finding in shown:
cls = finding.classification
title = finding.matched_package or str(finding.path)
sub = f"{_EN_LABEL.get(cls, cls.value)} · {_human(finding.size_bytes)}"
if finding.reason:
sub += f" · {finding.reason}"
row = Adw.ActionRow(title=title, subtitle=sub)
row.add_prefix(_dot(cls))
if spec.actions == "trash":
def _do_trash(f: Finding = finding, r: Adw.ActionRow = row,
lb: Gtk.ListBox = listbox) -> None:
def confirmed() -> None:
def worker() -> None:
ok = True
try:
trash.move_to_trash(f.path)
except (FileNotFoundError, OSError):
ok = False
GLib.idle_add(lambda: (_after_trash(r, lb, ok), False)[1])
threading.Thread(target=worker, daemon=True).start()
confirm.confirm(
rescan_btn,
"Move to Trash",
f"Move {f.path} to the recoverable Trash.",
confirmed,
)
def _do_keep(f: Finding = finding, r: Adw.ActionRow = row,
lb: Gtk.ListBox = listbox) -> None:
State.load().whitelist_add(f.path)
lb.remove(r)
row.add_suffix(_flat_btn("Keep", _do_keep))
trash_b = _flat_btn("Trash", _do_trash)
trash_b.add_css_class("destructive-action")
row.add_suffix(trash_b)
listbox.append(row)
if hidden > 0:
more = Adw.ActionRow(title=f"… +{hidden} more")
more.add_css_class("dim-label")
listbox.append(more)
visible = min(len(shown) + (1 if hidden else 0), 5)
h = visible * 56
scroller = Gtk.ScrolledWindow()
scroller.set_min_content_height(h)
scroller.set_max_content_height(h)
scroller.set_child(listbox)
grp.add(scroller)
return grp
def _after_trash(row: Adw.ActionRow, listbox: Gtk.ListBox, ok: bool) -> None:
if ok:
listbox.remove(row)
_refresh_trash()
# -- Scan async ---------------------------------------------------
def _render(results: list[tuple[ScannerSpec, list[Finding]]]) -> None:
_clear_scanner_groups()
summary_spin.set_visible(False)
# Trash + Log temporär entfernen, damit Scanner-Gruppen davor landen.
page.remove(g_trash)
page.remove(g_log)
total = 0
for spec, findings in results:
if not findings:
continue
total += sum(f.size_bytes for f in findings
if f.classification in _RECLAIMABLE)
grp = _build_group(spec, findings)
page.add(grp)
scanner_groups.append(grp)
page.add(g_trash)
page.add(g_log)
summary_row.set_subtitle(f"{_human(total)} can be reclaimed safely")
def _scan() -> None:
_clear_scanner_groups()
summary_spin.set_visible(True)
summary_row.set_subtitle("Scanning …")
def worker() -> None:
# State einmal laden, um whitelisted/ignored Findings auszublenden.
try:
state = State.load()
except Exception: # noqa: BLE001 — UI darf nie crashen
state = None
results: list[tuple[ScannerSpec, list[Finding]]] = []
for spec in SCANNERS:
try:
findings = spec.factory().scan()
except Exception: # noqa: BLE001 — ein Scanner darf nicht alles killen
findings = []
if state is not None:
try:
findings = [
f for f in findings
if not state.is_whitelisted(f.path)
and not state.is_ignored(f.path)
]
except OSError:
pass
results.append((spec, findings))
GLib.idle_add(lambda: (_render(results), False)[1])
threading.Thread(target=worker, daemon=True).start()
rescan_btn.connect("clicked", lambda _b: _scan())
def _on_clean_done(_r: ProcessRunner, code: int) -> None:
console.append_line("✔ done" if code == 0 else f"✘ exit {code}")
_refresh_trash()
_scan()
runner.connect("done", _on_clean_done)
_refresh_trash()
GLib.idle_add(lambda: (_scan(), False)[1])
return page
@@ -0,0 +1,194 @@
"""Network — Wi-Fi über core.network (nmcli).
Radio an/aus, Status, verfügbare Netze (async geladen) mit Verbinden inkl.
Passwort-Dialog für gesicherte Netze. Alles User-Level (NetworkManager-polkit),
kein root. Live-Output im Connection-Log.
"""
from __future__ import annotations
import threading
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, GLib, Gtk # noqa: E402
from taninux.core import network
from taninux.gui import confirm
from taninux.gui.console import LogConsole, log_expander
from taninux.gui.process import ProcessRunner
def _signal_icon(sig: int) -> str:
if sig >= 75:
return "network-wireless-signal-excellent-symbolic"
if sig >= 50:
return "network-wireless-signal-good-symbolic"
if sig >= 25:
return "network-wireless-signal-ok-symbolic"
return "network-wireless-signal-weak-symbolic"
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
if not network.available():
g = Adw.PreferencesGroup(title="Network")
g.add(Adw.ActionRow(
title="NetworkManager not available",
subtitle="nmcli found no running NetworkManager.",
))
page.add(g)
return page
console = LogConsole()
runner = ProcessRunner()
runner.connect("line", lambda _r, ln: console.append_line(ln))
# -- Wi-Fi radio + Status -----------------------------------------
g_wifi = Adw.PreferencesGroup(title="Wi-Fi")
wifi_sw = Adw.SwitchRow(title="Wi-Fi", subtitle="Enable the wireless radio")
wifi_sw.set_active(network.wifi_enabled())
status_row = Adw.ActionRow(title="Status")
status_lbl = Gtk.Label(label=network.active_summary())
status_lbl.add_css_class("dim-label")
status_lbl.set_selectable(True)
status_row.add_suffix(status_lbl)
def on_wifi(sw: Adw.SwitchRow, _p) -> None:
runner.run(network.set_wifi_argv(sw.get_active()))
wifi_sw.connect("notify::active", on_wifi)
g_wifi.add(wifi_sw)
g_wifi.add(status_row)
page.add(g_wifi)
# -- Verfügbare Netze (async) -------------------------------------
g_nets = Adw.PreferencesGroup(title="Available networks")
refresh = Gtk.Button(icon_name="view-refresh-symbolic")
refresh.add_css_class("flat")
refresh.set_valign(Gtk.Align.CENTER)
refresh.set_tooltip_text("Rescan")
g_nets.set_header_suffix(refresh)
page.add(g_nets)
rows: list[Gtk.Widget] = []
def _clear() -> None:
for r in rows:
g_nets.remove(r)
rows.clear()
def _connect(net: network.WifiNet, saved: bool) -> None:
def do(pw: str) -> None:
console.clear()
runner.run(network.connect_argv(net.ssid, pw))
# Gesichert, aber schon gespeichert -> ohne Passwort-Dialog verbinden.
if net.security and not saved:
_ask_password(refresh, net.ssid, do)
else:
do("")
def _disconnect() -> None:
argv = network.disconnect_argv()
if not argv:
return
console.clear()
runner.run(argv)
def _forget(net: network.WifiNet) -> None:
def do() -> None:
console.clear()
runner.run(network.forget_argv(net.ssid))
confirm.confirm(
refresh,
f"Forget {net.ssid}",
"Delete this saved Wi-Fi connection.",
do,
)
def _btn(label: str, cb) -> Gtk.Button:
btn = Gtk.Button(label=label)
btn.add_css_class("flat")
btn.set_valign(Gtk.Align.CENTER)
btn.connect("clicked", lambda _b: cb())
return btn
def _render(nets: list[network.WifiNet], saved: set[str]) -> None:
_clear()
for net in nets:
row = Adw.ActionRow(title=net.ssid,
subtitle=net.security or "open")
row.add_prefix(Gtk.Image.new_from_icon_name(_signal_icon(net.signal)))
if net.security:
lock = Gtk.Image.new_from_icon_name("network-wireless-encrypted-symbolic")
lock.add_css_class("dim-label")
row.add_suffix(lock)
is_saved = net.ssid in saved
if net.in_use:
tag = Gtk.Label(label="connected")
tag.add_css_class("success")
tag.add_css_class("caption-heading")
row.add_suffix(tag)
row.add_suffix(_btn("Disconnect", _disconnect))
else:
row.add_suffix(_btn("Connect",
lambda n=net, s=is_saved: _connect(n, s)))
if is_saved:
row.add_suffix(_btn("Forget", lambda n=net: _forget(n)))
g_nets.add(row)
rows.append(row)
def _load(rescan: bool = False) -> None:
_clear()
spin_row = Adw.ActionRow(title="Scanning …" if rescan else "Loading …")
sp = Adw.Spinner()
sp.set_valign(Gtk.Align.CENTER)
spin_row.add_prefix(sp)
g_nets.add(spin_row)
rows.append(spin_row)
status_lbl.set_label(network.active_summary())
def worker() -> None:
nets = network.wifi_networks(rescan=rescan)
saved = network.saved_connections()
GLib.idle_add(lambda: (_render(nets, saved), False)[1])
threading.Thread(target=worker, daemon=True).start()
refresh.connect("clicked", lambda _b: _load(rescan=True))
GLib.idle_add(lambda: (_load(rescan=False), False)[1])
# -- Connection log -----------------------------------------------
def on_done(_r: ProcessRunner, code: int) -> None:
status_lbl.set_label(network.active_summary())
console.append_line("✔ done" if code == 0 else f"✘ exit {code}")
# Liste + Status nach jeder Aktion neu laden.
_load(rescan=False)
runner.connect("done", on_done)
page.add(log_expander(console, title="Connection log"))
return page
def _ask_password(parent: Gtk.Widget, ssid: str, on_ok) -> None:
dialog = Adw.AlertDialog(
heading=f"Connect to {ssid}",
body="Enter the Wi-Fi password.",
)
entry = Gtk.PasswordEntry(show_peek_icon=True)
entry.set_margin_top(8)
dialog.set_extra_child(entry)
dialog.add_response("cancel", "Cancel")
dialog.add_response("connect", "Connect")
dialog.set_response_appearance("connect", Adw.ResponseAppearance.SUGGESTED)
dialog.set_default_response("connect")
dialog.set_close_response("cancel")
def on_resp(_d: Adw.AlertDialog, resp: str) -> None:
if resp == "connect":
on_ok(entry.get_text())
dialog.connect("response", on_resp)
dialog.present(parent)
@@ -0,0 +1,199 @@
"""Panel & Dock — steuert die eww-Bar + das Dock über panel.json + dock-pins.
Schreibt ausschließlich Config-Dateien (core.panel); eww zieht live nach. Fasst
~/eww NIE direkt an. Vertrag: docs/eww-panel-dock-integration.md.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.core import panel
_MODULE_LABELS = {
"music": "Now playing",
"sys": "System (CPU / RAM)",
"updates": "Updates",
"net": "Network",
"bt": "Bluetooth",
"vol": "Volume",
}
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
cfg = panel.load()
state = {"ready": False}
def persist() -> None:
if state["ready"]:
panel.save(cfg)
page.add(_topbar_group(cfg, persist))
page.add(_dock_group(cfg, persist))
page.add(_pins_group())
state["ready"] = True
return page
# -- Top bar --------------------------------------------------------------
def _topbar_group(cfg: dict, persist) -> Adw.PreferencesGroup:
group = Adw.PreferencesGroup(
title="Top bar",
description="Which modules the eww bar shows, and the clock format.",
)
clock = Adw.EntryRow(title="Clock format")
clock.set_text(cfg["bar"]["clock_format"])
clock.set_show_apply_button(True)
def on_clock(_e: Adw.EntryRow) -> None:
cfg["bar"]["clock_format"] = clock.get_text()
persist()
clock.connect("apply", on_clock)
group.add(clock)
mods = cfg["bar"]["modules"]
for key in panel.MODULES:
row = Adw.SwitchRow(title=_MODULE_LABELS.get(key, key))
row.set_active(bool(mods.get(key, True)))
def on_toggle(r: Adw.SwitchRow, _p, k: str = key) -> None:
cfg["bar"]["modules"][k] = r.get_active()
persist()
row.connect("notify::active", on_toggle)
group.add(row)
return group
# -- Dock -----------------------------------------------------------------
def _dock_group(cfg: dict, persist) -> Adw.PreferencesGroup:
group = Adw.PreferencesGroup(
title="Dock",
description="Auto-hide, icon size and hide delay. Applies live.",
)
dock = cfg["dock"]
autohide = Adw.SwitchRow(
title="Auto-hide",
subtitle="Hide the dock until you reach the screen edge",
)
autohide.set_active(bool(dock.get("autohide", True)))
def on_autohide(r: Adw.SwitchRow, _p) -> None:
dock["autohide"] = r.get_active()
persist()
autohide.connect("notify::active", on_autohide)
group.add(autohide)
size = Adw.SpinRow.new_with_range(24, 64, 1)
size.set_title("Icon size")
size.set_subtitle("Pixels (2464)")
size.set_value(int(dock.get("icon_size", 42)))
def on_size(r: Adw.SpinRow, _p) -> None:
dock["icon_size"] = int(r.get_value())
persist()
size.connect("notify::value", on_size)
group.add(size)
delay_adj = Gtk.Adjustment(value=float(dock.get("hide_delay", 1.2)),
lower=0.2, upper=5.0,
step_increment=0.1, page_increment=0.5)
delay = Adw.SpinRow(title="Hide delay", adjustment=delay_adj, digits=1)
delay.set_subtitle("Seconds before the dock hides (0.25.0)")
def on_delay(r: Adw.SpinRow, _p) -> None:
dock["hide_delay"] = round(r.get_value(), 1)
persist()
delay.connect("notify::value", on_delay)
group.add(delay)
return group
# -- Pinned apps (dock-pins) ----------------------------------------------
def _pins_group() -> Adw.PreferencesGroup:
group = Adw.PreferencesGroup(
title="Pinned apps",
description="Apps pinned to the dock (exec · class · icon).",
)
pins = panel.load_pins()
rows: list[Gtk.Widget] = []
def _save() -> None:
panel.save_pins(pins)
def _refresh() -> None:
for r in rows:
group.remove(r)
rows.clear()
for i, pin in enumerate(pins):
row = Adw.ActionRow(title=pin.wmclass or pin.exec, subtitle=pin.exec)
row.add_prefix(Gtk.Image.new_from_icon_name(pin.icon or "application-x-executable"))
rm = Gtk.Button(icon_name="user-trash-symbolic")
rm.add_css_class("flat")
rm.set_valign(Gtk.Align.CENTER)
rm.connect("clicked", lambda _b, idx=i: (_remove(idx)))
row.add_suffix(rm)
group.add(row)
rows.append(row)
group.add(_add_row)
rows.append(_add_row)
def _remove(idx: int) -> None:
if 0 <= idx < len(pins):
del pins[idx]
_save()
_refresh()
# -- Add-Zeile (Expander mit 3 Feldern) --
_add_row = Adw.ExpanderRow(title="Add app", subtitle="exec · class · icon")
e_exec = Adw.EntryRow(title="Exec (command)")
e_class = Adw.EntryRow(title="Window class (app_id / WM_CLASS)")
e_icon = Adw.EntryRow(title="Icon name (Papirus)")
_add_row.add_row(e_exec)
_add_row.add_row(e_class)
_add_row.add_row(e_icon)
add_btn_row = Adw.ActionRow()
add_btn = Gtk.Button(label="Add to dock")
add_btn.add_css_class("suggested-action")
add_btn.add_css_class("pill")
add_btn.set_halign(Gtk.Align.START)
add_btn.set_margin_top(4)
add_btn.set_margin_bottom(4)
add_btn_row.set_child(add_btn)
_add_row.add_row(add_btn_row)
def _on_add(_b: Gtk.Button) -> None:
ex = e_exec.get_text().strip()
cl = e_class.get_text().strip() or ex
ic = e_icon.get_text().strip() or ex
if not ex:
return
pins.append(panel.Pin(ex, cl, ic))
e_exec.set_text("")
e_class.set_text("")
e_icon.set_text("")
_add_row.set_expanded(False)
_save()
_refresh()
add_btn.connect("clicked", _on_add)
_refresh()
return group
@@ -0,0 +1,213 @@
"""Power — Akku-Info (falls vorhanden) + Idle-Timeouts (Bildschirm aus / Suspend).
Akku/AC sind reiner Read aus core.power. Die Idle-Timeouts laufen niri-only
über swayidle (eigener taninux-swayidle.service, DPMS via niri-Action). Der
Service läuft als User-Service (kein root); die niri-Config wird nicht
angefasst. Fehlt swayidle, gibt's einen Install-Block (pacman via Helper).
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.core import power
from taninux.gui.console import RunnerCard
from taninux.gui.process import ProcessRunner
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
page.add(_power_source_group())
if power.brightness_available():
page.add(_brightness_group())
page.add(_energy_mode_group())
page.add(_idle_group())
if not power.idle_daemon_installed():
page.add(_install_group())
return page
def _card_box(card: Gtk.Widget) -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box.set_margin_top(6)
box.append(card)
return box
def _energy_mode_group() -> Adw.PreferencesGroup:
group = Adw.PreferencesGroup(
title="Energy mode",
description="Most effective on laptops.",
)
if not power.ppd_installed():
group.add(Adw.ActionRow(title="Install to enable energy modes"))
group.add(_card_box(RunnerCard(
"Install",
["pacman", "-S", "--needed", "--noconfirm", "power-profiles-daemon"],
privileged=True,
)))
return group
profs = power.power_profiles()
if not profs:
group.add(Adw.ActionRow(title="Enable to use energy modes"))
group.add(_card_box(RunnerCard(
"Enable",
["systemctl", "enable", "--now", "power-profiles-daemon"],
privileged=True,
)))
return group
cur = power.current_profile()
combo = Adw.ComboRow(title="Power profile")
combo.set_model(Gtk.StringList.new(profs))
if cur in profs:
combo.set_selected(profs.index(cur))
def on_sel(c: Adw.ComboRow, _p) -> None:
i = c.get_selected()
if 0 <= i < len(profs):
power.set_profile(profs[i])
combo.connect("notify::selected", on_sel)
group.add(combo)
return group
def _power_source_group() -> Adw.PreferencesGroup:
group = Adw.PreferencesGroup(title="Power source")
bats = power.batteries()
if bats:
for b in bats:
row = Adw.ActionRow(title=b.name.upper(), subtitle=b.status)
cap = f"{b.capacity}%" if b.capacity >= 0 else ""
if b.capacity >= 0:
level = Gtk.LevelBar.new_for_interval(0, 100)
level.set_value(b.capacity)
level.set_size_request(120, -1)
level.set_valign(Gtk.Align.CENTER)
row.add_suffix(level)
lbl = Gtk.Label(label=cap)
lbl.add_css_class("dim-label")
lbl.add_css_class("numeric")
row.add_suffix(lbl)
group.add(row)
else:
ac = power.ac_online()
sub = (
"On AC power" if ac is True
else "No battery detected — desktop system"
)
group.add(Adw.ActionRow(title="Battery", subtitle=sub))
return group
def _brightness_group() -> Adw.PreferencesGroup:
group = Adw.PreferencesGroup(title="Display brightness")
row = Adw.ActionRow(title="Brightness")
scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 1, 100, 1)
scale.set_hexpand(True)
scale.set_size_request(220, -1)
scale.set_draw_value(False)
scale.set_valign(Gtk.Align.CENTER)
pct_lbl = Gtk.Label()
pct_lbl.add_css_class("dim-label")
pct_lbl.add_css_class("numeric")
# Startwert VOR connect setzen, sonst feuert es beim Aufbau ein set.
cur = power.brightness_percent()
scale.set_value(max(1, min(100, cur)))
pct_lbl.set_label(f"{cur}%")
runner = ProcessRunner()
# Runner am Widget verankern, damit er nicht weg-gc't wird.
row._brightness_runner = runner # noqa: SLF001
def on_changed(s: Gtk.Scale) -> None:
val = int(s.get_value())
pct_lbl.set_label(f"{val}%")
if not runner.running:
runner.run(power.set_brightness_argv(val), privileged=False)
scale.connect("value-changed", on_changed)
row.add_suffix(scale)
row.add_suffix(pct_lbl)
group.add(row)
return group
def _idle_group() -> Adw.PreferencesGroup:
t = power.read_idle_timeouts()
daemon = power.idle_daemon_name()
group = Adw.PreferencesGroup(
title="Idle · sleep",
description="Turn the screen off or suspend after inactivity. 0 = never.",
)
so_adj = Gtk.Adjustment(value=t["screen_off"] // 60, lower=0, upper=240,
step_increment=1, page_increment=5)
so = Adw.SpinRow(title="Screen off after", adjustment=so_adj)
so.set_subtitle("minutes (0 = never)")
group.add(so)
su_adj = Gtk.Adjustment(value=t["suspend"] // 60, lower=0, upper=480,
step_increment=1, page_increment=5)
su = Adw.SpinRow(title="Suspend after", adjustment=su_adj)
su.set_subtitle("minutes (0 = never)")
group.add(su)
btn = Gtk.Button(label="Apply idle settings")
btn.add_css_class("suggested-action")
btn.add_css_class("pill")
btn.set_halign(Gtk.Align.START)
status = Gtk.Label(xalign=0.0)
status.add_css_class("dim-label")
status.set_wrap(True)
def on_apply(_b: Gtk.Button) -> None:
so_sec = int(so_adj.get_value()) * 60
su_sec = int(su_adj.get_value()) * 60
p = power.write_idle_config(so_sec, su_sec)
if power.idle_daemon_installed():
ok = power.apply_idle()
status.set_label(
f"Saved → {p}. {daemon} "
+ ("restarted." if ok else "service could not start.")
)
else:
status.set_label(
f"Saved → {p}. Install {daemon} below to activate."
)
btn.connect("clicked", on_apply)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
box.set_margin_top(6)
box.append(btn)
box.append(status)
group.add(box)
return group
def _install_group() -> Adw.PreferencesGroup:
pkg = power.idle_pkg()
group = Adw.PreferencesGroup(
title="Idle timers",
description="Install to enable the timers above.",
)
card = RunnerCard(
"Install", ["pacman", "-S", "--needed", "--noconfirm", pkg],
privileged=True,
)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box.set_margin_top(6)
box.append(card)
group.add(box)
return group
@@ -0,0 +1,195 @@
"""Printers — CUPS-Druckerverwaltung (libadwaita).
Read kommt aus core.printers (lpstat). Schreib-Aktionen (Default setzen,
enable/disable, entfernen) liefern dort nur argv und laufen hier über einen
gemeinsamen ProcessRunner — privilegiert via Helper (lpadmin/cupsenable/
cupsdisable) bzw. unprivilegiert (cancel -a für die Queue).
Drucker HINZUFÜGEN (Treiberauswahl) ist NICHT enthalten — dafür ein
dediziertes System-Tool nutzen.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.core import printers
from taninux.gui import confirm as confirm_mod
from taninux.gui.console import LogConsole, RunnerCard, log_expander
from taninux.gui.process import ProcessRunner
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
if not printers.available():
page.add(_unavailable_group())
return page
# Gemeinsame Konsole + Runner für alle Aktionen dieser Seite.
console = LogConsole()
runner = ProcessRunner()
runner.connect("line", lambda _r, line: console.append_line(line))
# Druckergruppe wird nach jeder Aktion neu befüllt (live).
printers_group = Adw.PreferencesGroup(
title="Printers",
description="Adding a printer (driver selection) isn't here — use a "
"dedicated tool for that.",
)
queue_group = Adw.PreferencesGroup(
title="Print queue",
description="Pending jobs across all printers.",
)
def run(argv: list[str], *, privileged: bool) -> None:
console.clear()
runner.run(argv, privileged=privileged)
def refresh() -> None:
_fill_printers(printers_group, run)
_fill_queue(queue_group, run)
runner.connect("done", lambda _r, code: _on_done(console, code, refresh))
refresh()
page.add(printers_group)
page.add(queue_group)
page.add(log_expander(console, title="Output"))
return page
def _on_done(console: LogConsole, code: int, refresh) -> None:
console.append_line("")
console.append_line(
f"✔ done (exit {code})" if code == 0 else f"✘ exit {code}"
)
refresh()
def _clear_group(group: Adw.PreferencesGroup) -> None:
"""Entfernt alle bisher per _track() hinzugefügten Rows der Gruppe."""
rows = getattr(group, "_tani_rows", None)
if not rows:
return
for row in list(rows):
group.remove(row)
rows.clear()
def _track(group: Adw.PreferencesGroup, row: Gtk.Widget) -> None:
rows = getattr(group, "_tani_rows", None)
if rows is None:
rows = []
group._tani_rows = rows
group.add(row)
rows.append(row)
def _fill_printers(group: Adw.PreferencesGroup, run) -> None:
_clear_group(group)
plist = printers.printers()
if not plist:
_track(group, Adw.ActionRow(
title="No printers configured",
subtitle="Add one with a system printer tool.",
))
return
for p in plist:
subtitle = p.state
if p.is_default:
subtitle += " · default"
if p.info:
subtitle += f" · {p.info}"
row = Adw.ActionRow(title=p.name, subtitle=subtitle)
if not p.is_default:
row.add_suffix(_btn(
"Set default",
lambda _b, n=p.name: run(printers.set_default_argv(n),
privileged=True),
))
if p.enabled:
row.add_suffix(_btn(
"Disable",
lambda _b, n=p.name: run(printers.disable_argv(n),
privileged=True),
))
else:
row.add_suffix(_btn(
"Enable",
lambda _b, n=p.name: run(printers.enable_argv(n),
privileged=True),
suggested=True,
))
remove = _btn(
"Remove",
lambda _b, n=p.name: confirm_mod.confirm(
_b, "Remove printer?",
f"Permanently remove the printer “{n}” from CUPS.",
lambda n=n: run(printers.remove_argv(n), privileged=True),
),
destructive=True,
)
row.add_suffix(remove)
_track(group, row)
def _fill_queue(group: Adw.PreferencesGroup, run) -> None:
_clear_group(group)
jobs = printers.queue()
if not jobs:
_track(group, Adw.ActionRow(
title="Queue empty", subtitle="No pending print jobs."
))
return
for job in jobs:
_track(group, Adw.ActionRow(title=job))
clear_row = Adw.ActionRow(title="Clear queue",
subtitle="Cancel all pending jobs.")
clear_row.add_suffix(_btn(
"Clear queue",
lambda _b: run(printers.cancel_all_argv(), privileged=False),
destructive=True,
))
_track(group, clear_row)
def _btn(label: str, on_click, *, suggested: bool = False,
destructive: bool = False) -> Gtk.Button:
btn = Gtk.Button(label=label)
btn.set_valign(Gtk.Align.CENTER)
if suggested:
btn.add_css_class("suggested-action")
if destructive:
btn.add_css_class("destructive-action")
btn.connect("clicked", on_click)
return btn
def _unavailable_group() -> Adw.PreferencesGroup:
group = Adw.PreferencesGroup(
title="Printing",
description="CUPS is not installed. Printer management is unavailable.",
)
group.add(Adw.ActionRow(
title="CUPS not available",
subtitle="Install cups to manage printers.",
))
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box.set_margin_top(6)
box.append(RunnerCard(
"Install CUPS",
["pacman", "-S", "--needed", "--noconfirm", "cups"],
privileged=True,
))
group.add(box)
return group
@@ -0,0 +1,233 @@
"""Appearance-Seite — Farbschema + Fuji-Akzent.
Voll funktional und persistent (gui.config): Farbschema System/Light/Dark als
ComboRow, die Fuji-Palette als wählbare Swatches. Beide wirken sofort app-weit
über gui.style.
"""
from __future__ import annotations
import gi
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk, Pango # noqa: E402
from taninux.gui import config, desktop, eww, style
from taninux.gui.pages.wallpaper import wallpaper_group
# Schema-Reihenfolge der ComboRow ↔ Persistenz-Keys
_SCHEME_LABELS = ["System", "Light", "Dark"]
_SCHEME_KEYS = ["system", "light", "dark"]
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
page.add(_appearance_group())
page.add(wallpaper_group()) # sehr visuell -> direkt nach Color scheme/Accent
page.add(_window_buttons_group())
page.add(_theme_icons_group())
page.add(_font_group())
page.add(_cursor_group())
return page
# -- GTK-Theme + Icon-Theme (nwg-look-Stil, kein root) --------------------
def _combo(title: str, items: list[str], current_value: str, apply_fn) -> Adw.ComboRow:
"""ComboRow, die bei Auswahl apply_fn(value) ruft. Initial-Set feuert nicht
(connect erst NACH set_selected)."""
opts = list(items)
if current_value and current_value not in opts:
opts.insert(0, current_value) # Ist-Wert immer wählbar halten
row = Adw.ComboRow(title=title)
row.set_model(Gtk.StringList.new(opts or ["(none)"]))
row.set_enable_search(True)
if current_value in opts:
row.set_selected(opts.index(current_value))
def on_sel(r: Adw.ComboRow, _p) -> None:
i = r.get_selected()
if 0 <= i < len(opts):
apply_fn(opts[i])
row.connect("notify::selected", on_sel)
return row
def _window_buttons_group() -> Adw.PreferencesGroup:
"""Einzelne Titlebar-Knöpfe an/aus + Platzierung (wie GNOME Tweaks)."""
cur = desktop.window_buttons()
group = Adw.PreferencesGroup(
title="Window buttons",
description="Show or hide the titlebar buttons.",
)
state = {"ready": False}
min_row = Adw.SwitchRow(title="Minimize")
max_row = Adw.SwitchRow(title="Maximize")
close_row = Adw.SwitchRow(title="Close")
place_row = Adw.ComboRow(title="Placement")
place_row.set_model(Gtk.StringList.new(["Right", "Left"]))
def apply(*_a) -> None:
if not state["ready"]:
return
side = "left" if place_row.get_selected() == 1 else "right"
desktop.set_window_buttons(
min_row.get_active(), max_row.get_active(), close_row.get_active(), side,
)
# Startwerte ZUERST setzen, dann erst Signale verbinden (kein Initial-Feuern).
min_row.set_active(cur["minimize"])
max_row.set_active(cur["maximize"])
close_row.set_active(cur["close"])
place_row.set_selected(1 if cur["side"] == "left" else 0)
min_row.connect("notify::active", apply)
max_row.connect("notify::active", apply)
close_row.connect("notify::active", apply)
place_row.connect("notify::selected", apply)
group.add(min_row)
group.add(max_row)
group.add(close_row)
group.add(place_row)
state["ready"] = True
return group
def _theme_icons_group() -> Adw.PreferencesGroup:
cur = desktop.current()
group = Adw.PreferencesGroup(
title="Theme · icons",
description="Running apps may need a restart.",
)
group.add(_combo(
"GTK theme", desktop.list_gtk_themes(), cur["gtk_theme"],
desktop.apply_gtk_theme,
))
group.add(_combo(
"Icon theme", desktop.list_icon_themes(), cur["icon_theme"],
desktop.apply_icon_theme,
))
return group
def _font_group() -> Adw.PreferencesGroup:
cur = desktop.current()
group = Adw.PreferencesGroup(title="Font")
row = Adw.ActionRow(
title="System font",
subtitle="Default interface font",
)
dialog = Gtk.FontDialog()
fbtn = Gtk.FontDialogButton.new(dialog)
fbtn.set_valign(Gtk.Align.CENTER)
if cur["font"]:
fbtn.set_font_desc(Pango.FontDescription.from_string(cur["font"]))
def on_font(btn: Gtk.FontDialogButton, _p) -> None:
desc = btn.get_font_desc()
if desc is not None:
desktop.apply_font(desc.to_string())
fbtn.connect("notify::font-desc", on_font)
row.add_suffix(fbtn)
group.add(row)
return group
def _cursor_group() -> Adw.PreferencesGroup:
cur = desktop.current()
group = Adw.PreferencesGroup(
title="Cursor",
description="Cursor theme and size.",
)
group.add(_combo(
"Cursor theme", desktop.list_cursor_themes(), cur["cursor_theme"],
desktop.apply_cursor_theme,
))
try:
size = int(cur["cursor_size"])
except (ValueError, TypeError):
size = 24
adj = Gtk.Adjustment(value=size, lower=8, upper=128, step_increment=2,
page_increment=8)
spin = Adw.SpinRow(title="Cursor size", adjustment=adj)
spin.set_subtitle("Pixel")
# connect NACH dem Setzen des Startwerts -> Initial feuert nicht
adj.connect("value-changed", lambda a: desktop.apply_cursor_size(int(a.get_value())))
group.add(spin)
return group
def _appearance_group() -> Adw.PreferencesGroup:
cfg = config.load()
group = Adw.PreferencesGroup(
title="Appearance",
description="Color scheme and Fuji accent — applied instantly, saved.",
)
# Guard: programmatisches Setzen der Startwerte soll nicht schon speichern.
state = {"ready": False}
def persist() -> None:
if state["ready"]:
config.save(state["scheme"], state["accent"])
style.apply(state["scheme"], state["accent"]) # diese App live
style.write_global_theme(state["scheme"], state["accent"]) # ALLE GTK3+GTK4-Apps + gsettings
eww.reload_if_running() # Bar live nachziehen
state["scheme"] = cfg["color_scheme"]
state["accent"] = cfg["accent"]
# -- Color scheme --------------------------------------------------
scheme_row = Adw.ComboRow(title="Color scheme")
scheme_row.set_model(Gtk.StringList.new(_SCHEME_LABELS))
cur = _SCHEME_KEYS.index(cfg["color_scheme"]) if cfg["color_scheme"] in _SCHEME_KEYS else 0
scheme_row.set_selected(cur)
def on_scheme(row: Adw.ComboRow, _param) -> None:
state["scheme"] = _SCHEME_KEYS[row.get_selected()]
persist()
scheme_row.connect("notify::selected", on_scheme)
group.add(scheme_row)
# -- Fuji accent (Swatch-Reihe) -----------------------------------
accent_row = Adw.ActionRow(
title="Accent color",
subtitle="Fuji palette",
)
swatches = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
swatches.set_valign(Gtk.Align.CENTER)
leader: Gtk.ToggleButton | None = None
for acc in style.FUJI_ACCENTS:
btn = Gtk.ToggleButton()
btn.add_css_class("fuji-swatch")
btn.add_css_class(f"swatch-{acc.key}")
btn.set_tooltip_text(acc.label)
btn.set_valign(Gtk.Align.CENTER)
if leader is None:
leader = btn
else:
btn.set_group(leader)
if acc.key == cfg["accent"]:
btn.set_active(True)
def on_toggle(b: Gtk.ToggleButton, key: str = acc.key) -> None:
if b.get_active():
state["accent"] = key
persist()
btn.connect("toggled", on_toggle)
swatches.append(btn)
accent_row.add_suffix(swatches)
group.add(accent_row)
state["ready"] = True
return group
@@ -0,0 +1,282 @@
"""Network Shares — Remote-Verbindungen für Nautilus (SMB/SFTP/FTP/WebDAV/NFS).
Schreibt Remote-Bookmarks über core.shares (GTK-Bookmarks, die Nautilus liest)
und verbindet via `gio mount` (unprivilegiert; GVfs fragt das Passwort selbst
via Keyring/Agent). Nextcloud ist hier als WebDAV-Preset integriert — kein
Sync-Client nötig (leichtgewichtig, Dateien on-demand).
Felder passen sich pro Protokoll an, eine Live-Vorschau zeigt die Ziel-URI.
SMB/NFS brauchen gvfs-smb/gvfs-nfs; fehlen sie, gibt's einen Install-Block.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa: E402
from taninux.core import nextcloud as nc
from taninux.core import shares
from taninux.gui import confirm as confirm_mod
from taninux.gui.console import LogConsole, RunnerCard, log_expander
from taninux.gui.process import ProcessRunner
# (Label, scheme). "nextcloud" ist ein Spezial-Scheme (baut den WebDAV-Pfad).
_PROTOCOLS: list[tuple[str, str]] = [
("SFTP", "sftp"),
("SMB · Windows share", "smb"),
("WebDAV", "davs"),
("Nextcloud", "nextcloud"),
("FTP", "ftp"),
("NFS", "nfs"),
]
# Pro Scheme: welche Felder sichtbar sind + ihr Titel. (title, visible)
_FIELDS: dict[str, dict[str, tuple[str, bool]]] = {
"sftp": {"host": ("Host", True), "path": ("Path (optional)", True),
"user": ("User (optional)", True), "port": ("Port (optional)", True)},
"ftp": {"host": ("Host", True), "path": ("Path (optional)", True),
"user": ("User (optional)", True), "port": ("Port (optional)", True)},
"smb": {"host": ("Host", True), "path": ("Share", True),
"user": ("User (optional)", True), "port": ("", False)},
"davs": {"host": ("Host", True), "path": ("Path (optional)", True),
"user": ("User (optional)", True), "port": ("", False)},
"nextcloud": {"host": ("Server · e.g. cloud.example.com", True),
"path": ("", False), "user": ("Username", True),
"port": ("", False)},
"nfs": {"host": ("Host", True), "path": ("Export path", True),
"user": ("", False), "port": ("", False)},
}
_SCHEME_ICON = {
"smb": "folder-remote-symbolic", "sftp": "folder-remote-symbolic",
"ftp": "folder-remote-symbolic", "nfs": "folder-remote-symbolic",
"dav": "folder-cloud-symbolic", "davs": "folder-cloud-symbolic",
}
def build() -> Gtk.Widget:
page = Adw.PreferencesPage()
console = LogConsole()
runner = ProcessRunner()
runner.connect("line", lambda _r, line: console.append_line(line))
runner.connect("done", lambda _r, code: console.append_line(
"✔ done" if code == 0 else f"✘ exit {code}"))
# ============================================================ Your shares
list_group = Adw.PreferencesGroup(
title="Your shares",
description="Saved remote locations — they appear in the file manager.",
)
refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic")
refresh_btn.add_css_class("flat")
refresh_btn.set_valign(Gtk.Align.CENTER)
refresh_btn.set_tooltip_text("Reload")
list_group.set_header_suffix(refresh_btn)
page.add(list_group)
def _mount(uri: str) -> None:
console.clear()
runner.run(shares.mount_argv(uri))
def _remove(uri: str, label: str) -> None:
confirm_mod.confirm(
page, "Remove share?",
f"Remove “{label}”. The remote data is not deleted.",
lambda: (shares.remove_bookmark(uri), _fill_list()),
)
def _fill_list() -> None:
_clear_group(list_group)
bms = shares.list_remote()
if not bms:
_track(list_group, Adw.ActionRow(
title="No shares yet",
subtitle="Add one below — SFTP, SMB, WebDAV, Nextcloud, FTP or NFS.",
))
return
for bm in bms:
row = Adw.ActionRow(title=bm.label or bm.uri, subtitle=bm.uri)
icon = _SCHEME_ICON.get(bm.scheme, "folder-remote-symbolic")
row.add_prefix(Gtk.Image.new_from_icon_name(icon))
connect = _btn("Connect", suggested=True)
connect.connect("clicked", lambda _b, u=bm.uri: _mount(u))
row.add_suffix(connect)
remove = _btn("Remove", destructive=True)
remove.connect("clicked", lambda _b, u=bm.uri, lb=bm.label: _remove(u, lb))
row.add_suffix(remove)
_track(list_group, row)
refresh_btn.connect("clicked", lambda _b: _fill_list())
# ============================================================ Add
add_group = Adw.PreferencesGroup(
title="Add a connection",
description="You'll be asked for the password on first connect.",
)
proto_row = Adw.ComboRow(title="Type")
proto_model = Gtk.StringList()
for label, _scheme in _PROTOCOLS:
proto_model.append(label)
proto_row.set_model(proto_model)
add_group.add(proto_row)
host_row = Adw.EntryRow(title="Host")
path_row = Adw.EntryRow(title="Path")
user_row = Adw.EntryRow(title="User")
port_row = Adw.EntryRow(title="Port")
field_rows = {"host": host_row, "path": path_row,
"user": user_row, "port": port_row}
for r in field_rows.values():
add_group.add(r)
# Live-Vorschau der Ziel-URI.
preview = Adw.ActionRow(title="Will create")
preview_lbl = Gtk.Label(label="")
preview_lbl.add_css_class("dim-label")
preview_lbl.set_selectable(True)
preview_lbl.set_ellipsize(3) # END
preview.add_suffix(preview_lbl)
add_group.add(preview)
add_btn = Gtk.Button(label="Add")
add_btn.add_css_class("suggested-action")
add_btn.set_valign(Gtk.Align.CENTER)
add_row = Adw.ActionRow(title="Add to file manager")
add_row.add_suffix(add_btn)
add_group.add(add_row)
page.add(add_group)
def _scheme() -> str:
return _PROTOCOLS[proto_row.get_selected()][1]
def _current_uri() -> str:
host = host_row.get_text().strip()
if not host:
return ""
scheme = _scheme()
if scheme == "nextcloud":
u = user_row.get_text().strip()
return nc.webdav_uri_from(host, u) if u else ""
return shares.build_uri(
scheme, host, path=path_row.get_text(),
user=user_row.get_text(), port=port_row.get_text(),
)
def _update_preview(*_a) -> None:
preview_lbl.set_label(_current_uri() or "")
def _sync_fields(*_a) -> None:
conf = _FIELDS[_scheme()]
for key, row in field_rows.items():
title, visible = conf[key]
row.set_visible(visible)
if title:
row.set_title(title)
_update_preview()
proto_row.connect("notify::selected", _sync_fields)
for r in field_rows.values():
r.connect("changed", _update_preview)
def _on_add(_b: Gtk.Button) -> None:
uri = _current_uri()
console.clear()
if not uri:
console.append_line("✘ fill in the required fields first")
return
label = host_row.get_text().strip()
shares.add_bookmark(uri, label=label)
for r in field_rows.values():
r.set_text("")
console.append_line(f"✔ added {uri}")
_update_preview()
_fill_list()
add_btn.connect("clicked", _on_add)
# ============================================================ Nextcloud accounts
nc_accounts = nc.accounts()
if nc_accounts:
nc_group = Adw.PreferencesGroup(
title="Detected Nextcloud accounts",
description="From the Nextcloud client — add as WebDAV in one click.",
)
for acc in nc_accounts:
row = Adw.ActionRow(title=acc.name, subtitle=acc.url)
row.add_prefix(Gtk.Image.new_from_icon_name("folder-cloud-symbolic"))
add = _btn("Add to Files", suggested=True)
def _add_acc(_b, a=acc) -> None:
uri = nc.webdav_uri(a)
shares.add_bookmark(uri, label=a.name)
console.clear()
console.append_line(f"✔ added {uri}")
_fill_list()
add.connect("clicked", _add_acc)
row.add_suffix(add)
nc_group.add(row)
page.add(nc_group)
# ============================================================ Backends
missing: list[tuple[str, str, list[str]]] = []
if not shares.smb_available():
missing.append(("SMB · Windows shares", "gvfs-smb", shares.install_smb_argv()))
if not shares.nfs_available():
missing.append(("NFS shares", "gvfs-nfs", shares.install_nfs_argv()))
if missing:
be_group = Adw.PreferencesGroup(
title="More connection types",
description="SMB and NFS need an extra component.",
)
for label, pkg, argv in missing:
be_group.add(Adw.ActionRow(
title=label, subtitle="Install to enable this connection type."))
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box.set_margin_top(6)
box.append(RunnerCard("Install", argv, privileged=True))
be_group.add(box)
page.add(be_group)
# ============================================================ Log
page.add(log_expander(console))
_sync_fields()
_fill_list()
return page
# ----------------------------------------------------------- Helpers
def _clear_group(group: Adw.PreferencesGroup) -> None:
rows = getattr(group, "_tani_rows", None)
if not rows:
return
for row in list(rows):
group.remove(row)
rows.clear()
def _track(group: Adw.PreferencesGroup, row: Gtk.Widget) -> None:
rows = getattr(group, "_tani_rows", None)
if rows is None:
rows = []
group._tani_rows = rows
group.add(row)
rows.append(row)
def _btn(label: str, *, suggested: bool = False,
destructive: bool = False) -> Gtk.Button:
btn = Gtk.Button(label=label)
btn.add_css_class("flat")
btn.set_valign(Gtk.Align.CENTER)
if suggested:
btn.add_css_class("suggested-action")
if destructive:
btn.add_css_class("destructive-action")
return btn

Some files were not shown because too many files have changed in this diff Show More