From 202e29c87cb0b7b5288c1a8c2441d3bb5cec0523 Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Mon, 7 Sep 2026 12:49:44 +0200 Subject: [PATCH] update --- flake.lock | 18 +- flake.nix | 20 ++ pkgs/delta-bin/package.nix | 74 ++++++ scripts/app-usage-report.py | 452 ++++++++++++++++++++++++++++++++++++ servers/ui-mode/home.nix | 32 +-- 5 files changed, 572 insertions(+), 24 deletions(-) create mode 100644 pkgs/delta-bin/package.nix create mode 100755 scripts/app-usage-report.py diff --git a/flake.lock b/flake.lock index f533dfa..304c67e 100644 --- a/flake.lock +++ b/flake.lock @@ -1689,11 +1689,11 @@ }, "nixpkgs-unstable": { "locked": { - "lastModified": 1788179007, - "narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=", + "lastModified": 1788531059, + "narHash": "sha256-hLD4l3QOGBQhkVp3mQ2lJ/YbEi99qUgKapb40KovZ88=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9", + "rev": "801bef6abd86b91e51083066b83fb354a11fc640", "type": "github" }, "original": { @@ -1766,11 +1766,11 @@ }, "nixpkgs_13": { "locked": { - "lastModified": 1788179007, - "narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=", + "lastModified": 1788531059, + "narHash": "sha256-hLD4l3QOGBQhkVp3mQ2lJ/YbEi99qUgKapb40KovZ88=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9", + "rev": "801bef6abd86b91e51083066b83fb354a11fc640", "type": "github" }, "original": { @@ -1961,11 +1961,11 @@ "rust-overlay": "rust-overlay_2" }, "locked": { - "lastModified": 1788263493, - "narHash": "sha256-VCwnvc3sHbp8Ln4GugqbJHNl0CVelsxzdWMLfDaFXbw=", + "lastModified": 1788547023, + "narHash": "sha256-xr/YlrdPgCE/DouJdKVfMhEGldzsvgXs8k5heknctVY=", "owner": "can1357", "repo": "oh-my-pi", - "rev": "45aed39ae01f1086f56983563b80c8c079df5cfd", + "rev": "5964a0f7649275bcde818f20073193fd032451f2", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 61db0b9..df5438c 100644 --- a/flake.nix +++ b/flake.nix @@ -168,6 +168,26 @@ ]; }; desktopCompatibilityOverlay = _: prev: { + codex = + let + version = "0.153.4"; + src = prev.fetchFromGitHub { + owner = "openai"; + repo = "codex"; + tag = "rust-v${version}"; + hash = "sha256-lHiDj5SodaM3mh8goMm6esfejeAT+Y3JJWrRnyj6sJo="; + }; + sourceRoot = "${src.name}/codex-rs"; + in + prev.codex.overrideAttrs (_: { + inherit version src sourceRoot; + cargoHash = "sha256-GG6kOXmCdq+bZLU2ul0DIVL8lDuweayvZvXn6+bcUZw="; + cargoDeps = prev.rustPlatform.fetchCargoVendor { + inherit src sourceRoot; + hash = "sha256-GG6kOXmCdq+bZLU2ul0DIVL8lDuweayvZvXn6+bcUZw="; + }; + }); + # Hyprland 0.56.1 requires glaze >= 7 and < 8, while this # nixpkgs revision provides glaze 8.0.0. hyprland = prev.hyprland.override { diff --git a/pkgs/delta-bin/package.nix b/pkgs/delta-bin/package.nix new file mode 100644 index 0000000..38d1f5c --- /dev/null +++ b/pkgs/delta-bin/package.nix @@ -0,0 +1,74 @@ +{ + alsa-lib, + autoPatchelfHook, + dbus, + fontconfig, + glib, + lib, + libGL, + libsecret, + makeWrapper, + openssl, + requireFile, + stdenv, + vulkan-loader, + wayland, + xkeyboard_config, +}: + +stdenv.mkDerivation (finalAttrs: { + pname = "delta-bin"; + version = "0.6.1"; + + src = requireFile { + name = "delta-linux-x86_64.tar.gz"; + url = "https://delta.dev/download"; + hash = "sha256-Q/CSFKlIDd47DwQk/QatfBs8AtM/ObTA3TFOTvggcsE="; + message = '' + Delta is beta-gated and cannot be downloaded without a Zed account. + Download the Linux x86_64 archive from https://delta.dev/download, then run: + + nix-store --add-fixed sha256 delta-linux-x86_64.tar.gz + ''; + }; + + nativeBuildInputs = [ + autoPatchelfHook + makeWrapper + ]; + runtimeDependencies = [ + alsa-lib + dbus + fontconfig + glib + libGL + libsecret + openssl + vulkan-loader + wayland + ]; + + installPhase = '' + runHook preInstall + + mkdir -p $out + cp -a ./. $out/ + rm $out/install.sh + + runHook postInstall + ''; + + postFixup = '' + wrapProgram $out/bin/delta \ + --set-default XKB_CONFIG_ROOT "${xkeyboard_config}/share/X11/xkb" + ''; + + meta = { + description = "Collaborative agent workspace from the creators of Zed"; + homepage = "https://delta.dev/"; + license = lib.licenses.unfree; + mainProgram = "delta"; + platforms = [ "x86_64-linux" ]; + sourceProvenance = with lib.sourceTypes; [ binaryNativeCode ]; + }; +}) diff --git a/scripts/app-usage-report.py b/scripts/app-usage-report.py new file mode 100755 index 0000000..83b08b8 --- /dev/null +++ b/scripts/app-usage-report.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +"""Report GUI packages declared for a Home Manager user but rarely launched. + +The primary signal is DankMaterialShell's launcher history. Shell history, +notification history, and configuration references are included as secondary +signals so the report can expose likely false positives instead of silently +classifying them as unused. +""" + +from __future__ import annotations + +import argparse +import configparser +import datetime as dt +import json +import os +import re +import shlex +import subprocess +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any, Iterable + +TEXT_SUFFIXES = { + "", + ".conf", + ".desktop", + ".fish", + ".ini", + ".json", + ".kdl", + ".lua", + ".nix", + ".nu", + ".service", + ".sh", + ".toml", + ".yaml", + ".yml", +} +SKIP_DIRS = { + ".git", + ".mozilla", + "Cache", + "GPUCache", + "IndexedDB", + "Local Storage", + "Service Worker", + "cache", + "node_modules", + "storage", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Find rarely launched GUI applications declared in a Home Manager profile." + ) + parser.add_argument("--flake", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--host", default="fern") + parser.add_argument("--user", default=os.environ.get("USER", "dan")) + parser.add_argument( + "--days", + type=int, + default=90, + help="consider a DMS launch recent within this many days (default: 90)", + ) + parser.add_argument( + "--max-launches", + type=int, + default=3, + help="label an app rare when its lifetime DMS count is at most this value (default: 3)", + ) + parser.add_argument("--all", action="store_true", help="include recently used applications") + parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") + return parser.parse_args() + + +def nix_packages(flake: Path, host: str, user: str) -> list[dict[str, Any]]: + installable = ( + f".#nixosConfigurations.{host}.config.home-manager.users.{user}.home.packages" + ) + result = subprocess.run( + ["nix", "eval", "--json", installable], + cwd=flake, + text=True, + capture_output=True, + ) + if result.returncode: + sys.stderr.write(result.stderr) + raise SystemExit(result.returncode) + return [ + { + "name": Path(out_path).name, + "pname": None, + "version": None, + "outPath": out_path, + } + for out_path in json.loads(result.stdout) + ] + + +def normalize(value: str) -> str: + value = value.casefold().removesuffix(".desktop") + return re.sub(r"[^a-z0-9]+", "", value) + + +def executable_from(exec_line: str) -> str: + try: + words = shlex.split(exec_line) + except ValueError: + words = exec_line.split() + words = [word for word in words if not word.startswith("%")] + while words and ("=" in words[0] or words[0] in {"env", "nohup"}): + words.pop(0) + return Path(words[0]).name if words else "" + + +def desktop_entry(path: Path) -> dict[str, str] | None: + parser = configparser.ConfigParser(interpolation=None, strict=False) + try: + parser.read(path, encoding="utf-8") + entry = parser["Desktop Entry"] + except (OSError, UnicodeError, KeyError, configparser.Error): + return None + if entry.get("Type", "Application") != "Application": + return None + if entry.getboolean("Hidden", fallback=False) or entry.getboolean( + "NoDisplay", fallback=False + ): + return None + return { + "id": path.stem, + "name": entry.get("Name", path.stem), + "exec": executable_from(entry.get("Exec", "")), + "wm_class": entry.get("StartupWMClass", ""), + "desktop_file": str(path), + } + + +def package_key(store_name: str) -> str: + name = re.sub(r"^[a-z0-9]{32}-", "", store_name) + parts = name.split("-") + for index, part in enumerate(parts[1:], start=1): + if re.match(r"v?\d", part): + return "-".join(parts[:index]).casefold() + return name.casefold() + + +def active_gui_packages( + home: Path, user: str, declared_packages: list[dict[str, Any]] +) -> tuple[list[dict[str, Any]], int]: + profile = Path("/etc/profiles/per-user") / user + if not profile.exists(): + profile = home / ".nix-profile" + applications = profile / "share/applications" + declared_keys = {package_key(package["name"]) for package in declared_packages} + grouped: dict[Path, list[dict[str, str]]] = defaultdict(list) + skipped = 0 + for path in applications.glob("*.desktop"): + try: + resolved = path.resolve(strict=True) + owner = Path(*resolved.parts[:4]) + except OSError: + continue + if package_key(owner.name) not in declared_keys: + skipped += 1 + continue + entry = desktop_entry(path) + if entry: + grouped[owner].append(entry) + return ( + [ + { + "name": re.sub(r"^[a-z0-9]{32}-", "", owner.name), + "pname": package_key(owner.name), + "version": None, + "outPath": str(owner), + "entries": entries, + } + for owner, entries in grouped.items() + ], + skipped, + ) + + +def load_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return {} + + +def usage_index(path: Path) -> tuple[dict[str, Any], dict[str, list[str]]]: + records = load_json(path).get("appUsageRanking", {}) + indexes: dict[str, list[str]] = defaultdict(list) + for key, record in records.items(): + for value in (key, record.get("name", ""), executable_from(record.get("exec", ""))): + token = normalize(str(value)) + if token and key not in indexes[token]: + indexes[token].append(key) + return records, indexes + + +def entry_tokens(entry: dict[str, str]) -> set[str]: + return { + token + for token in ( + normalize(entry["id"]), + normalize(entry["name"]), + normalize(entry["exec"]), + normalize(entry["wm_class"]), + ) + if len(token) >= 3 + } + + +def match_usage_keys(entry: dict[str, str], index: dict[str, list[str]]) -> set[str]: + matches: set[str] = set() + for token in entry_tokens(entry): + matches.update(index.get(token, [])) + return matches + + +def notification_counts(path: Path) -> dict[str, int]: + data = load_json(path) + notifications = data if isinstance(data, list) else data.get("notifications", []) + counts: dict[str, int] = defaultdict(int) + for item in notifications if isinstance(notifications, list) else []: + if not isinstance(item, dict): + continue + for field in ("desktopEntry", "appName"): + token = normalize(str(item.get(field, ""))) + if token: + counts[token] += 1 + return counts + + +def history_text(home: Path) -> str: + chunks: list[str] = [] + for path in ( + home / ".local/share/fish/fish_history", + home / ".config/nushell/history.txt", + ): + try: + chunks.append(path.read_text(encoding="utf-8", errors="ignore")) + except OSError: + pass + return "\n".join(chunks).casefold() + + +def iter_config_files(roots: Iterable[Path]) -> Iterable[Path]: + seen: set[Path] = set() + for root in roots: + if not root.is_dir(): + continue + for current, dirs, files in os.walk(root): + dirs[:] = [name for name in dirs if name not in SKIP_DIRS] + for name in files: + path = Path(current) / name + if path in seen or path.suffix.casefold() not in TEXT_SUFFIXES: + continue + seen.add(path) + try: + if path.stat().st_size <= 1_000_000: + yield path + except OSError: + continue + + +def config_references( + roots: Iterable[Path], + home_config: Path, + token_to_packages: dict[str, set[int]], +) -> dict[int, list[str]]: + references: dict[int, list[str]] = defaultdict(list) + searchable = sorted( + (token for token in token_to_packages if len(token) >= 4), + key=len, + reverse=True, + ) + if not searchable: + return references + pattern = re.compile( + r"(? str | None: + if not timestamp_ms: + return None + return dt.datetime.fromtimestamp(float(timestamp_ms) / 1000, dt.timezone.utc).isoformat( + timespec="seconds" + ) + + +def analyze(args: argparse.Namespace) -> dict[str, Any]: + home = Path.home() if args.user == os.environ.get("USER") else Path("/home") / args.user + dms_state = home / ".local/state/DankMaterialShell/appusage.json" + records, usage_by_token = usage_index(dms_state) + notifications = notification_counts(home / ".cache/DankMaterialShell/notification_history.json") + shell_history = history_text(home) + + declared_packages = nix_packages(args.flake, args.host, args.user) + gui_packages, skipped_profile_entries = active_gui_packages( + home, args.user, declared_packages + ) + + token_to_packages: dict[str, set[int]] = defaultdict(set) + raw_tokens: dict[int, set[str]] = defaultdict(set) + for package_id, package in enumerate(gui_packages): + for entry in package["entries"]: + for raw in (entry["id"], entry["exec"], entry["wm_class"]): + token = raw.casefold().strip() + if len(token) >= 4: + raw_tokens[package_id].add(token) + token_to_packages[token].add(package_id) + + config_refs = config_references( + (args.flake / ".config",), + home / ".config", + token_to_packages, + ) + cutoff = dt.datetime.now(dt.timezone.utc).timestamp() * 1000 - args.days * 86_400_000 + report: list[dict[str, Any]] = [] + + for package_id, package in enumerate(gui_packages): + usage_keys: set[str] = set() + app_names: list[str] = [] + notify_count = 0 + for entry in package["entries"]: + usage_keys.update(match_usage_keys(entry, usage_by_token)) + app_names.append(entry["name"]) + notify_count += sum(notifications.get(token, 0) for token in entry_tokens(entry)) + + launches = sum(int(records[key].get("usageCount", 0)) for key in usage_keys) + last_used_ms = max( + (float(records[key].get("lastUsed", 0)) for key in usage_keys), default=0 + ) + history_hits = sum(shell_history.count(token) for token in raw_tokens[package_id]) + recent = last_used_ms >= cutoff + if recent: + status = "recent" + elif last_used_ms: + status = ( + "stale-rare" + if launches <= args.max_launches + else "stale-frequent" + ) + else: + status = "configured-only" if config_refs[package_id] else "never" + + report.append( + { + "package": package.get("pname") or package["name"], + "version": package.get("version"), + "applications": sorted(set(app_names)), + "status": status, + "dms_launches": launches, + "last_dms_launch": iso_time(last_used_ms), + "shell_history_hits": history_hits, + "notification_hits": notify_count, + "config_references": sorted(config_refs[package_id]), + } + ) + + order = { + "never": 0, + "configured-only": 1, + "stale-rare": 2, + "stale-frequent": 3, + "recent": 4, + } + report.sort( + key=lambda row: ( + order[row["status"]], + row["dms_launches"], + row["last_dms_launch"] or "", + row["package"].casefold(), + ) + ) + return { + "host": args.host, + "user": args.user, + "window_days": args.days, + "dms_usage_file": str(dms_state), + "gui_package_count": len(gui_packages), + "unmatched_profile_entry_count": skipped_profile_entries, + "applications": report if args.all else [row for row in report if row["status"] != "recent"], + } + + +def print_report(result: dict[str, Any]) -> None: + rows = result["applications"] + print( + f"{result['host']} GUI package usage: {len(rows)} shown / " + f"{result['gui_package_count']} detected " + f"({result['window_days']}-day DMS window)" + ) + print("STATUS LAUNCHES LAST DMS LAUNCH HIST NOTIF CONFIG PACKAGE / APPLICATIONS") + for row in rows: + last = row["last_dms_launch"][:10] if row["last_dms_launch"] else "never" + apps = ", ".join(row["applications"]) + print( + f"{row['status']:<16} {row['dms_launches']:>8} {last:<17} " + f"{row['shell_history_hits']:>5} {row['notification_hits']:>5} " + f"{len(row['config_references']):>6} {row['package']} / {apps}" + ) + print("\nDMS launches are authoritative only for apps opened through the DMS launcher.") + print("HIST, NOTIF, and CONFIG are secondary evidence; inspect them before removing a package.") + + +def main() -> None: + args = parse_args() + result = analyze(args) + if args.json: + json.dump(result, sys.stdout, indent=2) + print() + else: + print_report(result) + + +if __name__ == "__main__": + main() diff --git a/servers/ui-mode/home.nix b/servers/ui-mode/home.nix index 6eb59c6..84785b8 100644 --- a/servers/ui-mode/home.nix +++ b/servers/ui-mode/home.nix @@ -23,6 +23,7 @@ }: let system = pkgs.stdenv.hostPlatform.system; + delta-bin = pkgs.callPackage ../../pkgs/delta-bin/package.nix { }; dmsShell = dms.packages.${system}.dms-shell.overrideAttrs (oldAttrs: { postPatch = (oldAttrs.postPatch or "") + '' if [[ -f quickshell/Services/PopoutService.qml ]]; then @@ -142,6 +143,8 @@ in stateVersion = "25.11"; packages = with pkgs; [ + delta-bin + kicad protontricks waypipe cinny @@ -157,7 +160,7 @@ in wine codexbar.packages.${pkgs.system}.default codex - jellyfin-desktop + #jellyfin-desktop (kdePackages.qt6ct.overrideAttrs (oldAttrs: { patches = (oldAttrs.patches or [ ]) ++ [ ../../pkgs/qt6ct-0.11.patch ]; name = "qt6ct-kde"; @@ -176,12 +179,12 @@ in nixd buck2 (rusic.packages.${system}.default) - affine + #affine voxtype-vulkan #affinity-nix.packages.x86_64-linux.v3 biome bun - lenovo-legion + #lenovo-legion itch filezilla nicotine-plus @@ -203,11 +206,11 @@ in libxkbfile # dioxus-cli - cosmic-files - cosmic-player - cosmic-screenshot - cosmic-applibrary - cosmic-ext-calculator + #cosmic-files + #cosmic-player + #cosmic-screenshot + #cosmic-applibrary + #cosmic-ext-calculator cosmic-icons examine @@ -227,7 +230,7 @@ in usbimager #bitwarden-desktop #metasploit - lenovo-legion + #lenovo-legion burpsuite zap kubernetes-helm @@ -268,17 +271,16 @@ in lf rawtherapee syncthingtray - anki-bin + #anki-bin xournalpp simple-scan godot_4 #rar - wootility + #wootility #surrealdb pico-sdk elf2uf2-rs obsidian - betaflight-configurator home-manager #glxinfo pciutils @@ -318,8 +320,8 @@ in datovka nwg-displays wireguard-tools - mongodb-compass - unstable.mongodb-tools + #mongodb-compass + #unstable.mongodb-tools #hashcat tldr #dunst @@ -430,7 +432,7 @@ in graphviz #blender - warpinator + #warpinator awatcher tigervnc