From c9238657219966fa152a0830661f7a28cab364cb Mon Sep 17 00:00:00 2001 From: Cody LeCates Date: Sat, 7 Nov 2026 13:29:30 -0700 Subject: [PATCH 1/2] Integrate local inventory and diagnostics scans --- .gitattributes | 1 + .github/workflows/linux.yml | 19 + .gitignore | 1 + AppLens.py | 12 +- PRIVACY.md | 5 +- applens_linux.py | 645 ++++++++++++++++++ docs/AppLens-Scanner.md | 15 +- src/AppLens.Backend/AppLens.Backend.csproj | 2 + src/AppLens.Backend/CliToolPresence.cs | 22 + src/AppLens.Backend/DiagnosticsService.cs | 124 ++++ src/AppLens.Backend/InventoryCollector.cs | 1 + src/AppLens.Backend/InventoryData.cs | 3 + src/AppLens.Backend/InventorySnapshot.cs | 12 +- src/AppLens.Backend/MachineCollector.cs | 1 + src/AppLens.Backend/Models.cs | 2 + src/AppLens.Backend/ReportWriter.cs | 20 +- src/AppLens.Backend/WindowsDiagnostics.ps1 | 332 +++++++++ src/AppLens.Desktop/MainWindow.xaml | 1 + src/AppLens.Desktop/MainWindow.xaml.cs | 36 +- .../AppLens.Backend.Tests/DiagnosticsTests.cs | 76 +++ .../WorkflowIntakeTests.cs | 2 +- tests/linux/fixtures/baseline.json | 12 + tests/linux/test_scans.py | 150 ++++ tools/AppLens.Smoke/Program.cs | 16 +- 24 files changed, 1474 insertions(+), 36 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/linux.yml create mode 100644 applens_linux.py create mode 100644 src/AppLens.Backend/CliToolPresence.cs create mode 100644 src/AppLens.Backend/DiagnosticsService.cs create mode 100644 src/AppLens.Backend/WindowsDiagnostics.ps1 create mode 100644 tests/AppLens.Backend.Tests/DiagnosticsTests.cs create mode 100644 tests/linux/fixtures/baseline.json create mode 100644 tests/linux/test_scans.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..85fc53e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +Run-AppLens.sh text eol=lf diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 0000000..5afcad1 --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,19 @@ +name: Linux CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + scan: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Test inventory, diagnostics, and redaction + run: python3 -B -m unittest discover -s tests/linux -v diff --git a/.gitignore b/.gitignore index 04e500c..233e11c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ TestResults/ *.user *.suo desktop.ini +__pycache__/ # Local report exports /AppLens-*.md diff --git a/AppLens.py b/AppLens.py index d0e3595..91da4d9 100644 --- a/AppLens.py +++ b/AppLens.py @@ -1,9 +1,7 @@ #!/usr/bin/env python3 """ -AppLens app inventory scanner for macOS and Linux. - -Read-only. Writes a categorized plain-text report to the user's Desktop when -available, or to the home directory when no Desktop folder exists. +Local AppLens scanner. Linux emits redacted JSON and supports opt-in diagnostics. +macOS retains its plain-text inventory. """ from __future__ import annotations @@ -304,6 +302,12 @@ def build_report() -> str: def main() -> int: + if platform.system() == "Linux": + sys.dont_write_bytecode = True + from applens_linux import main as linux_main + + return linux_main(TOOL_CHECKS, is_runtime) + report = build_report() path = output_path(f"AppLens_Results_{socket.gethostname()}.txt") path.write_text(report, encoding="utf-8") diff --git a/PRIVACY.md b/PRIVACY.md index c658f08..6b815c9 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -3,11 +3,14 @@ Installed scans PC specifications and installed applications locally. It collects OS/device details, CPU/GPU names, memory/disk readings, app identities and locations, computer name, and Windows username. Optional workflow notes are user-supplied. +Running diagnostics also collects local process and startup names, system status, +and hardware and runtime readings. There are no accounts, telemetry, automatic uploads, or background monitoring. Reports contain the full capture, notes, and action history, including rows hidden by filters. Known computer/user identifiers and profile paths are redacted by -default, with explicit raw-detail opt-in. App names and notes may still be sensitive. +default, with explicit raw-detail opt-in. App names, diagnostic readings, and +notes may still be sensitive. Download report saves a readable file to the chosen location. Send report reviews a frozen file before handing it to Windows sharing; the user selects the app and diff --git a/applens_linux.py b/applens_linux.py new file mode 100644 index 0000000..4f9cf52 --- /dev/null +++ b/applens_linux.py @@ -0,0 +1,645 @@ +"""Local Linux inventory and opt-in diagnostics. Probe output stays local.""" + +from __future__ import annotations + +import argparse +import contextlib +import getpass +import json +import os +import platform +import re +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import time +from datetime import datetime, timezone +from pathlib import Path + + +class ProbeError(Exception): + def __init__(self, state): + self.state = state + + +def command(*args, timeout=4, accepted=(0,)): + if not shutil.which(args[0]): + raise ProbeError("Unavailable") + env = {**os.environ, "LC_ALL": "C", "HOMEBREW_NO_AUTO_UPDATE": "1", + "SYSTEMD_PAGER": "cat", "PYTHONDONTWRITEBYTECODE": "1"} + # A private process group lets Ctrl+C and timeouts stop only the probe tree. + with subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=env, text=True, errors="replace", + start_new_session=True) as child: + try: + output, error = child.communicate(timeout=timeout) + except (subprocess.TimeoutExpired, KeyboardInterrupt) as exc: + with contextlib.suppress(ProcessLookupError): + os.killpg(child.pid, signal.SIGKILL) + child.communicate() + if isinstance(exc, KeyboardInterrupt): + raise + raise ProbeError("TimedOut") from exc + if child.returncode not in accepted: + if re.search(r"permission denied|operation not permitted|access denied|must be root|not root", + error + output, re.I): + raise ProbeError("PermissionLimited") + if re.search(r"not been booted|failed to connect to bus|no such file or directory", + error, re.I): + raise ProbeError("Unavailable") + raise ProbeError("Failed") + return output + + +def capture(name, source, read): + started = time.monotonic() + try: + rows = read() + state = "Succeeded" if rows else "Empty" + row_states = {row.get("State") for row in rows if isinstance(row, dict)} + failed_states = row_states & {"Unavailable", "PermissionLimited", "Partial", "TimedOut", "Failed"} + if failed_states: + state = (next(iter(failed_states)) if len(row_states) == 1 else "Partial") + except ProbeError as exc: + state, rows = exc.state, [] + except PermissionError: + state, rows = "PermissionLimited", [] + except FileNotFoundError: + state, rows = "Unavailable", [] + except (OSError, ValueError, KeyError, IndexError, AttributeError): + state, rows = "Failed", [] + return {"Name": name, "Source": source, "State": state, "Rows": rows, + "DurationSeconds": round(time.monotonic() - started, 3)} + + +def rows_of(text, **fields): + return [{key: parts[index] for key, index in fields.items()} for line in text.splitlines() + if len(parts := line.split()) > max(fields.values())] + + +def read_text(path): + return Path(path).read_text(encoding="utf-8", errors="replace") + + +def properties(path, sep="="): + return dict((key.strip(), value.strip().strip('"')) for line in read_text(path).splitlines() + if sep in line for key, value in [line.split(sep, 1)]) + + +def launcher_entries(roots, startup=False): + seen, rows = set(), [] + for root in roots: + try: + entries = sorted(root.iterdir()) + except FileNotFoundError: + continue + for path in entries: + if path.suffix != ".desktop" or path.name in seen: + continue + seen.add(path.name) + # Later sections can contain action names and commands. + sections = re.split(r"^\[([^\]]+)\]\s*$", read_text(path), flags=re.M) + if "Desktop Entry" not in sections[1::2]: + continue + content = sections[sections.index("Desktop Entry") + 1] + values = dict(line.split("=", 1) for line in content.splitlines() + if "=" in line and not line.startswith("#")) + hidden = values.get("Hidden", "").lower() == "true" + if startup: + rows.append({"Name": values.get("Name", path.stem), "State": "Disabled" if hidden or + values.get("X-GNOME-Autostart-enabled", "").lower() == "false" + else "DesktopConditional" if ("OnlyShowIn" in values or "NotShowIn" in values) + else "Configured"}) + elif (not hidden and values.get("NoDisplay", "").lower() != "true" + and values.get("Type") == "Application" and values.get("Name")): + rows.append({"Name": values["Name"].replace(r"\s", " "), "Source": "Desktop entry", + "Version": "", "Kind": "Desktop"}) + return rows + + +PACKAGE_PROBES = ( + ("dpkg", ("dpkg-query", "-W", "-f=${db:Status-Status}\t${binary:Package}\t${Version}\n")), + ("rpm", ("rpm", "-qa", "--qf", "%{NAME}.%{ARCH}\t%{VERSION}-%{RELEASE}\n")), + ("pacman", ("pacman", "-Q")), + ("apk", ("apk", "info", "-v")), + ("xbps", ("xbps-query", "-l")), + ("Flatpak", ("flatpak", "list", "--columns=application,version")), + ("Snap", ("snap", "list")), + ("Homebrew formula", ("brew", "list", "--formula", "--versions")), + ("Homebrew cask", ("brew", "list", "--cask", "--versions")), +) + + +def package_rows(source, output): + packages = {} + for line in output.splitlines(): + if not line.strip(): + continue + parts = line.strip().split("\t") if "\t" in line else line.split() + if source in {"dpkg", "xbps"}: + if len(parts) < 2 or parts[0] != ("installed" if source == "dpkg" else "ii"): + continue + parts = parts[1:3] if source == "dpkg" else [parts[1]] + if source == "Snap" and (parts[0] == "Name" or line.startswith("No snaps")): + continue + if source in {"apk", "xbps"}: + match = re.match(r"^(.+?)-(\d[^\s]*)$", parts[0]) + if match: + parts = list(match.groups()) + name, version = parts[0], " ".join(parts[1:] if source.startswith("Homebrew") else parts[1:2]) + packages[(name, version)] = {"Name": name, "Version": version, "Source": source, "Kind": "Package"} + return list(packages.values()) + + +def low_disk(free, total): + return free * 10 < total if total > 0 else None + + +def mount_rows(): + rows = [] + for line in read_text("/proc/self/mountinfo").splitlines(): + left, right = line.split(" - ", 1) + major, fstype = left.split()[2], right.split()[0] + mount = re.sub(r"\\([0-7]{3})", lambda match: chr(int(match[1], 8)), left.split()[4]) + if (mount != "/" and major.startswith("0:") and fstype not in {"btrfs", "zfs", "fuseblk", "tmpfs"}) or ( + fstype == "tmpfs" and mount != "/dev/shm"): + continue + # Never stat mounted network shares. A report contains no mount paths. + if fstype in {"nfs", "nfs4", "cifs", "smb3", "9p", "ceph", "fuse.sshfs"}: + continue + try: + stats = os.statvfs(mount) + except OSError: + rows.append({"Volume": f"Mount {len(rows) + 1}", "FileSystem": fstype, "State": "Unavailable"}) + continue + size, free = stats.f_blocks * stats.f_frsize, stats.f_bavail * stats.f_frsize + if size: + rows.append({"Volume": f"Mount {len(rows) + 1}", "FileSystem": fstype, + "TotalBytes": size, "FreeBytes": free, "UsedBytes": size - free, + "UsedPercent": round(100 * (size - free) / size, 1), + "LowSpace": low_disk(free, size)}) + return rows + + +def gpu_rows(): + rows = [] + drm = [(card / "device").resolve() for card in sorted(Path("/sys/class/drm").glob("card[0-9]*")) + if re.fullmatch(r"card\d+", card.name)] + pci = [device.resolve() for device in sorted(Path("/sys/bus/pci/devices").glob("*")) + if (device / "class").is_file() and read_text(device / "class").strip().startswith("0x03")] + for device in dict.fromkeys([*drm, *pci]): + if not (device / "vendor").exists(): + continue + rows.append({"VendorId": read_text(device / "vendor").strip(), + "DeviceId": read_text(device / "device").strip(), + "Backend": (device / "driver").resolve().name if (device / "driver").exists() else None, + "ReportedVramBytes": optional_int(device / "mem_info_vram_total"), + "ReportedGttApertureBytes": optional_int(device / "mem_info_gtt_total"), + "SharedOrReservedBytes": None, + "VerifiedUsableInferenceBytes": None}) + if not rows and Path("/dev/dxg").exists(): + rows.append({"Name": "Virtual GPU exposed to Linux", "Backend": "dxgkrnl", + "VerifiedUsableInferenceBytes": None}) + return rows + + +def optional_int(path): + try: + return int(read_text(path)) + except FileNotFoundError: + return None + + +def inventory(tool_checks, is_runtime): + statuses = [] + + def read(name, source, action): + result = capture(name, source, action) + statuses.append({key: value for key, value in result.items() if key != "Rows"}) + return result["Rows"] + + os_info = read("Operating system", "os-release", lambda: [properties("/etc/os-release")]) + cpu = read("Processors", "procfs", lambda: sorted(set( + match.strip() for match in re.findall(r"^(?:model name|Hardware)\s*:\s*(.*)$", + read_text("/proc/cpuinfo"), re.M)))) + ram = read("Memory", "procfs", lambda: [int(re.search(rf"^{key}:\s+(\d+)", read_text( + "/proc/meminfo"), re.M).group(1)) * 1024 for key in ("MemTotal", "MemAvailable")]) + uptime = read("Uptime", "procfs", lambda: [float(read_text("/proc/uptime").split()[0])]) + graphics = read("Graphics", "DRM sysfs", gpu_rows) + disks = read("Mounts", "mountinfo / statvfs", mount_rows) + data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) + data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":") + apps = read("Desktop applications", "XDG", lambda: launcher_entries( + [root / "applications" for root in [data_home, *map(Path, filter(None, data_dirs))]])) + packages = [row for source, args in PACKAGE_PROBES + for row in read(source, source, lambda: package_rows(source, command(*args)))] + tools = [{"Name": label, "Command": tool, "State": "Succeeded" if shutil.which(tool) else "Unavailable"} + for label, tool, _ in tool_checks] + used = (ram[0] - ram[1]) / ram[0] if ram and ram[0] and 0 <= ram[1] <= ram[0] else None + machine = {"ComputerName": "[computer]", "UserName": "[user]", "OSDescription": + os_info[0].get("PRETTY_NAME", "Linux") if os_info else "Linux", + "OSArchitecture": platform.machine(), "Processors": cpu, "Graphics": [ + row["Name"] if "Name" in row else f"{row['VendorId']}:{row['DeviceId']}" for row in graphics], + "TotalMemoryBytes": ram[0] if ram else None, "FreeMemoryBytes": ram[1] if ram else None, + "MemoryUsedPercent": round(100 * used, 1) if used is not None else None, + "HighMemoryUse": used >= .85 if used is not None else None, + "UptimeSeconds": uptime[0] if uptime else None, "Disks": disks} + items = {"DesktopApplications": sorted(apps, key=lambda row: row["Name"].casefold()), + "Packages": packages, "RuntimesAndFrameworks": [row for row in [*apps, *packages] + if is_runtime(row["Name"])], + "CliTools": tools, "StoreApplications": [], "SystemComponents": []} + return machine, items, statuses + + +def startup(): + home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + system = os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":") + return launcher_entries([root / "autostart" for root in [home, *map(Path, filter(None, system))]], + startup=True) + + +def directory_summary(label, root, repos=False, pattern=None): + """Bound the walk and omit filenames, crossing mount points or following links.""" + if not root.exists() or root.is_symlink() or root.is_mount(): + return [] + key = "RepositoryCount" if repos else "ModelCount" if pattern else "Bytes" + deadline = time.monotonic() + 1.5 + filesystem = root.stat().st_dev + total = visited = 0 + state = "Succeeded" + + def failed(error): + nonlocal state + state = "PermissionLimited" if isinstance(error, PermissionError) else "Partial" + + for path, dirs, files in os.walk(root, onerror=failed): + if repos and (".git" in dirs or ".git" in files): + total += 1 + dirs.clear() + if repos and Path(path).relative_to(root).parts[4:]: + dirs.clear() + for name in dirs[:]: + entry = Path(path, name) + try: + if entry.is_symlink() or entry.stat().st_dev != filesystem or entry.is_mount(): + dirs.remove(name) + except OSError as exc: + failed(exc) + dirs.remove(name) + for name in (dirs if repos else [*dirs, *files]): + if visited >= 50000 or time.monotonic() >= deadline: + return [{"Location": label, key: total, "State": "Partial"}] + visited += 1 + if not repos: + try: + entry = Path(path, name) + if not entry.is_symlink() and entry.is_file(): + if not pattern: + total += entry.stat().st_size + elif entry.match(pattern): + total += 1 + except OSError as exc: + failed(exc) + return [{"Location": label, key: total, "State": state}] + + +def storage(): + home = Path.home() + return [row for label, root in (("Ollama", home / ".ollama"), ("Package cache", home / ".cache"), + ("Docker rootless", home / ".local/share/docker"), + ("Docker system", Path("/var/lib/docker"))) + for row in directory_summary(label, root)] + + +def repositories(): + return [row for name in ("Projects", "src", "Desktop", "Documents") + for row in directory_summary(name, Path.home() / name, repos=True)] + + +def battery(root=Path("/sys/class/power_supply")): + if not root.is_dir(): + raise ProbeError("Unavailable") + rows = [] + for device in sorted(root.iterdir()): + if read_text(device / "type").strip() != "Battery": + continue + unit = "energy" if (device / "energy_full_design").exists() else "charge" + full = optional_int(device / f"{unit}_full") + design = optional_int(device / f"{unit}_full_design") + cycles = optional_int(device / "cycle_count") + rows.append({"FullCapacity": full, "DesignCapacity": design, + "CapacityUnit": "µWh" if unit == "energy" else "µAh", + "HealthPercent": round(100 * full / design, 1) if full is not None and design else None, + "Cycles": cycles, "CycleCountState": "Succeeded" if cycles is not None else "Unavailable", + "State": "Partial" if None in (full, design) else "Succeeded"}) + if not rows: + raise ProbeError("Inapplicable") + return rows + + +def block_devices(): + return json.loads(command("lsblk", "--json", "--list", "--output", + "NAME,TYPE,FSTYPE"))["blockdevices"] + + +def smart_health(): + devices = [row for row in block_devices() if row["type"] == "disk" + and re.fullmatch(r"(?:sd[a-z]+|nvme\d+n\d+|hd[a-z]+|mmcblk\d+)", row["name"])] + if not devices: + raise ProbeError("Inapplicable") + rows = [] + for device in devices: + name = device["name"] + node = "/dev/" + name + row = {"Device": f"Disk {len(rows) + 1}", "Type": "NVMe" if name.startswith("nvme") else "ATA"} + try: + if name.startswith("nvme") and not shutil.which("smartctl"): + info = json.loads(command("nvme", "smart-log", node, "--output-format=json")) + warning = info.get("critical_warning") + row.update(State="Succeeded" if warning is not None else "Partial", CriticalWarning=warning, + Passed=warning == 0 if warning is not None else None, + TemperatureKelvin=info.get("temperature"), MediaErrors=info.get("media_errors"), + PercentageUsed=info.get("percent_used")) + rows.append(row) + continue + info = json.loads(command("smartctl", "-j", "-n", "standby", "-H", "-A", node, + accepted=tuple(range(256)))) + messages = " ".join(m.get("string", "") for m in info.get("smartctl", {}).get("messages", [])) + if re.search(r"permission|operation not permitted", messages, re.I): + raise ProbeError("PermissionLimited") + passed = info.get("smart_status", {}).get("passed") + nvme = info.get("nvme_smart_health_information_log", {}) + row.update(State="Succeeded" if passed is not None else "Unavailable", Passed=passed, + TemperatureC=info.get("temperature", {}).get("current"), + PercentageUsed=nvme.get("percentage_used"), MediaErrors=nvme.get("media_errors")) + except ProbeError as exc: + row["State"] = exc.state + except ValueError: + row["State"] = "Failed" + rows.append(row) + return rows + + +def encryption(): + devices = block_devices() + filesystem_metadata = any(row.get("fstype") for row in devices) + return [{"DetectedLuksContainers": sum(row.get("fstype") == "crypto_LUKS" for row in devices) + if filesystem_metadata else None, + "ActiveEncryptedMappings": sum(row["type"] == "crypt" for row in devices), + "State": "Succeeded" if filesystem_metadata else "Partial"}] + + +def network(): + return [{"InterfaceCount": len(os.listdir("/sys/class/net")), + "UpInterfaceCount": sum(read_text(path).strip() == "up" + for path in Path("/sys/class/net").glob("*/operstate"))}] + + +def listeners(): + return [{"Transport": "TCP" if "tcp" in path.name else "UDP", + "IPVersion": 6 if path.name.endswith("6") else 4, + "Count": sum("udp" in path.name or line.split()[3] == "0A" + for line in read_text(path).splitlines()[1:] if len(line.split()) >= 4)} + for path in Path("/proc/net").iterdir() if path.name in {"tcp", "tcp6", "udp", "udp6"}] + + +def firewall(): + if shutil.which("nft"): + rules = json.loads(command("nft", "-j", "list", "ruleset"))["nftables"] + return [{"RuleCount": sum("rule" in item for item in rules), + "BaseChainPolicies": sorted(item["chain"]["policy"] for item in rules + if "chain" in item and "policy" in item["chain"])}] + return [{"RuleCount": sum(line.startswith("-A ") for line in command("iptables", "-S").splitlines())}] + + +def cron(): + sources = [Path("/etc/crontab"), *Path("/etc/cron.d").glob("*")] + entries = [] + for path in sources: + if path.is_file(): + entries.extend(read_text(path).splitlines()) + if shutil.which("crontab"): + entries.extend(command("crontab", "-l", accepted=(0, 1)).splitlines()) + periodic = sum(path.is_file() and os.access(path, os.X_OK) + for interval in ("hourly", "daily", "weekly", "monthly") + for path in Path(f"/etc/cron.{interval}").glob("*")) + return [{"JobCount": sum(bool(re.match(r"^\s*(?:@\w+\s|[\d*/])", line)) for line in entries), + "PeriodicScriptCount": periodic}] + + +def systemd(scope, *args): + return command("systemctl", f"--{scope}", "--no-pager", "--no-legend", *args) + + +def services(scope): + units = rows_of(systemd(scope, "list-units", "--type=service", "--all", "--plain"), + Load=1, Active=2, SubState=3) + counts = {} + for row in units: + key = tuple(row.values()) + counts[key] = counts.get(key, 0) + 1 + return [{"Load": load, "Active": active, "SubState": sub, "Count": count} + for (load, active, sub), count in sorted(counts.items())] + + +def backup_outcomes(scope): + configured = rows_of(systemd(scope, "list-unit-files", "--type=service"), Unit=0) + units = [row["Unit"] for row in configured if re.search( + r"(?:backup|restic|borg|kopia|snapper|timeshift)", row["Unit"], re.I) + and re.fullmatch(r"[\w.@-]+\.service", row["Unit"]) and "@." not in row["Unit"]] + rows = [] + for index, unit in enumerate(units[:8], 1): + try: + details = dict(line.split("=", 1) for line in systemd(scope, "show", + "--property=Result,ExecMainExitTimestamp,ExecMainStatus", "--", unit).splitlines() if "=" in line) + except ProbeError as exc: + rows.append({"Job": f"Backup service {index}", "State": exc.state}) + continue + observed = bool(details.get("ExecMainExitTimestamp")) + rows.append({"Job": f"Backup service {index}", "Observed": observed, + "LastExitAt": details.get("ExecMainExitTimestamp") if observed else None, + "Result": details.get("Result") if observed else None, + "ExitStatus": details.get("ExecMainStatus") if observed else None}) + if len(units) > 8: + rows.append({"State": "Partial"}) + return rows + + +def updates(): + output = command("apt", "list", "--upgradable", timeout=6) + candidates = [line for line in output.splitlines() if "[upgradable from:" in line] + return [{"CachedCandidateCount": len(candidates), + "SecurityCandidateCount": sum("-security" in line.split()[0] for line in candidates), + "MetadataRefreshed": False}] + + +def reboot(): + if not Path("/etc/debian_version").exists(): + raise ProbeError("Unavailable") + return [{"RebootRequired": Path("/run/reboot-required").exists()}] + + +def docker_usage(): + sockets = [Path(f"/run/user/{os.getuid()}/docker.sock"), Path("/var/run/docker.sock")] + endpoint = next((candidate for candidate in sockets if candidate.is_socket()), None) + if endpoint is None: + raise ProbeError("Unavailable") + return [json.loads(line) for line in command( + "docker", "--host", f"unix://{endpoint}", "system", "df", "--format", + '{"Type":{{json .Type}},"Total":{{json .TotalCount}},"Active":{{json .Active}},"Size":{{json .Size}}}' + ).splitlines() if line.strip()] + + +LLAMA_DEVICE = re.compile(r"^\s*((?:CUDA|Vulkan|ROCm|HIP|SYCL|Metal|CPU)[\w.-]*):\s*(.+)$", re.I) + + +def llama_devices(): + binary = next((name for name in ("llama-cli", "llama-server") if shutil.which(name)), None) + if not binary: + raise ProbeError("Unavailable") + return [{"Device": match[1], "Description": match[2], + "Backend": re.match(r"[A-Za-z]+", match[1])[0], + "VerifiedUsableInferenceBytes": None} + for line in command(binary, "--list-devices").splitlines() if (match := LLAMA_DEVICE.match(line))] + + +TORCH_PROBE = ( + "import importlib.util, json; " + "s=importlib.util.find_spec('torch'); " + "exec(\"import torch\\na=torch.cuda.is_available()\\n" + "print(json.dumps([{'Version':torch.__version__,'Backend':'ROCm' if torch.version.hip else " + "'CUDA' if torch.version.cuda else 'CPU','DeviceCount':torch.cuda.device_count() if a else 0," + "'VerifiedUsableInferenceBytes':None}]))\") if s else print('[]')" +) + + +def diagnostics(machine): + probes = [ + ("Processes", "ps", lambda: rows_of(command("ps", "-eo", "pid=,rss=,time=,comm=", "--sort=-rss"), + Pid=0, WorkingSetKiB=1, CpuTime=2, Name=3)[:15]), + ("Startup", "XDG autostart", startup), + ("Storage", "Selected folders", storage), + ("Git placement", "Selected folders", repositories), + ("Docker storage", "Local Docker socket", docker_usage), + ("AI tools", "PATH", lambda: [{"Tool": name} for name in + ("ollama", "llama-cli", "llama-server", "lmstudio", "nvcc") + if shutil.which(name)]), + ("Local models", "Selected model stores", lambda: [ + *directory_summary("Ollama manifests", Path.home() / ".ollama/models/manifests", pattern="*"), + *directory_summary("LM Studio GGUF", Path.home() / ".lmstudio/models", pattern="*.gguf")]), + ("Graphics memory", "DRM sysfs", gpu_rows), + ("Graphics memory", "nvidia-smi", lambda: [ + {"Device": cells[0], "Backend": "NVIDIA driver", "ReportedVramMiB": cells[1], + "ReportedFreeVramMiB": cells[2], "VerifiedUsableInferenceBytes": None} + for line in command("nvidia-smi", "--query-gpu=name,memory.total,memory.free", + "--format=csv,noheader,nounits").splitlines() + if len(cells := [part.strip() for part in line.split(",")]) == 3]), + ("llama.cpp", "Local device enumeration", llama_devices), + ("CUDA toolkit", "nvcc", lambda: [ + {"Release": match[1]} for match in re.finditer(r"release ([\d.]+)", command("nvcc", "--version"))]), + ("PyTorch", "Local Python runtime", lambda: json.loads(command( + sys.executable, "-I", "-B", "-c", TORCH_PROBE, timeout=8))), + ("Disk health", "SMART / NVMe", smart_health), + ("Battery", "Power supply sysfs", battery), + ("Disk encryption", "lsblk", encryption), + ("Network", "Network sysfs", network), + ("DNS", "resolv.conf", lambda: [{"ResolverCount": sum( + line.strip().startswith("nameserver ") for line in read_text("/etc/resolv.conf").splitlines())}]), + ("Listeners", "procfs", listeners), + ("Firewall", "nftables / iptables", firewall), + ("Scheduled jobs", "cron", cron), + ("Security updates", "Cached APT metadata", updates), + ("Reboot", "Debian reboot marker", reboot), + ] + for scope in ("system", "user"): + probes.extend([ + ("Services", f"systemd {scope}", lambda scope=scope: services(scope)), + ("Scheduled jobs", f"systemd {scope}", lambda scope=scope: [ + {"TimerCount": sum(".timer" in line for line in systemd(scope, "list-timers", "--all").splitlines())}]), + ("Backup outcomes", f"systemd {scope}", lambda scope=scope: backup_outcomes(scope)), + ]) + results = [capture(*probe) for probe in probes] + results.append({"Name": "WSL", "Source": "Windows subsystem", "State": "Inapplicable", + "Rows": [], "DurationSeconds": 0}) + findings = [{"Category": "Storage", "Observation": "Volume is below the free space threshold."} + for disk in machine["Disks"] if disk.get("LowSpace")] + if machine.get("HighMemoryUse"): + findings.append({"Category": "Memory", "Observation": "At least 85% of RAM was in use at capture."}) + findings.extend({"Category": "Disk health", "Observation": "A disk reported a SMART health failure."} + for result in results if result["Name"] == "Disk health" + for row in result["Rows"] if row.get("Passed") is False) + return results + [capture("Findings", "Local rules", lambda: findings)] + + +def scan(tool_checks, is_runtime, diagnostic=False): + start = time.monotonic() + timestamp = datetime.now(timezone.utc).isoformat() + machine, items, statuses = inventory(tool_checks, is_runtime) + details = diagnostics(machine) if diagnostic else [] + covered = {"Succeeded", "Empty", "Inapplicable"} + report = {"SchemaVersion": "3.0", "Mode": "Diagnostics" if diagnostic else "Inventory", "Platform": "Linux", + "GeneratedAt": timestamp, "Machine": machine, "Inventory": items, + "ProbeStatuses": statuses, "Diagnostics": details, "Actions": [], + "DurationSeconds": round(time.monotonic() - start, 3), + "IsPartial": any(row["State"] not in covered and not ( + row["State"] == "Unavailable" and row["Name"] in {name for name, _ in PACKAGE_PROBES}) + for row in [*statuses, *details])} + return redact(report) + + +def redact(report, *, user=None, host=None, home=None): + user = getpass.getuser() if user is None else user + host = socket.gethostname() if host is None else host + home = str(Path.home()) if home is None else home + replacements = {"profile": "%USERPROFILE%", "computer": "[computer]", "user": "[user]"} + identifiers = [(field, value) for field, value in (("profile", home), ("computer", host), ("user", user)) + if value] + if not identifiers: + return report + pattern = re.compile("|".join( + rf"(?P<{field}>{re.escape(value)})" if field == "profile" else + rf"(?{re.escape(value)})(?![\w-])" for field, value in identifiers), re.I) + # Redact observations without rewriting schema values such as Mode and State. + def clean(value): + if isinstance(value, str): + return pattern.sub(lambda match: replacements[match.lastgroup], value) + if isinstance(value, list): + return [clean(item) for item in value] + if isinstance(value, dict): + return {key: item if key in {"State", "Source", "Kind"} else clean(item) + for key, item in value.items()} + return value + report["Machine"] = clean(report["Machine"]) + report["Inventory"] = clean(report["Inventory"]) + report["Diagnostics"] = [{**probe, "Rows": clean(probe["Rows"])} for probe in report["Diagnostics"]] + return report + + +def main(tool_checks, is_runtime): + parser = argparse.ArgumentParser(description="Scan local inventory and optionally run diagnostics.") + parser.add_argument("--diagnostics", action="store_true", help="Run the slower local diagnostics scan.") + parser.add_argument("--output", type=Path, help="Save the redacted JSON to this path. Default is stdout.") + args = parser.parse_args() + try: + report = json.dumps(scan(tool_checks, is_runtime, args.diagnostics), indent=2) + "\n" + if args.output: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=args.output.parent, + delete=False) as output: + temp = Path(output.name) + try: + output.write(report) + output.close() + temp.replace(args.output) + finally: + temp.unlink(missing_ok=True) + else: + sys.stdout.write(report) + return 0 + except KeyboardInterrupt: + return 130 + except OSError: + print("Could not write the report.", file=sys.stderr) + return 1 diff --git a/docs/AppLens-Scanner.md b/docs/AppLens-Scanner.md index fcc26ac..ec18f94 100644 --- a/docs/AppLens-Scanner.md +++ b/docs/AppLens-Scanner.md @@ -2,7 +2,9 @@ The desktop client uses `AuditService`, `MachineCollector`, and `InventoryCollector`. `InventorySnapshot` combines device readings, full app inventory, coverage, action -history, and optional client context. Schema 2.1 includes a derived workflow summary. +history, and optional client context. **Run diagnostics** adds local diagnostic +readings and findings to the inventory. Windows desktop JSON and the Linux CLI +use schema 3.0. The `Mode` field identifies the scan. Unavailable readings remain unknown; installed software does not establish access, licensing, or workload performance. @@ -16,12 +18,13 @@ identifier tokens and profile paths. Client notes are unverified, escaped text. - Windows: `Run-AppLens.bat` or `powershell -File AppLens.ps1`. Captures hardware/OS, uptime, RAM, fixed disks, and categorized apps. Disk free below 10% and RAM use at least 85% produce snapshot flags. -- macOS/Linux: `bash Run-AppLens.sh` or `python3 AppLens.py`. - Captures app and developer-tool inventory, with less device detail than Windows. +- Linux: `sh Run-AppLens.sh` or `python3 AppLens.py`. Prints redacted inventory + JSON. Add `--diagnostics` for local diagnostics and `--output PATH` to save it. +- macOS: `sh Run-AppLens.sh` or `python3 AppLens.py`. Captures app and developer-tool inventory. Windows writes `AppLens_Results__.txt` to Desktop. `-OutputPath` selects another path; existing files are rejected. -Python writes `AppLens_Results_.txt`, replacing that file on rerun. +On macOS, Python writes `AppLens_Results_.txt`, replacing it on rerun. It uses `APPLENS_OUTPUT_DIR` or Desktop, falling back to the home directory if -the selected directory is absent. Script reports retain local identifiers; -desktop-app redaction does not apply to them. +the selected directory is absent. Windows and macOS text scripts retain local +identifiers. diff --git a/src/AppLens.Backend/AppLens.Backend.csproj b/src/AppLens.Backend/AppLens.Backend.csproj index ee697c9..38a9b9a 100644 --- a/src/AppLens.Backend/AppLens.Backend.csproj +++ b/src/AppLens.Backend/AppLens.Backend.csproj @@ -8,6 +8,8 @@ + + diff --git a/src/AppLens.Backend/CliToolPresence.cs b/src/AppLens.Backend/CliToolPresence.cs new file mode 100644 index 0000000..3b437c4 --- /dev/null +++ b/src/AppLens.Backend/CliToolPresence.cs @@ -0,0 +1,22 @@ +namespace AppLens.Backend; + +public sealed record CliToolPresence(string Name, string Command, string State) +{ + private static readonly string[] Commands = + ["git", "python", "node", "npm", "docker", "ollama", "pwsh", "winget", "choco", "scoop", "wsl", "dotnet", "java", "go", "rustc", "uv"]; + private static readonly string[] Extensions = [".exe", ".cmd", ".bat", ".ps1", ".com"]; + + internal static IReadOnlyList Collect() + { + var roots = (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator) + .Select(path => path.Trim().Trim('"')) + .Where(path => Path.IsPathFullyQualified(path) && !path.StartsWith(@"\\", StringComparison.Ordinal)) + .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var fixedDrives = DriveInfo.GetDrives().Where(drive => drive.DriveType == DriveType.Fixed) + .Select(drive => drive.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + var localRoots = roots.Where(root => fixedDrives.Contains(Path.GetPathRoot(root) ?? "")).ToArray(); + return Commands.Select(command => new CliToolPresence(command, command, localRoots.Any(root => + Extensions.Any(extension => File.Exists(Path.Combine(root, command + extension)))) + ? "Succeeded" : "Unavailable")).ToArray(); + } +} diff --git a/src/AppLens.Backend/DiagnosticsService.cs b/src/AppLens.Backend/DiagnosticsService.cs new file mode 100644 index 0000000..892f882 --- /dev/null +++ b/src/AppLens.Backend/DiagnosticsService.cs @@ -0,0 +1,124 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace AppLens.Backend; + +public sealed record DiagnosticReading(string Name, string Source, string State, JsonElement[] Rows, double DurationSeconds); + +public sealed class DiagnosticsService +{ + private static readonly (string Id, string Name, string Source)[] Probes = + [ + ("Processes", "Processes", "Windows process table"), ("Startup", "Startup", "StartupApproved registry and startup folders"), + ("Services", "Services", "Service Control Manager"), ("Storage", "Storage", "Selected folders"), + ("Git", "Git placement", "Selected folders"), ("Docker", "Docker storage", "Local Docker named pipe"), + ("Wsl", "WSL", "Registered distributions"), ("Tools", "AI tools", "PATH"), + ("Models", "Local models", "Selected model stores"), ("Graphics", "Graphics memory", "Windows GPU counters / nvidia-smi"), + ("Llama", "llama.cpp", "Local device enumeration"), ("Cuda", "CUDA toolkit", "nvcc"), + ("Torch", "PyTorch", "Local Python runtime"), ("Disk", "Disk health", "Storage reliability counters"), + ("Battery", "Battery", "Battery CIM"), ("Encryption", "Disk encryption", "BitLocker CIM"), + ("Network", "Network", "Windows network adapters"), ("Dns", "DNS", "Windows DNS client"), + ("Listeners", "Listeners", "Windows TCP and UDP tables"), ("Firewall", "Firewall", "Windows firewall profiles"), + ("Backup", "Backup outcomes", "Windows Backup event log"), ("Jobs", "Scheduled jobs", "Task Scheduler"), + ("Updates", "Security updates", "Cached Windows Update metadata"), ("Reboot", "Reboot", "Windows servicing registry") + ]; + + private static readonly Lazy Script = new(() => + { + using var stream = typeof(DiagnosticsService).Assembly.GetManifestResourceStream("AppLens.Backend.WindowsDiagnostics.ps1")!; + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + }); + + public async Task RunAsync(InventorySnapshot inventory, CancellationToken cancellationToken = default) + { + var watch = Stopwatch.StartNew(); + var readings = new DiagnosticReading[Probes.Length]; + await Parallel.ForEachAsync(Enumerable.Range(0, Probes.Length), + new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = cancellationToken }, + async (index, token) => + { + var probe = Probes[index]; + var limit = TimeSpan.FromSeconds(probe.Id is "Storage" or "Updates" ? 14 : 9); + readings[index] = await RunProbeAsync(probe, Script.Value, limit, token).ConfigureAwait(false); + }).ConfigureAwait(false); + var findings = new List(); + if (inventory.Machine.Disks.Any(disk => disk.LowSpace == true)) + findings.Add(JsonSerializer.SerializeToElement(new { Category = "Storage", Observation = "A volume is below the free space threshold." })); + if (inventory.Machine.HighMemoryUse == true) + findings.Add(JsonSerializer.SerializeToElement(new { Category = "Memory", Observation = "At least 85% of RAM was in use at capture." })); + if (readings.Where(reading => reading.Name == "Disk health").SelectMany(reading => reading.Rows).Any(row => + row.TryGetProperty("Health", out var health) && health.GetString() is "Warning" or "Unhealthy")) + findings.Add(JsonSerializer.SerializeToElement(new { Category = "Disk health", Observation = "A disk reported degraded health." })); + return inventory with + { + Mode = "Diagnostics", + Diagnostics = [.. readings, new("Findings", "Local rules", findings.Count > 0 ? "Succeeded" : "Empty", [.. findings], 0)], + Duration = inventory.Duration + watch.Elapsed + }; + } + + internal static DiagnosticReading Parse(string name, string source, string output, double elapsed) + { + using var json = JsonDocument.Parse(output); + var root = json.RootElement; + var state = root.GetProperty("State").GetString(); + var values = root.GetProperty("Rows"); + var rows = values.EnumerateArray().Where(row => row.ValueKind == JsonValueKind.Object) + .Select(row => row.Clone()).ToArray(); + if (rows.Length != values.GetArrayLength()) state = rows.Length == 0 ? "Failed" : "Partial"; + return new(name, source, state is "Succeeded" or "Empty" or "Inapplicable" or "Unavailable" or + "PermissionLimited" or "Partial" or "Failed" ? state : "Failed", rows, elapsed); + } + + internal static async Task RunProbeAsync( + (string Id, string Name, string Source) probe, string script, TimeSpan limit, CancellationToken cancellationToken) + { + var watch = Stopwatch.StartNew(); + using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + budget.CancelAfter(limit); + // Pipe the embedded script to a fixed launcher so it never hits Windows' + // command-line length limit or needs a writable script file. + var launcher = Convert.ToBase64String(Encoding.Unicode.GetBytes( + "[Console]::InputEncoding=[Text.Encoding]::UTF8; & ([ScriptBlock]::Create([Console]::In.ReadToEnd()))")); + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardError = true, RedirectStandardOutput = true, RedirectStandardInput = true, + StandardOutputEncoding = Encoding.UTF8, StandardInputEncoding = new UTF8Encoding(false), + ArgumentList = { "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", launcher } + } + }; + DiagnosticReading Status(string state) => new(probe.Name, probe.Source, state, [], watch.Elapsed.TotalSeconds); + try + { + budget.Token.ThrowIfCancellationRequested(); + process.Start(); + var stdout = process.StandardOutput.ReadToEndAsync(budget.Token); + var stderr = process.StandardError.ReadToEndAsync(budget.Token); + await process.StandardInput.WriteAsync(("$Probe = '" + probe.Id + "'\n" + script).AsMemory(), budget.Token) + .ConfigureAwait(false); + process.StandardInput.Close(); + await process.WaitForExitAsync(budget.Token).ConfigureAwait(false); + await Task.WhenAll(stdout, stderr).WaitAsync(budget.Token).ConfigureAwait(false); + if (process.ExitCode != 0) return Status("Failed"); + return Parse(probe.Name, probe.Source, await stdout, watch.Elapsed.TotalSeconds); + } + catch (OperationCanceledException) + { + try { if (!process.HasExited) process.Kill(entireProcessTree: true); } + catch (InvalidOperationException) { } + catch (Win32Exception) { } + cancellationToken.ThrowIfCancellationRequested(); + return Status("TimedOut"); + } + catch (Win32Exception ex) { return Status(ex.NativeErrorCode == 5 ? "PermissionLimited" : "Unavailable"); } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or KeyNotFoundException or IOException) + { return Status("Failed"); } + } +} diff --git a/src/AppLens.Backend/InventoryCollector.cs b/src/AppLens.Backend/InventoryCollector.cs index d61f466..29da316 100644 --- a/src/AppLens.Backend/InventoryCollector.cs +++ b/src/AppLens.Backend/InventoryCollector.cs @@ -29,6 +29,7 @@ public Task CollectAsync(CancellationToken token) => Task.Run( StoreApplications = ordered.Where(a => a.Kind == ApplicationKind.Store).ToList(), RuntimesAndFrameworks = ordered.Where(a => a.Kind == ApplicationKind.Runtime).ToList(), SystemComponents = ordered.Where(a => a.Kind is ApplicationKind.SystemComponent or ApplicationKind.Driver or ApplicationKind.Update).ToList(), + CliTools = CliToolPresence.Collect(), ProbeStatuses = statuses }; }, token); diff --git a/src/AppLens.Backend/InventoryData.cs b/src/AppLens.Backend/InventoryData.cs index 0fa7fe1..9131119 100644 --- a/src/AppLens.Backend/InventoryData.cs +++ b/src/AppLens.Backend/InventoryData.cs @@ -23,7 +23,9 @@ public sealed class DeviceSummary ? TotalMemoryBytes - FreeMemoryBytes : null; public double? MemoryUsedPercent => UsedMemoryBytes is { } used && TotalMemoryBytes is > 0 ? 100d * used / TotalMemoryBytes.Value : null; + public bool? HighMemoryUse => MemoryUsedPercent is { } percent ? percent >= 85 : null; public DateTimeOffset? LastBootAt { get; init; } + public double? UptimeSeconds { get; init; } public List Processors { get; init; } = []; public List Graphics { get; init; } = []; public List Disks { get; init; } = []; @@ -43,6 +45,7 @@ public sealed class DiskSnapshot ? TotalBytes - FreeBytes : null; public double? UsedPercent => UsedBytes is { } used && TotalBytes is > 0 ? 100d * used / TotalBytes.Value : null; public double? FreePercent => UsedPercent is { } used ? 100 - used : null; + public bool? LowSpace => FreePercent is { } percent ? percent < 10 : null; [JsonIgnore] public string Summary => $"{Volume} {InventoryFormatting.Size(UsedBytes)} used / {InventoryFormatting.Size(TotalBytes)} · {InventoryFormatting.Size(FreeBytes)} free"; } diff --git a/src/AppLens.Backend/InventorySnapshot.cs b/src/AppLens.Backend/InventorySnapshot.cs index 0368385..784817b 100644 --- a/src/AppLens.Backend/InventorySnapshot.cs +++ b/src/AppLens.Backend/InventorySnapshot.cs @@ -1,9 +1,10 @@ namespace AppLens.Backend; -/// The core client/report contract; contains no deferred diagnostics. -public sealed class InventorySnapshot +public sealed record InventorySnapshot { - public string SchemaVersion { get; init; } = "2.1"; + public string SchemaVersion { get; init; } = "3.0"; + public string Mode { get; init; } = "Inventory"; + public string Platform { get; init; } = "Windows"; public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.Now; public DeviceSummary Machine { get; init; } = new(); public InventorySummary Inventory { get; init; } = new(); @@ -13,7 +14,10 @@ public sealed class InventorySnapshot public List Actions { get; init; } = []; public WorkflowClientContext ClientContext { get; init; } = new(); public WorkflowIntakeSummary WorkflowIntake => WorkflowIntakeSummary.From(this); - public bool IsPartial => ProbeStatuses.Any(p => p.State != ProbeState.Succeeded); + public IReadOnlyList Diagnostics { get; init; } = []; + public double DurationSeconds => Duration.TotalSeconds; + public bool IsPartial => ProbeStatuses.Any(p => p.State != ProbeState.Succeeded) || + Diagnostics.Any(p => p.State is not ("Succeeded" or "Empty" or "Inapplicable")); [System.Text.Json.Serialization.JsonIgnore] public IEnumerable Applications => Inventory.DesktopApplications .Concat(Inventory.StoreApplications).Concat(Inventory.RuntimesAndFrameworks).Concat(Inventory.SystemComponents) diff --git a/src/AppLens.Backend/MachineCollector.cs b/src/AppLens.Backend/MachineCollector.cs index 0b76a9f..471f9a5 100644 --- a/src/AppLens.Backend/MachineCollector.cs +++ b/src/AppLens.Backend/MachineCollector.cs @@ -30,6 +30,7 @@ public async Task CollectAsync(CancellationToken token) Manufacturer = Text(computer, "Manufacturer"), Model = Text(computer, "Model"), TotalMemoryBytes = KiB(Number(os, "TotalVisibleMemorySize")), FreeMemoryBytes = KiB(Number(os, "FreePhysicalMemory")), LastBootAt = boot, + UptimeSeconds = boot is { } started ? Math.Max(0, (DateTimeOffset.Now - started).TotalSeconds) : null, Processors = reads[2].Rows.Select(r => Text(r, "Name")).Where(n => n.Length > 0).ToList(), Graphics = reads[3].Rows.Select(r => Text(r, "Name")).Where(n => n.Length > 0).ToList(), Disks = reads[4].Rows.Select(r => new DiskSnapshot diff --git a/src/AppLens.Backend/Models.cs b/src/AppLens.Backend/Models.cs index 0c39eca..bae4a7e 100644 --- a/src/AppLens.Backend/Models.cs +++ b/src/AppLens.Backend/Models.cs @@ -46,6 +46,7 @@ public sealed class MachineSummary public sealed class InventorySummary { + public IReadOnlyList CliTools { get; init; } = []; public List DesktopApplications { get; init; } = []; public List StoreApplications { get; init; } = []; public List RuntimesAndFrameworks { get; init; } = []; @@ -346,6 +347,7 @@ public sealed class ProbeStatus public ProbeState State { get; init; } public string Message { get; init; } = ""; public TimeSpan Duration { get; init; } + public double DurationSeconds => Duration.TotalSeconds; } [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/src/AppLens.Backend/ReportWriter.cs b/src/AppLens.Backend/ReportWriter.cs index f5f488e..ce5c852 100644 --- a/src/AppLens.Backend/ReportWriter.cs +++ b/src/AppLens.Backend/ReportWriter.cs @@ -90,15 +90,17 @@ private static IEnumerable
Sections(InventorySnapshot snapshot) yield return new("Capture", ["Field", "Value"], new[] { new[] { "Captured", snapshot.GeneratedAt.ToString("O") }, + new[] { "Mode", snapshot.Mode }, new[] { "Coverage", intake.Coverage }, new[] { "Inventory entries", snapshot.Applications.Count().ToString() }, new[] { "Entries without reported sizes", snapshot.Applications.Count(a => !a.ReportedSizeBytes.HasValue).ToString() }, new[] { "First inventory results", snapshot.FirstResultsDuration is { } first ? $"{first.TotalSeconds:N3}s" : "Unknown" }, new[] { "Collection duration", $"{snapshot.Duration.TotalSeconds:N3}s" } }); - yield return new("Local disks", ["Volume", "Total", "Used", "Used %", "Free", "Free %"], machine.Disks.Select(d => new[] + yield return new("Local disks", ["Volume", "Total", "Used", "Used %", "Free", "Free %", "Attention"], machine.Disks.Select(d => new[] { d.Volume, InventoryFormatting.Size(d.TotalBytes), InventoryFormatting.Size(d.UsedBytes), - Percent(d.UsedPercent), InventoryFormatting.Size(d.FreeBytes), Percent(d.FreePercent) })); + Percent(d.UsedPercent), InventoryFormatting.Size(d.FreeBytes), Percent(d.FreePercent), + d.LowSpace == true ? "Below 10% free" : d.LowSpace is null ? "Unknown" : "—" })); yield return new("Applications", ["Name", "Publisher", "Version", "Reported size", "Size source", "Scope", "Type", "Removal status", "Admin approval", "Identity", "Install location", "Installed / serviced", "Latest action", "Scope evidence", "Classification evidence"], snapshot.Applications.Select(a => new[] { a.Name, a.Publisher, a.Version, a.SizeDisplay, a.SizeSource, @@ -114,7 +116,7 @@ private static IEnumerable
Sections(InventorySnapshot snapshot) new[] { "GPU", machine.Graphics.Count == 0 ? "Unknown" : string.Join("; ", machine.Graphics) }, new[] { "RAM total", InventoryFormatting.Size(machine.TotalMemoryBytes) }, new[] { "RAM free", InventoryFormatting.Size(machine.FreeMemoryBytes) }, - new[] { "RAM used", Percent(machine.MemoryUsedPercent) }, + new[] { "RAM used", Percent(machine.MemoryUsedPercent) + (machine.HighMemoryUse == true ? " (high)" : "") }, new[] { "Uptime at capture", machine.LastBootAt is { } boot ? (snapshot.GeneratedAt - boot).ToString(@"d\.hh\:mm\:ss") : "Unknown" } }); yield return new("Action history — original observations", ["App", "Identity", "Route", "Started", "Original completion", "Original outcome", "Original detail", "Originally still installed", "Restart required", "Version", "Publisher", "Scope", "Scope evidence"], @@ -127,6 +129,15 @@ private static IEnumerable
Sections(InventorySnapshot snapshot) a.FirstAbsentAt?.ToString("O") ?? "Not observed", a.Revision.ToString(), a.PersistenceWarning ?? "None" })); yield return new("Scan coverage", ["Source", "State", "Duration", "Detail"], snapshot.ProbeStatuses.Select(p => new[] { p.Name, p.State.ToString(), $"{p.Duration.TotalSeconds:N3}s", p.Message })); + yield return new("CLI tools", ["Tool", "State"], snapshot.Inventory.CliTools.Select(tool => + new[] { tool.Name, tool.State })); + if (snapshot.Diagnostics.Count > 0) + { + yield return new("Diagnostics", ["Probe", "Source", "State", "Observation"], snapshot.Diagnostics.SelectMany(reading => + reading.Rows.Select(row => string.Join(", ", row.EnumerateObject().Select(field => + $"{field.Name} = {field.Value}"))).DefaultIfEmpty("—").Select(observation => + new[] { reading.Name, reading.Source, reading.State, observation }))); + } yield return new("Observed disk changes after actions", ["Action ID", "App", "Before reading", "After reading", "Change", "Administrator handoff"], snapshot.Actions.Select(a => new[] { a.Id, a.AppName, a.BeforeCapturedAt?.ToString("O") ?? "Unknown", a.AfterCapturedAt?.ToString("O") ?? "Unknown", a.DiskChangeDisplay.Length == 0 ? "Unknown" : a.DiskChangeDisplay, @@ -142,8 +153,11 @@ private void RedactValues(JsonNode node, InventorySnapshot snapshot) if (node is JsonObject obj) { foreach (var (key, child) in obj.ToArray()) + { + if (key is "Mode" or "Platform" or "SchemaVersion" or "State") continue; if (child is JsonValue value && value.TryGetValue(out var text)) obj[key] = Protect(text, snapshot, false); else if (child is not null) RedactValues(child, snapshot); + } } else if (node is JsonArray array) { diff --git a/src/AppLens.Backend/WindowsDiagnostics.ps1 b/src/AppLens.Backend/WindowsDiagnostics.ps1 new file mode 100644 index 0000000..3ff6a1e --- /dev/null +++ b/src/AppLens.Backend/WindowsDiagnostics.ps1 @@ -0,0 +1,332 @@ +# AppLens runs one probe per process. Emit only selected observations. +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) +$state = 'Succeeded' + +function Find-LocalApplication($name) { + $fixed = @([IO.DriveInfo]::GetDrives() | Where-Object DriveType -eq 'Fixed' | ForEach-Object Name) + foreach ($entry in ($env:PATH -split ';')) { + $root = $entry.Trim().Trim('"') + if ($root -match '^[A-Za-z]:\\' -and $fixed -contains [IO.Path]::GetPathRoot($root)) { + $file = Join-Path $root ($name + '.exe') + if ([IO.File]::Exists($file)) { return $file } + } + } +} + +function Invoke-LocalApplication($name, [string[]]$arguments) { + $app = Find-LocalApplication $name + if (!$app) { throw [NotSupportedException]::new('Executable unavailable') } + # Windows PowerShell wraps native stderr; only return successful output. + $ErrorActionPreference = 'Continue' + $output = & $app @arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + if (($output -join ' ') -match 'permission denied|access is denied|operation not permitted') { + throw [UnauthorizedAccessException]::new() + } + throw [InvalidOperationException]::new() + } + @($output | ForEach-Object ToString) +} + +function Measure-SelectedFolder($label, $path, [switch]$repositories, [string]$modelPattern) { + if (!(Test-Path -LiteralPath $path -PathType Container)) { return } + $root = Get-Item -LiteralPath $path -Force + if ($root.Attributes -band [IO.FileAttributes]::ReparsePoint) { return } + $pending = New-Object 'System.Collections.Generic.Stack[object]' + $pending.Push(@{ Directory = $root; Depth = 0 }) + $clock = [Diagnostics.Stopwatch]::StartNew() + $visited = 0 + $total = 0L + $rowState = 'Succeeded' + :walk while ($pending.Count -gt 0) { + $item = $pending.Pop() + try { + if ($repositories -and (Test-Path -LiteralPath (Join-Path $item.Directory.FullName '.git'))) { + $total++ + continue + } + foreach ($entry in $item.Directory.EnumerateFileSystemInfos()) { + $visited++ + if ($visited -gt 50000 -or $clock.Elapsed.TotalSeconds -gt 1.2) { + $rowState = 'Partial' + break walk + } + if ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } + if ($entry -is [IO.DirectoryInfo]) { + if (!$repositories -or $item.Depth -lt 4) { + $pending.Push(@{ Directory = $entry; Depth = $item.Depth + 1 }) + } + } elseif (!$repositories -and (!$modelPattern -or $entry.Name -like $modelPattern)) { + $total += $(if ($modelPattern) { 1 } else { $entry.Length }) + } + } + } catch [UnauthorizedAccessException] { $rowState = 'PermissionLimited' } + catch [IO.IOException] { $rowState = 'Partial' } + } + $key = if ($repositories) { 'RepositoryCount' } elseif ($modelPattern) { 'ModelCount' } else { 'Bytes' } + @{ Location = $label; $key = $total; State = $rowState } +} + +function Read-Startup { + $run = 'Software\Microsoft\Windows\CurrentVersion\Run' + $approval = 'Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved' + foreach ($hive in 'CurrentUser', 'LocalMachine') { + foreach ($view in 'Registry64', 'Registry32') { + # The current user's Run key is shared between registry views. + if ($hive -eq 'CurrentUser' -and $view -eq 'Registry32') { continue } + $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey($hive, $view) + try { + $key = $base.OpenSubKey($run) + if (!$key) { continue } + $approvalView = if ($view -eq 'Registry32') { 'Run32' } else { 'Run' } + $approved = $(if ($hive -eq 'CurrentUser') { [Microsoft.Win32.Registry]::CurrentUser } + else { [Microsoft.Win32.Registry]::LocalMachine }).OpenSubKey("$approval\$approvalView") + try { + foreach ($name in $key.GetValueNames()) { + $bytes = if ($approved) { $approved.GetValue($name) } + $value = if ($bytes) { $bytes[0] } + $enabled = if ($value -in 2,6) { 'Enabled' } elseif ($value -in 3,7) { 'Disabled' } else { 'Unknown' } + @{ Name = $name; Scope = $hive; Location = $approvalView; Approval = $enabled } + } + } finally { + if ($approved) { $approved.Dispose() } + $key.Dispose() + } + } finally { $base.Dispose() } + } + $scope = if ($hive -eq 'CurrentUser') { 'Startup' } else { 'CommonStartup' } + $folder = [Environment]::GetFolderPath($scope) + if (!$folder -or !(Test-Path -LiteralPath $folder)) { continue } + $base = if ($hive -eq 'CurrentUser') { [Microsoft.Win32.Registry]::CurrentUser } else { [Microsoft.Win32.Registry]::LocalMachine } + $key = $base.OpenSubKey("$approval\StartupFolder") + try { + foreach ($entry in Get-ChildItem -LiteralPath $folder -File) { + $bytes = if ($key) { $key.GetValue($entry.Name) } + $value = if ($bytes) { $bytes[0] } + $enabled = if ($value -in 2,6) { 'Enabled' } elseif ($value -in 3,7) { 'Disabled' } else { 'Unknown' } + @{ Name = $entry.Name; Scope = $hive; Location = 'StartupFolder'; Approval = $enabled } + } + } finally { if ($key) { $key.Dispose() } } + } +} + +function Read-Battery { + $batteries = @(Get-CimInstance -ClassName Win32_Battery) + if (!$batteries.Count) { throw [NotSupportedException]::new('Inapplicable') } + $static = @(Get-CimInstance -Namespace root/wmi -ClassName BatteryStaticData -ErrorAction SilentlyContinue) + $full = @(Get-CimInstance -Namespace root/wmi -ClassName BatteryFullChargedCapacity -ErrorAction SilentlyContinue) + $cycles = @(Get-CimInstance -Namespace root/wmi -ClassName BatteryCycleCount -ErrorAction SilentlyContinue) + $devices = @($static + $full + $cycles | Group-Object InstanceName | ForEach-Object { $_.Group[0] }) + if (!$devices.Count) { + foreach ($device in $batteries) { + @{ DesignCapacity = $device.DesignCapacity; FullCapacity = $device.FullChargeCapacity + Cycles = $null; CapacityUnit = 'mWh'; State = 'Partial' } + } + } + foreach ($device in $devices) { + $design = $static | Where-Object InstanceName -eq $device.InstanceName | Select-Object -First 1 + $capacity = $full | Where-Object InstanceName -eq $device.InstanceName | Select-Object -First 1 + $cycle = $cycles | Where-Object InstanceName -eq $device.InstanceName | Select-Object -First 1 + $designCapacity = $design.DesignedCapacity + if (!$designCapacity -and $devices.Count -eq 1 -and $batteries.Count -eq 1) { + $designCapacity = $batteries[0].DesignCapacity + } + @{ DesignCapacity = $designCapacity; FullCapacity = $capacity.FullChargedCapacity + Cycles = $cycle.CycleCount; CapacityUnit = 'mWh'; CycleCountState = $(if ($cycle) { 'Succeeded' } else { 'Unavailable' }) + State = $(if ($designCapacity -and $capacity.FullChargedCapacity) { 'Succeeded' } else { 'Partial' }) } + } +} + +function Read-Probe { + switch ($Probe) { + 'Processes' { + Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 15 | ForEach-Object { + @{ Name = $_.ProcessName; Pid = $_.Id; WorkingSetKiB = [long]($_.WorkingSet64 / 1KB); CpuSeconds = $_.CPU } + } + } + 'Startup' { Read-Startup } + 'Services' { + Get-CimInstance Win32_Service -Property Name,State,StartMode | + Group-Object State,StartMode | ForEach-Object { + @{ State = $_.Group[0].State; StartMode = $_.Group[0].StartMode; Count = $_.Count } + } + } + 'Storage' { + Measure-SelectedFolder 'Temporary files' (Join-Path $env:LOCALAPPDATA 'Temp') + Measure-SelectedFolder 'Ollama' (Join-Path $env:USERPROFILE '.ollama') + Measure-SelectedFolder 'Package cache' (Join-Path $env:LOCALAPPDATA 'pip\Cache') + Measure-SelectedFolder 'Docker Desktop' (Join-Path $env:LOCALAPPDATA 'Docker') + } + 'Git' { + foreach ($folder in 'Desktop', 'Documents', 'Projects', 'src') { + Measure-SelectedFolder $folder (Join-Path $env:USERPROFILE $folder) -repositories + } + } + 'Docker' { + Invoke-LocalApplication 'docker' @('--host','npipe:////./pipe/docker_engine','system','df','--format', + '{{.Type}}|{{.TotalCount}}|{{.Active}}|{{.Size}}') | ForEach-Object { + $parts = $_ -split '\|' + if ($parts.Count -eq 4) { + @{ Type = $parts[0]; Total = $parts[1]; Active = $parts[2]; Size = $parts[3] } + } + } + } + 'Wsl' { + if (!(Find-LocalApplication 'wsl')) { throw [NotSupportedException]::new() } + $root = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss' + if (Test-Path -LiteralPath $root) { + Get-ChildItem -LiteralPath $root | ForEach-Object { + $values = Get-ItemProperty -LiteralPath $_.PSPath + if ($values.DistributionName) { @{ Version = $values.Version } } + } + } + } + 'Tools' { + foreach ($tool in 'ollama','llama-cli','llama-server','lms','nvcc','nvidia-smi') { + if (Find-LocalApplication $tool) { @{ Tool = $tool } } + } + } + 'Models' { + Measure-SelectedFolder 'Ollama manifests' (Join-Path $env:USERPROFILE '.ollama\models\manifests') -modelPattern '*' + Measure-SelectedFolder 'LM Studio GGUF' (Join-Path $env:USERPROFILE '.lmstudio\models') -modelPattern '*.gguf' + } + 'Graphics' { + foreach ($device in Get-CimInstance Win32_VideoController -Property Name,AdapterRAM) { + @{ Device = $device.Name; Backend = 'Display driver'; CimAdapterRamBytes32Bit = $device.AdapterRAM + SharedOrReservedBytes = $null; VerifiedUsableInferenceBytes = $null } + } + $index = 0 + foreach ($counter in Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUAdapterMemory -ErrorAction SilentlyContinue) { + $index++ + @{ Device = "GPU counter $index"; Backend = 'Windows GPU performance counter' + DedicatedUsageBytes = $counter.DedicatedUsage; SharedUsageBytes = $counter.SharedUsage + VerifiedUsableInferenceBytes = $null } + } + if (Find-LocalApplication 'nvidia-smi') { + Invoke-LocalApplication 'nvidia-smi' @('--query-gpu=name,memory.total,memory.free','--format=csv,noheader,nounits') | + ForEach-Object { + $parts = $_ -split ',\s*' + if ($parts.Count -eq 3) { + @{ Device = $parts[0]; Backend = 'NVIDIA driver'; ReportedVramMiB = $parts[1] + ReportedFreeVramMiB = $parts[2]; VerifiedUsableInferenceBytes = $null } + } + } + } + } + 'Llama' { + $tool = if (Find-LocalApplication 'llama-cli') { 'llama-cli' } else { 'llama-server' } + Invoke-LocalApplication $tool @('--list-devices') | ForEach-Object { + if ($_ -match '^\s*((CUDA|Vulkan|ROCm|HIP|SYCL|Metal|CPU)[\w.-]*):\s*(.+)$') { + @{ Device = $Matches[1]; Backend = $Matches[2]; Description = $Matches[3]; VerifiedUsableInferenceBytes = $null } + } + } + } + 'Cuda' { + Invoke-LocalApplication 'nvcc' @('--version') | ForEach-Object { + if ($_ -match 'release ([\d.]+)') { @{ Release = $Matches[1] } } + } + } + 'Torch' { + $env:PYTHONDONTWRITEBYTECODE = '1' + $script = "import importlib.util,json; s=importlib.util.find_spec('torch'); exec('import torch') if s else None; print(json.dumps([dict(Version=torch.__version__,Backend='ROCm' if torch.version.hip else 'CUDA' if torch.version.cuda else 'CPU',DeviceCount=torch.cuda.device_count(),VerifiedUsableInferenceBytes=None)] if s else []))" + $python = Find-LocalApplication 'python' + if (!$python -or $python -like '*\Microsoft\WindowsApps\*') { throw [NotSupportedException]::new() } + $items = ((Invoke-LocalApplication 'python' @('-I','-B','-c',$script)) -join '') | ConvertFrom-Json + foreach ($item in $items) { $item } + } + 'Disk' { + $index = 0 + foreach ($disk in Get-PhysicalDisk) { + $index++ + $status = 'Succeeded' + try { $reading = $disk | Get-StorageReliabilityCounter } + catch { $reading = $null; $status = 'Partial' } + @{ Device = "Disk $index"; BusType = $disk.BusType.ToString(); Health = $disk.HealthStatus.ToString() + TemperatureC = $reading.Temperature; WearPercent = $reading.Wear; PowerOnHours = $reading.PowerOnHours + ReadErrors = $reading.ReadErrorsTotal; WriteErrors = $reading.WriteErrorsTotal; State = $status } + } + } + 'Battery' { Read-Battery } + 'Encryption' { + $index = 0 + Get-CimInstance -Namespace root/cimv2/security/microsoftvolumeencryption -ClassName Win32_EncryptableVolume | + ForEach-Object { + $index++ + $protection = switch ($_.ProtectionStatus) { + 0 { 'Off' } + 1 { 'On' } + default { 'Unknown' } + } + @{ Volume = "Volume $index"; ProtectionStatus = $protection } + } + } + 'Network' { + $adapters = @(Get-NetAdapter) + @{ InterfaceCount = $adapters.Count; UpInterfaceCount = @($adapters | Where-Object Status -eq 'Up').Count } + } + 'Dns' { + $addresses = @(Get-DnsClientServerAddress | ForEach-Object ServerAddresses | Where-Object { $_ } | Sort-Object -Unique) + @{ ResolverCount = $addresses.Count } + } + 'Listeners' { + @{ Transport = 'TCP'; Count = @(Get-NetTCPConnection -ErrorAction Stop | Where-Object State -eq 'Listen').Count } + @{ Transport = 'UDP'; Count = @(Get-NetUDPEndpoint).Count } + } + 'Firewall' { + Get-NetFirewallProfile -PolicyStore ActiveStore | ForEach-Object { + @{ Profile = $_.Name; Enabled = $_.Enabled.ToString(); DefaultInboundAction = $_.DefaultInboundAction.ToString() } + } + } + 'Backup' { + try { + Get-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-Backup'; Id = 4,5,9 } -MaxEvents 12 | + ForEach-Object { + @{ EventId = $_.Id; Outcome = $(if ($_.Id -eq 4) { 'Succeeded' } else { 'Failed' }) + ObservedAt = $_.TimeCreated.ToUniversalTime().ToString('o') } + } + } catch { + if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') { throw } + } + } + 'Jobs' { + Get-ScheduledTask | Group-Object State | ForEach-Object { @{ State = $_.Name; Count = $_.Count } } + } + 'Updates' { + $session = New-Object -ComObject Microsoft.Update.Session + $searcher = $session.CreateUpdateSearcher() + $searcher.Online = $false + $result = $searcher.Search('IsInstalled=0 and IsHidden=0 and Type=''Software''') + $security = @($result.Updates | Where-Object { + @($_.Categories | Where-Object CategoryID -eq '0fa1201d-4330-4fa8-8ae9-b877473b6441').Count -gt 0 + }) + @{ CachedCandidateCount = $result.Updates.Count; SecurityCandidateCount = $security.Count + MetadataRefreshed = $false; ResultCode = $result.ResultCode; State = $(if ($result.ResultCode -eq 2) { 'Succeeded' } else { 'Partial' }) } + } + 'Reboot' { + $cbs = Test-Path -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' + $wu = Test-Path -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' + $rename = Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -ErrorAction Stop + @{ RebootRequired = [bool]($cbs -or $wu -or $rename.PendingFileRenameOperations) + Servicing = $cbs; WindowsUpdate = $wu; PendingFileRename = [bool]$rename.PendingFileRenameOperations } + } + } +} + +$rows = New-Object 'System.Collections.Generic.List[object]' +try { + Read-Probe | ForEach-Object { if ($null -ne $_) { $rows.Add($_) } } + if (!$rows.Count) { $state = 'Empty' } + elseif ($Probe -in 'Disk','Battery','Storage','Git','Models','Updates' -and + @($rows | Where-Object { $_.State -in 'Partial','PermissionLimited' }).Count) { $state = 'Partial' } +} catch { + if ($rows.Count) { $state = 'Partial' } + elseif ($_.Exception -is [UnauthorizedAccessException] -or $_.Exception -is [Security.SecurityException] -or + $_.Exception.Message -match 'access.+denied|0x80070005') { $state = 'PermissionLimited' } + elseif ($_.Exception.Message -eq 'Inapplicable') { $state = 'Inapplicable' } + elseif ($_.Exception -is [NotSupportedException] -or $_.CategoryInfo.Category -in 'ObjectNotFound','NotInstalled' -or + $_.Exception.Message -match 'invalid namespace|invalid class|not registered') { $state = 'Unavailable' } + else { $state = 'Failed' } +} +[Console]::WriteLine((ConvertTo-Json -InputObject @{ State = $state; Rows = $rows.ToArray() } -Depth 6 -Compress)) diff --git a/src/AppLens.Desktop/MainWindow.xaml b/src/AppLens.Desktop/MainWindow.xaml index aa5a4a2..c7b6ed6 100644 --- a/src/AppLens.Desktop/MainWindow.xaml +++ b/src/AppLens.Desktop/MainWindow.xaml @@ -15,6 +15,7 @@