diff --git a/drive-health/README.md b/drive-health/README.md index 66e668b..801b270 100644 --- a/drive-health/README.md +++ b/drive-health/README.md @@ -18,17 +18,17 @@ Drive Health runs on Linux with Noctalia Shell v5 and uses the following commands declared in `plugin.toml`: `lsblk`, `smartctl`, `sh`, `date`, `dirname`, `mkdir`, `mktemp`, `rm`, `sed`, -`cat`, `chmod`, `mv`, `sudo`, `env`, `bash`, `install`, `systemctl`, `pkexec`, +`cat`, `chmod`, `mv`, `env`, `bash`, `install`, `systemctl`, `pkexec`, `id`, `tr`, `pacman`, `apt-get`, `dnf`, `zypper`, `apk`, `xbps-install`, and `emerge`. Most are standard system utilities. Install `smartctl` from the -`smartmontools` package and `lsblk` from `util-linux`. `systemctl`, `sudo`, and -`pkexec` are needed only for the optional collector and SMART self-tests. +`smartmontools` package and `lsblk` from `util-linux`. `systemctl` and `pkexec` +are needed only for the optional collector and SMART self-tests. -The dependency card can open a terminal with a package-manager command for -`pacman`, `apt-get`, `dnf`, `zypper`, `apk`, `xbps-install`, or `emerge`. The -command is shown for review before any privileged prompt. +The dependency card uses the desktop authorization dialog before it runs a +package-manager command for `pacman`, `apt-get`, `dnf`, `zypper`, `apk`, +`xbps-install`, or `emerge`. The exact command remains visible for review. ## Usage @@ -44,11 +44,12 @@ Basic mode discovers drives, mounted folders, storage use, and temperatures available to the user session. Open the collector controls from the gear in the panel header to compare basic mode with optional Full SMART mode. -Full SMART installation always opens a terminal with the exact command. After -the user reviews and approves `sudo`, the installer adds a hardened systemd -oneshot and timer. Disabling Full SMART in settings makes Drive Health ignore -the collector cache; use **Stop background service** to stop an installed -timer as well. +Full SMART installation uses the desktop authorization dialog and runs in the +background. After approval, the installer adds a hardened systemd oneshot and +timer. Starting, pausing, removing the collector, and changing its interval +use the same dialog. Disabling Full SMART in settings makes Drive Health ignore +the collector cache; use **Stop background service** to stop an installed timer +as well. Expand a drive for detailed counters, trend history, per-drive preferences, and SMART self-tests. A self-test requires explicit confirmation and a Polkit @@ -66,7 +67,8 @@ do not produce one-scan notification noise. | Setting | Type | Default | Description | | --- | --- | --- | --- | | `system_collector_enabled` | `bool` | `false` | Read the optional root collector cache for complete SMART data. | -| `refresh_seconds` | `int` | `30` | Seconds between user-session refreshes (15–300). The root timer independently refreshes every 30 seconds. | +| `refresh_seconds` | `int` | `30` | Seconds between lightweight user-session refreshes (15–300). This updates drive inventory, mounts, and non-waking sysfs temperatures; the root timer refreshes full SMART data every 15 minutes. | +| `full_smart_refresh_minutes` | `int` | `15` | Minutes between privileged full SMART reads (1–1440). Applying a changed interval requires explicit administrator approval from the collector controls. | | `warning_temperature` | `int` | `65` | Global warning temperature in °C. | | `critical_temperature` | `int` | `80` | Global critical temperature in °C. | | `life_warning_percent` | `int` | `20` | Remaining SSD-life percentage that triggers a warning. | @@ -93,8 +95,8 @@ services communicate through Noctalia state and do not require manual IPC. Drive Health makes no network requests and does not download or execute code. It spawns only the commands documented under Requirements. Conditional -package-manager commands are generated locally and opened in a terminal for -review. +package-manager commands are generated locally and require desktop +administrator authorization. The plugin stores bounded local state in its Noctalia data directory: @@ -107,6 +109,8 @@ Full SMART mode installs these system files only after explicit approval: - `/usr/local/libexec/noctalia-drive-health/collect_raw.sh`; - `/usr/local/libexec/noctalia-drive-health/smart-action.sh`; +- `/usr/local/libexec/noctalia-drive-health/manage-collector.sh`; +- `/usr/local/libexec/noctalia-drive-health/uninstall-collector.sh`; - `/etc/systemd/system/noctalia-drive-health.service`; - `/etc/systemd/system/noctalia-drive-health.timer`; - `/run/noctalia-drive-health/raw.json`. @@ -121,8 +125,8 @@ take minutes or hours, may increase drive activity, and should not be confused with filesystem repair or data recovery. To remove the optional collector, use **Remove collector** in its controls and -approve the terminal command. Removing the Noctalia entry alone does not -silently remove system files. +approve the desktop authorization dialog. Removing the Noctalia entry alone +does not silently remove system files. ## Development diff --git a/drive-health/collector.luau b/drive-health/collector.luau index c6ce183..4e39f53 100644 --- a/drive-health/collector.luau +++ b/drive-health/collector.luau @@ -6,9 +6,14 @@ local SYSTEM_NAMESPACE = "noctalia-drive-health" local SYSTEM_LIBEXEC = "/usr/local/libexec/" .. SYSTEM_NAMESPACE local RAW_CACHE = "/run/" .. SYSTEM_NAMESPACE .. "/raw.json" -local CACHE_MAX_AGE_SECONDS = 180 +-- The root collector performs full SMART reads every 15 minutes. The desktop +-- service refreshes lsblk and sysfs temperatures between those runs, so allow +-- a little scheduler slack before falling back to an expensive direct read. +local CACHE_MAX_AGE_SECONDS = 1200 +local LSBLK_INVENTORY_COMMAND = "lsblk --json --bytes --paths --output " + .. "NAME,KNAME,PATH,PKNAME,TYPE,TRAN,ROTA,RM,HOTPLUG,SIZE,LOG-SEC,PHY-SEC,MODEL,SERIAL,FSTYPE,FSSIZE,FSUSED,FSAVAIL,MOUNTPOINTS" local NVME_DATA_UNIT_BYTES = 512000 -local EXPECTED_COLLECTOR_VERSION = "2.0.0" +local EXPECTED_COLLECTOR_VERSION = "2.0.1" local PREFERENCES_FILE = "drive-preferences.json" local collecting = false @@ -48,13 +53,13 @@ local DEPENDENCIES = { } local PACKAGE_MANAGERS = { - { command = "pacman", id = "pacman", label = "pacman", prefix = "sudo pacman -S --needed " }, - { command = "apt-get", id = "apt", label = "APT", prefix = "sudo apt-get install " }, - { command = "dnf", id = "dnf", label = "DNF", prefix = "sudo dnf install " }, - { command = "zypper", id = "zypper", label = "Zypper", prefix = "sudo zypper install " }, - { command = "apk", id = "apk", label = "APK", prefix = "sudo apk add " }, - { command = "xbps-install", id = "xbps", label = "XBPS", prefix = "sudo xbps-install -S " }, - { command = "emerge", id = "emerge", label = "Portage", prefix = "sudo emerge --ask " }, + { command = "pacman", id = "pacman", label = "pacman", prefix = "pkexec pacman -S --needed --noconfirm " }, + { command = "apt-get", id = "apt", label = "APT", prefix = "pkexec apt-get install --yes " }, + { command = "dnf", id = "dnf", label = "DNF", prefix = "pkexec dnf install --assumeyes " }, + { command = "zypper", id = "zypper", label = "Zypper", prefix = "pkexec zypper --non-interactive install " }, + { command = "apk", id = "apk", label = "APK", prefix = "pkexec apk add " }, + { command = "xbps-install", id = "xbps", label = "XBPS", prefix = "pkexec xbps-install -S -y " }, + { command = "emerge", id = "emerge", label = "Portage", prefix = "pkexec emerge --ask=n " }, } local function shellQuote(value) @@ -65,6 +70,11 @@ local function systemCollectorEnabled() return noctalia.getConfig("system_collector_enabled") == true end +local function fullSmartRefreshMinutes() + local configured = tonumber(noctalia.getConfig("full_smart_refresh_minutes")) or 15 + return math.max(1, math.min(1440, math.floor(configured))) +end + local function numeric(value) if value == nil or type(value) == "boolean" then return nil @@ -711,11 +721,24 @@ local function systemCollectorState(raw, source) local pluginDirectory = noctalia.pluginDir() local enabled = systemCollectorEnabled() local installed = noctalia.fileExists(SYSTEM_LIBEXEC .. "/collect_raw.sh") + local pkexecAvailable = noctalia.commandExists("pkexec") local reportedVersion = type(raw) == "table" and tostring(raw.collector_version or "") or "" local version = source == "system-cache" and reportedVersion or "" + local refreshMinutes = fullSmartRefreshMinutes() + local intervalScript = SYSTEM_LIBEXEC .. "/set-collector-interval.sh" + local manageScript = SYSTEM_LIBEXEC .. "/manage-collector.sh" + local uninstallScript = SYSTEM_LIBEXEC .. "/uninstall-collector.sh" + local installScript = pluginDirectory ~= nil + and pluginDirectory .. "/packaging/install-system-collector.sh" or nil + local sourceUninstallScript = pluginDirectory ~= nil + and pluginDirectory .. "/packaging/uninstall-system-collector.sh" or nil local status if not enabled then status = "disabled" + elseif installed and not noctalia.fileExists(manageScript) then + -- Older collectors did not include the fixed, root-owned lifecycle helper. + -- Ask for an upgrade rather than falling back to a shell or terminal. + status = "upgrade-required" elseif source == "system-cache" and version == EXPECTED_COLLECTOR_VERSION then status = "healthy" elseif source == "system-cache" and version ~= EXPECTED_COLLECTOR_VERSION then @@ -731,20 +754,23 @@ local function systemCollectorState(raw, source) status = status, version = version ~= "" and version or nil, expected_version = EXPECTED_COLLECTOR_VERSION, + smart_refresh_minutes = refreshMinutes, helper_installed = noctalia.fileExists(SYSTEM_LIBEXEC .. "/smart-action.sh"), helper_available = enabled and noctalia.fileExists(SYSTEM_LIBEXEC .. "/smart-action.sh"), - authorization_available = noctalia.commandExists("pkexec"), - install_command = pluginDirectory ~= nil and "sudo " - .. shellQuote(pluginDirectory .. "/packaging/install-system-collector.sh") or nil, - uninstall_command = pluginDirectory ~= nil and "sudo " - .. shellQuote(pluginDirectory .. "/packaging/uninstall-system-collector.sh") or nil, - enable_command = installed and noctalia.commandExists("systemctl") - and "sudo systemctl enable --now " .. SYSTEM_NAMESPACE .. ".timer" - .. " && sudo systemctl start " .. SYSTEM_NAMESPACE .. ".service" or nil, - disable_command = installed and noctalia.commandExists("systemctl") - and "sudo systemctl disable --now " .. SYSTEM_NAMESPACE .. ".timer" - .. " && sudo systemctl stop " .. SYSTEM_NAMESPACE .. ".service" or nil, + authorization_available = pkexecAvailable, + install_command = installScript ~= nil and pkexecAvailable and "pkexec " + .. shellQuote(installScript) + .. " --interval-minutes " .. tostring(refreshMinutes) or nil, + uninstall_command = pkexecAvailable and ((noctalia.fileExists(uninstallScript) + and "pkexec " .. shellQuote(uninstallScript)) + or (sourceUninstallScript ~= nil and "pkexec " .. shellQuote(sourceUninstallScript))) or nil, + enable_command = installed and noctalia.fileExists(manageScript) and pkexecAvailable + and "pkexec " .. shellQuote(manageScript) .. " start" or nil, + disable_command = installed and noctalia.fileExists(manageScript) and pkexecAvailable + and "pkexec " .. shellQuote(manageScript) .. " pause" or nil, + interval_command = installed and noctalia.fileExists(intervalScript) and pkexecAvailable + and "pkexec " .. shellQuote(intervalScript) .. " " .. tostring(refreshMinutes) or nil, } end @@ -787,10 +813,15 @@ local function normalizeRaw(raw, source) if not smartAvailable then values.smart_completeness = "unavailable" end - if values.temperature_c == nil then - values.temperature_c = sysfsTemperature(device) - end - if values.hotspot_temperature_c == nil then + -- sysfs provides a lightweight, non-waking temperature update. Prefer + -- it when available so the bar and temperature alerts remain current + -- between the 15-minute full SMART snapshots. + local liveTemperature = sysfsTemperature(device) + if liveTemperature ~= nil then + values.temperature_c = liveTemperature + values.hotspot_temperature_c = liveTemperature + values.temperature_source = "sysfs" + elseif values.hotspot_temperature_c == nil then values.hotspot_temperature_c = values.temperature_c end local used, available, usagePercent, mountPoints = mountedUsage(block) @@ -963,7 +994,7 @@ local function checkDependencies() end table.sort(signatureParts) local installCommand = nil - if manager ~= nil and #packages > 0 and noctalia.commandExists("sudo") then + if manager ~= nil and #packages > 0 and noctalia.commandExists("pkexec") then installCommand = manager.prefix .. table.concat(packages, " ") end local signature = table.concat(signatureParts, ",") @@ -1032,6 +1063,43 @@ local function publish(snapshot, dependencies) noctalia.state.set("collector_snapshot", snapshot) end +local function refreshSystemCacheInventory(rawCache, dependencies) + collecting = true + local current = noctalia.state.get("collector_snapshot") or {} + current.collecting = true + current.dependencies = dependencies + noctalia.state.set("collector_snapshot", current) + + local launched = noctalia.runAsync(LSBLK_INVENTORY_COMMAND, function(result) + collecting = false + local resolvedDependencies = checkDependencies() + local inventory = result.exitCode == 0 and not result.timedOut + and noctalia.json.decode(result.stdout or "") or nil + if type(inventory) == "table" and type(inventory.blockdevices) == "table" then + rawCache.lsblk = inventory + end + + -- A failed lightweight inventory refresh must not discard a healthy SMART + -- cache. It simply leaves connection and mount information at its last + -- known values until the next refresh. + local snapshot, normalizeError = normalizeRaw(rawCache, "system-cache") + if snapshot == nil then + publishError(tostring(normalizeError or "SMART normalization failed."), resolvedDependencies) + return + end + publish(snapshot, resolvedDependencies) + end, 15000) + if not launched then + collecting = false + local snapshot, normalizeError = normalizeRaw(rawCache, "system-cache") + if snapshot == nil then + publishError(tostring(normalizeError or "SMART normalization failed."), dependencies) + else + publish(snapshot, dependencies) + end + end +end + local function collect() if collecting then return @@ -1047,7 +1115,7 @@ local function collect() if type(rawCache) == "table" and tonumber(rawCache.schema) == 2 then local snapshot, normalizeError = normalizeRaw(rawCache, "system-cache") if snapshot ~= nil then - publish(snapshot, dependencies) + refreshSystemCacheInventory(rawCache, dependencies) return end noctalia.log("Raw SMART cache rejected: " .. tostring(normalizeError)) diff --git a/drive-health/packaging/install-system-collector.sh b/drive-health/packaging/install-system-collector.sh index d60981e..5462d56 100755 --- a/drive-health/packaging/install-system-collector.sh +++ b/drive-health/packaging/install-system-collector.sh @@ -8,7 +8,41 @@ fi project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" service_name="noctalia-drive-health" -target_user="${SUDO_USER:-${1:-}}" +target_user="${SUDO_USER:-}" +if [[ -z "$target_user" && "${PKEXEC_UID:-}" =~ ^[0-9]+$ ]]; then + target_user="$(id -un "$PKEXEC_UID" 2>/dev/null || true)" +fi +interval_minutes=15 + +while (($# > 0)); do + case "$1" in + --interval-minutes) + if (($# < 2)); then + echo "--interval-minutes requires a value." >&2 + exit 2 + fi + interval_minutes="$2" + shift 2 + ;; + --help) + echo "usage: $0 [--interval-minutes 1-1440] [desktop-user]" + exit 0 + ;; + *) + if [[ -n "$target_user" ]]; then + echo "Unexpected argument: $1" >&2 + exit 2 + fi + target_user="$1" + shift + ;; + esac +done + +if ! [[ "$interval_minutes" =~ ^[0-9]+$ ]] || ((10#$interval_minutes < 1 || 10#$interval_minutes > 1440)); then + echo "Interval must be a whole number of minutes from 1 to 1440." >&2 + exit 2 +fi if [[ -z "$target_user" || "$target_user" == root ]] || ! id "$target_user" >/dev/null 2>&1; then echo "Unable to determine the desktop user. Run with sudo, or pass the username explicitly." >&2 @@ -34,6 +68,15 @@ install -Dm0755 \ install -Dm0755 \ "$project_dir/packaging/smart-action.sh" \ "/usr/local/libexec/$service_name/smart-action.sh" +install -Dm0755 \ + "$project_dir/packaging/set-collector-interval.sh" \ + "/usr/local/libexec/$service_name/set-collector-interval.sh" +install -Dm0755 \ + "$project_dir/packaging/manage-collector.sh" \ + "/usr/local/libexec/$service_name/manage-collector.sh" +install -Dm0755 \ + "$project_dir/packaging/uninstall-system-collector.sh" \ + "/usr/local/libexec/$service_name/uninstall-collector.sh" install -Dm0644 \ "$rendered_service" \ "/etc/systemd/system/$service_name.service" @@ -44,6 +87,7 @@ install -Dm0644 \ systemctl daemon-reload systemctl enable --now "$service_name.timer" systemctl start "$service_name.service" +/usr/local/libexec/$service_name/set-collector-interval.sh "$interval_minutes" echo "Installed the read-only SMART collector." echo "Cache: /run/$service_name/raw.json" diff --git a/drive-health/packaging/manage-collector.sh b/drive-health/packaging/manage-collector.sh new file mode 100755 index 0000000..02be29d --- /dev/null +++ b/drive-health/packaging/manage-collector.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +if (( EUID != 0 )); then + echo "Run this command through the administrator authorization dialog." >&2 + exit 1 +fi + +if (($# != 1)) || [[ "$1" != "start" && "$1" != "pause" ]]; then + echo "usage: $0 {start|pause}" >&2 + exit 2 +fi + +service_name="noctalia-drive-health" + +case "$1" in + start) + systemctl daemon-reload + systemctl enable --now "$service_name.timer" + systemctl start "$service_name.service" + echo "Started the Noctalia Drive Health collector." + ;; + pause) + systemctl disable --now "$service_name.timer" + systemctl stop "$service_name.service" + echo "Paused the Noctalia Drive Health collector." + ;; +esac diff --git a/drive-health/packaging/noctalia-drive-health.timer b/drive-health/packaging/noctalia-drive-health.timer index eaa27a1..5212615 100644 --- a/drive-health/packaging/noctalia-drive-health.timer +++ b/drive-health/packaging/noctalia-drive-health.timer @@ -3,7 +3,7 @@ Description=Refresh SMART data for Noctalia [Timer] OnBootSec=20s -OnUnitActiveSec=30s +OnUnitActiveSec=15min AccuracySec=5s Unit=noctalia-drive-health.service diff --git a/drive-health/packaging/set-collector-interval.sh b/drive-health/packaging/set-collector-interval.sh new file mode 100755 index 0000000..3d50835 --- /dev/null +++ b/drive-health/packaging/set-collector-interval.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +if (( EUID != 0 )); then + echo "Run this command with sudo." >&2 + exit 1 +fi + +if (($# != 1)) || ! [[ "$1" =~ ^[0-9]+$ ]] || ((10#$1 < 1 || 10#$1 > 1440)); then + echo "usage: $0 MINUTES (1-1440)" >&2 + exit 2 +fi + +service_name="noctalia-drive-health" +interval_minutes=$((10#$1)) +dropin_dir="/etc/systemd/system/$service_name.timer.d" +dropin_path="$dropin_dir/interval.conf" +temporary=$(mktemp) +trap 'rm -f -- "$temporary"' EXIT + +printf '[Timer]\nOnUnitActiveSec=\nOnUnitActiveSec=%smin\n' "$interval_minutes" >"$temporary" +install -d -m0755 "$dropin_dir" +install -m0644 "$temporary" "$dropin_path" + +systemctl daemon-reload +systemctl restart "$service_name.timer" +systemctl start "$service_name.service" + +echo "Noctalia full SMART refresh interval set to $interval_minutes minute(s)." diff --git a/drive-health/panel.luau b/drive-health/panel.luau index 7ed80d9..56c8556 100644 --- a/drive-health/panel.luau +++ b/drive-health/panel.luau @@ -11,6 +11,8 @@ local currentIssues = {} local dismissRequestNonce = 0 local pendingSelfTest = nil local selfTestLaunch = nil +local intervalApply = nil +local privilegedAction = nil local confirmUninstall = false local showCollectorSettings = false local hiddenSelection = 1 @@ -260,6 +262,18 @@ local function dependencyCard(dependencies) })) end + if privilegedAction ~= nil and privilegedAction.scope == "dependencies" then + table.insert(children, ui.label({ + text = privilegedAction.state == "authorizing" + and noctalia.tr("privileged_action.authorizing", { action = noctalia.tr("dependencies.action_install") }) + or noctalia.tr("privileged_action.failed", { action = noctalia.tr("dependencies.action_install"), + error = tostring(privilegedAction.error or "") }), + fontSize = 10, + color = privilegedAction.state == "authorizing" and "secondary" or "error", + maxLines = 2, + })) + end + local actions = {} if dependencies.can_install and dependencies.install_command ~= nil then table.insert(actions, ui.button({ @@ -378,6 +392,15 @@ local function collectorSettingsCard(collector) table.insert(actions, 1, ui.button({ text = noctalia.tr("collector.stop_service"), variant = "ghost", controlSize = "sm", onClick = "onPauseCollectorClicked" })) end + if collector.installed and collector.interval_command ~= nil then + table.insert(actions, 1, ui.button({ + text = noctalia.tr("collector.apply_interval", { + minutes = tostring(collector.smart_refresh_minutes or 15), + }), variant = "outline", controlSize = "sm", enabled = (intervalApply == nil + or intervalApply.state ~= "authorizing") and (privilegedAction == nil + or privilegedAction.state ~= "authorizing"), onClick = "onApplyCollectorIntervalClicked", + })) + end local children = { ui.row({ gap = 8, align = "center" }, { @@ -411,12 +434,34 @@ local function collectorSettingsCard(collector) }), ui.label({ text = noctalia.tr("collector.settings_hint"), fontSize = 10, color = "on_surface_variant", maxLines = 2 }), + ui.label({ text = noctalia.tr("collector.interval", { + minutes = tostring(collector.smart_refresh_minutes or 15), + }), fontSize = 10, color = "on_surface_variant" }), ui.row({ gap = 7, align = "center" }, actions), } if confirmUninstall then table.insert(children, ui.label({ text = noctalia.tr("collector.remove_confirm"), fontSize = 10, color = "error" })) end + if intervalApply ~= nil then + table.insert(children, ui.label({ + text = intervalApply.state == "authorizing" and noctalia.tr("collector.interval_authorizing") + or noctalia.tr("collector.interval_failed", { error = tostring(intervalApply.error or "") }), + fontSize = 10, color = intervalApply.state == "authorizing" and "secondary" or "error", maxLines = 2, + })) + end + if privilegedAction ~= nil and privilegedAction.scope == "collector" then + local action = noctalia.tr("collector.action_" .. privilegedAction.kind) + table.insert(children, ui.label({ + text = privilegedAction.state == "authorizing" + and noctalia.tr("privileged_action.authorizing", { action = action }) + or noctalia.tr("privileged_action.failed", { action = action, + error = tostring(privilegedAction.error or "") }), + fontSize = 10, + color = privilegedAction.state == "authorizing" and "secondary" or "error", + maxLines = 2, + })) + end if collector.installed then table.insert(children, ui.button({ text = noctalia.tr("collector.remove"), variant = confirmUninstall and "destructive" or "ghost", controlSize = "sm", @@ -1448,27 +1493,113 @@ function onConfirmSelfTestClicked() end end +local function privilegedActionError(result) + if result.timedOut == true then + return noctalia.tr("privileged_action.timeout") + end + if tonumber(result.exitCode) == 126 then + return noctalia.tr("privileged_action.cancelled") + end + local detail = noctalia.string.trim(tostring(result.stderr or "")) + if detail == "" then detail = noctalia.string.trim(tostring(result.stdout or "")) end + if #detail > 240 then detail = detail:sub(1, 237) .. "..." end + return detail ~= "" and detail or noctalia.tr("privileged_action.command_failed") +end + +local function runPrivilegedAction(command, details) + if command == nil or command == "" or (privilegedAction ~= nil and privilegedAction.state == "authorizing") + or (intervalApply ~= nil and intervalApply.state == "authorizing") then + return + end + privilegedAction = { state = "authorizing", scope = details.scope, kind = details.kind } + render() + local launched = noctalia.runAsync(command, function(result) + if result.timedOut ~= true and tonumber(result.exitCode) == 0 then + privilegedAction = nil + noctalia.notify(details.title, noctalia.tr("privileged_action.completed", { action = details.action })) + local nonce = number(noctalia.state.get("refresh_nonce"), 0) + 1 + noctalia.state.set("refresh_nonce", nonce) + else + privilegedAction = { + state = "failed", scope = details.scope, kind = details.kind, + error = privilegedActionError(result), + } + noctalia.notifyError(details.title, noctalia.tr("privileged_action.failed", { + action = details.action, error = privilegedAction.error, + })) + end + render() + end, 120000) + if not launched then + privilegedAction = { + state = "failed", scope = details.scope, kind = details.kind, + error = noctalia.tr("privileged_action.launch_failed"), + } + noctalia.notifyError(details.title, noctalia.tr("privileged_action.failed", { + action = details.action, error = privilegedAction.error, + })) + render() + end +end + function onInstallCollectorClicked() local command = snapshot and snapshot.system_collector and snapshot.system_collector.install_command or nil - if command == nil or command == "" then return end - if noctalia.runInTerminal(command) then - noctalia.notify(noctalia.tr("collector.title"), noctalia.tr("collector.terminal_opened")) - end + runPrivilegedAction(command, { + scope = "collector", kind = "install", title = noctalia.tr("collector.title"), + action = noctalia.tr("collector.action_install"), + }) end function onStartCollectorClicked() local command = snapshot and snapshot.system_collector and snapshot.system_collector.enable_command or nil - if command == nil or command == "" then return end - if noctalia.runInTerminal(command) then - noctalia.notify(noctalia.tr("collector.title"), noctalia.tr("collector.start_terminal_opened")) - end + runPrivilegedAction(command, { + scope = "collector", kind = "start", title = noctalia.tr("collector.title"), + action = noctalia.tr("collector.action_start"), + }) end function onPauseCollectorClicked() local command = snapshot and snapshot.system_collector and snapshot.system_collector.disable_command or nil + runPrivilegedAction(command, { + scope = "collector", kind = "pause", title = noctalia.tr("collector.title"), + action = noctalia.tr("collector.action_pause"), + }) +end + +function onApplyCollectorIntervalClicked() + if (intervalApply ~= nil and intervalApply.state == "authorizing") + or (privilegedAction ~= nil and privilegedAction.state == "authorizing") then return end + local command = snapshot and snapshot.system_collector and snapshot.system_collector.interval_command or nil if command == nil or command == "" then return end - if noctalia.runInTerminal(command) then - noctalia.notify(noctalia.tr("collector.title"), noctalia.tr("collector.pause_terminal_opened")) + intervalApply = { state = "authorizing" } + render() + local launched = noctalia.runAsync(command, function(result) + if result.timedOut ~= true and tonumber(result.exitCode) == 0 then + intervalApply = nil + noctalia.notify(noctalia.tr("collector.title"), noctalia.tr("collector.interval_applied")) + local nonce = number(noctalia.state.get("refresh_nonce"), 0) + 1 + noctalia.state.set("refresh_nonce", nonce) + else + local error + if result.timedOut == true then + error = noctalia.tr("collector.interval_timeout") + elseif tonumber(result.exitCode) == 126 then + error = noctalia.tr("collector.interval_cancelled") + else + local detail = noctalia.string.trim(tostring(result.stderr or "")) + if detail == "" then detail = noctalia.string.trim(tostring(result.stdout or "")) end + if #detail > 240 then detail = detail:sub(1, 237) .. "..." end + error = detail ~= "" and detail or noctalia.tr("collector.interval_command_failed") + end + intervalApply = { state = "failed", error = error } + noctalia.notifyError(noctalia.tr("collector.title"), error) + end + render() + end, 120000) + if not launched then + intervalApply = { state = "failed", error = noctalia.tr("collector.interval_launch_failed") } + noctalia.notifyError(noctalia.tr("collector.title"), intervalApply.error) + render() end end @@ -1500,11 +1631,11 @@ function onUninstallCollectorClicked() return end local command = snapshot and snapshot.system_collector and snapshot.system_collector.uninstall_command or nil - if command ~= nil and noctalia.runInTerminal(command) then - noctalia.notify(noctalia.tr("collector.title"), noctalia.tr("collector.uninstall_terminal_opened")) - end confirmUninstall = false - render() + runPrivilegedAction(command, { + scope = "collector", kind = "remove", title = noctalia.tr("collector.title"), + action = noctalia.tr("collector.action_remove"), + }) end function onOpen(_context) @@ -1532,8 +1663,10 @@ function onInstallDependenciesClicked() return end - noctalia.notify(noctalia.tr("dependencies.install_title"), noctalia.tr("dependencies.install_body")) - noctalia.runInTerminal(command) + runPrivilegedAction(command, { + scope = "dependencies", kind = "install", title = noctalia.tr("dependencies.title"), + action = noctalia.tr("dependencies.action_install"), + }) end function onCopyInstallCommandClicked() diff --git a/drive-health/plugin.toml b/drive-health/plugin.toml index 58a3335..b5998f3 100644 --- a/drive-health/plugin.toml +++ b/drive-health/plugin.toml @@ -1,11 +1,11 @@ id = "gustav0ar/drive-health" name = "Drive Health" -version = "1.2.1" +version = "1.2.2" plugin_api = 3 author = "Drive Health contributors" license = "MIT" deprecated = false -dependencies = ["lsblk", "smartctl", "sh", "date", "dirname", "mkdir", "mktemp", "rm", "sed", "cat", "chmod", "mv", "sudo", "env", "bash", "install", "systemctl", "pkexec", "id", "tr", "pacman", "apt-get", "dnf", "zypper", "apk", "xbps-install", "emerge"] +dependencies = ["lsblk", "smartctl", "sh", "date", "dirname", "mkdir", "mktemp", "rm", "sed", "cat", "chmod", "mv", "env", "bash", "install", "systemctl", "pkexec", "id", "tr", "pacman", "apt-get", "dnf", "zypper", "apk", "xbps-install", "emerge"] tags = ["bar", "panel", "service", "system", "hardware", "utility"] icon = "server-2" description = "SMART health, temperature, integrity, endurance, and storage monitoring for SSDs and hard drives." @@ -26,6 +26,15 @@ default = 30 min = 15 max = 300 +[[setting]] +key = "full_smart_refresh_minutes" +type = "int" +label_key = "settings.full_smart_refresh_minutes.label" +description_key = "settings.full_smart_refresh_minutes.description" +default = 15 +min = 1 +max = 1440 + [[setting]] key = "warning_temperature" type = "int" diff --git a/drive-health/scripts/collect_raw.sh b/drive-health/scripts/collect_raw.sh index 6fea26c..4b344c4 100755 --- a/drive-health/scripts/collect_raw.sh +++ b/drive-health/scripts/collect_raw.sh @@ -8,7 +8,7 @@ set -eu LC_ALL=C export LC_ALL -collector_version="2.0.0" +collector_version="2.0.1" generated_at_epoch=$(date +%s) collection_id="" if [ -r /proc/sys/kernel/random/uuid ]; then diff --git a/drive-health/tests/collector_harness.lua b/drive-health/tests/collector_harness.lua index c960e19..840cb1c 100644 --- a/drive-health/tests/collector_harness.lua +++ b/drive-health/tests/collector_harness.lua @@ -26,13 +26,17 @@ local available = { local rawFixture = { schema = 2, - collector_version = mode == "outdated-raw-cache" and "0.6.0" or "2.0.0", + collector_version = mode == "outdated-raw-cache" and "0.6.0" or "2.0.1", collection_id = "fixture-collection-id", generated_at_epoch = 1700000000, lsblk = { blockdevices = {} }, smart = {}, } +if mode == "raw-cache" or mode == "outdated-raw-cache" then + files["/usr/local/libexec/noctalia-drive-health/manage-collector.sh"] = "installed" +end + local function translate(key, substitutions) local value = key for name, replacement in pairs(substitutions or {}) do @@ -47,6 +51,7 @@ noctalia = { if key == "system_collector_enabled" then return collectorEnabled end + if key == "full_smart_refresh_minutes" then return 15 end return nil end, pluginDir = function() return "/mock/plugin" end, @@ -166,14 +171,14 @@ elseif mode == "probe-timeout" then return elseif mode == "missing-lsblk" then assert(dependencies.ready == false and dependencies.blocking == true, "missing lsblk was not blocking") - assert(dependencies.install_command == "sudo pacman -S --needed util-linux", "wrong lsblk install command") + assert(dependencies.install_command == "pkexec pacman -S --needed --noconfirm util-linux", "wrong lsblk install command") assert(launchedCommand == nil, "collector launched without lsblk") assert(snapshot.collector_error ~= nil, "blocking dependency did not publish an error") print("collector initialization test passed: " .. mode) return elseif mode == "missing-smartctl" then assert(dependencies.ready == false and dependencies.blocking == false, "missing smartctl blocked inventory") - assert(dependencies.install_command == "sudo pacman -S --needed smartmontools", "wrong smartctl install command") + assert(dependencies.install_command == "pkexec pacman -S --needed --noconfirm smartmontools", "wrong smartctl install command") assert(launchedCommand and launchedCommand:match("collect_raw%.sh"), "fallback collector did not launch") print("collector initialization test passed: " .. mode) return @@ -181,8 +186,9 @@ elseif mode == "raw-cache" then assert(snapshot.source == "system-cache", "raw cache was not normalized") assert(snapshot.collection_id == "fixture-collection-id", "raw cache lost its collection ID") assert(snapshot.system_collector.status == "healthy" - and snapshot.system_collector.version == "2.0.0" - and snapshot.system_collector.expected_version == "2.0.0", + and snapshot.system_collector.version == "2.0.1" + and snapshot.system_collector.expected_version == "2.0.1" + and snapshot.system_collector.smart_refresh_minutes == 15, "current system collector was not reported healthy") assert(not (launchedCommand or ""):match("collect_raw%.sh"), "collector launched despite a fresh raw cache") print("collector initialization test passed: " .. mode) @@ -191,7 +197,7 @@ elseif mode == "outdated-raw-cache" then assert(snapshot.source == "system-cache", "outdated raw cache was not normalized") assert(snapshot.system_collector.status == "upgrade-required" and snapshot.system_collector.version == "0.6.0" - and snapshot.system_collector.expected_version == "2.0.0", + and snapshot.system_collector.expected_version == "2.0.1", "older system collector did not request an upgrade") assert(not (launchedCommand or ""):match("collect_raw%.sh"), "collector launched despite a fresh raw cache") assert(#notifications == 1 and notifications[1].title == "collector.update_title" @@ -465,6 +471,24 @@ local healthyRaw = assert(normalizeRaw({ assert(healthyRaw.disks[1].smart_available == true and healthyRaw.disks[1].smart_error == nil, "healthy SMART data retained a contradictory error message") +directories["/sys/class/nvme/nvme0"] = { "hwmon0" } +files["/sys/class/nvme/nvme0/hwmon0/temp1_input"] = "51000\n" +local liveTemperatureRaw = assert(normalizeRaw({ + schema = 2, generated_at_epoch = 1700000000, + lsblk = { blockdevices = { { + name = "nvme0n1", kname = "nvme0n1", path = "/dev/nvme0n1", type = "disk", tran = "nvme", + rota = false, size = 1000000000, model = "Live Temperature NVMe", serial = "LIVE1", + mountpoints = {}, children = {}, + } } }, + smart = { { requested_device = "/dev/nvme0", payload = { + smart_status = { passed = true }, temperature = { current = 42 }, + } } }, +}, "test")) +assert(liveTemperatureRaw.disks[1].temperature_c == 51 + and liveTemperatureRaw.disks[1].hotspot_temperature_c == 51 + and liveTemperatureRaw.disks[1].temperature_source == "sysfs", + "fresh sysfs temperature did not override the stale SMART-cache value") + local sleeping = assert(normalizeRaw({ schema = 2, generated_at_epoch = 1700000000, lsblk = { blockdevices = { { @@ -511,6 +535,7 @@ local oversizedId = assert(normalizeRaw({ assert(oversizedId.collection_id == nil, "oversized collection ID was preserved") files["/usr/local/libexec/noctalia-drive-health/collect_raw.sh"] = "installed" +files["/usr/local/libexec/noctalia-drive-health/manage-collector.sh"] = "installed" collectorEnabled = true state.collector_snapshot = { summary = {}, system_collector = { status = "healthy" } } publishError("fixture failure", { ready = true, blocking = false }) diff --git a/drive-health/tests/panel_harness.lua b/drive-health/tests/panel_harness.lua index c01e0c0..37e9eb4 100644 --- a/drive-health/tests/panel_harness.lua +++ b/drive-health/tests/panel_harness.lua @@ -77,10 +77,12 @@ state.snapshot = { dependencies = { ready = true }, system_collector = { enabled = true, installed = true, status = "healthy", version = "1.0.0", expected_version = "1.0.0", helper_available = true, authorization_available = true, - enable_command = "sudo systemctl enable --now noctalia-drive-health.timer", - disable_command = "sudo systemctl disable --now noctalia-drive-health.timer", - install_command = "sudo '/mock/plugin/packaging/install-system-collector.sh'", - uninstall_command = "sudo '/mock/plugin/packaging/uninstall-system-collector.sh'" }, + enable_command = "pkexec '/usr/local/libexec/noctalia-drive-health/manage-collector.sh' start", + disable_command = "pkexec '/usr/local/libexec/noctalia-drive-health/manage-collector.sh' pause", + install_command = "pkexec '/mock/plugin/packaging/install-system-collector.sh' --interval-minutes 15", + uninstall_command = "pkexec '/usr/local/libexec/noctalia-drive-health/uninstall-collector.sh'", + interval_command = "pkexec '/usr/local/libexec/noctalia-drive-health/set-collector-interval.sh' 15", + smart_refresh_minutes = 15 }, summary = { disk_count = 2, ssd_count = 1, hdd_count = 1, smart_available_count = 2, ssd_smart_available_count = 1, hottest_drive_temperature_c = 70, hottest_drive_name = "Fixture SSD", hottest_ssd_temperature_c = 70, @@ -180,8 +182,14 @@ assert(containsText(rendered, "collector.settings_title") and containsText(rendered, "collector.full_features"), "collector settings did not explain Basic and Full SMART capabilities") onPauseCollectorClicked() -assert(terminalCommand == "sudo systemctl disable --now noctalia-drive-health.timer", - "collector settings did not expose the explicit service pause command") +assert(asyncCommand == "pkexec '/usr/local/libexec/noctalia-drive-health/manage-collector.sh' pause", + "collector settings did not use the fixed Polkit pause helper") +assert(terminalCommand == nil, "collector pause opened a terminal") +assert(containsText(rendered, "privileged_action.authorizing"), + "collector pause did not show authorization progress") +asyncCallback({ exitCode = 126, stdout = "", stderr = "", timedOut = false }) +assert(errors[#errors].body:match("privileged_action.failed"), + "cancelled collector pause did not report a useful error") terminalCommand = nil onOpenPluginSettingsClicked() assert(asyncCommand == "noctalia msg settings-open plugins", @@ -383,10 +391,20 @@ onCancelDrivePreferencesClicked() state.snapshot.dependencies = { ready = false, blocking = true, missing_text = "lsblk (util-linux)", - install_command = "sudo pacman -S --needed util-linux", package_manager = "pacman", can_install = true, + install_command = "pkexec pacman -S --needed --noconfirm util-linux", package_manager = "pacman", can_install = true, } watchers.snapshot(state.snapshot) assert(containsText(rendered, "dependencies.title"), "missing dependency card did not render") +asyncCommand = nil +asyncCallback = nil +terminalCommand = nil +onInstallDependenciesClicked() +assert(asyncCommand == "pkexec pacman -S --needed --noconfirm util-linux", + "dependency installation did not use Polkit") +assert(terminalCommand == nil, "dependency installation opened a terminal") +asyncCallback({ exitCode = 126, stdout = "", stderr = "", timedOut = false }) +assert(errors[#errors].body:match("privileged_action.failed"), + "cancelled dependency installation did not report an error") state.snapshot.dependencies = { ready = true } state.snapshot.disks = { state.snapshot.disks[2] } @@ -401,4 +419,39 @@ assert(not containsText(rendered, "summary.lowest_ssd_life"), assert(countText(rendered, "Fixture HDD") >= 2, "HDD-only temperature summary did not identify its drive") +-- Changing the full-SMART schedule uses Polkit in the background, rather than +-- placing a password-bearing command in a terminal. Keep the panel open so +-- the in-progress and error states are visible to the user. +onToggleCollectorSettingsClicked() +asyncCommand = nil +asyncCallback = nil +terminalCommand = nil +local refreshNonceBeforeInterval = state.refresh_nonce or 0 +onApplyCollectorIntervalClicked() +assert(asyncCommand == "pkexec '/usr/local/libexec/noctalia-drive-health/set-collector-interval.sh' 15", + "collector interval update did not use the Polkit helper") +assert(terminalCommand == nil, "collector interval update opened a terminal") +assert(containsText(rendered, "collector.interval_authorizing"), + "collector interval authorization progress was not shown") +local intervalSuccess = assert(asyncCallback, "collector interval callback was not registered") +intervalSuccess({ exitCode = 0, stdout = "", stderr = "", timedOut = false }) +assert((state.refresh_nonce or 0) == refreshNonceBeforeInterval + 1, + "successful collector interval update did not refresh the panel") +assert(notifications[#notifications].body == "collector.interval_applied", + "successful collector interval update did not notify the user") + +onApplyCollectorIntervalClicked() +local intervalCancelled = assert(asyncCallback, "second collector interval callback was not registered") +intervalCancelled({ exitCode = 126, stdout = "", stderr = "", timedOut = false }) +assert(containsText(rendered, "collector.interval_failed"), + "cancelled collector interval authorization did not render an inline result") +assert(errors[#errors].body == "collector.interval_cancelled", + "cancelled collector interval authorization did not notify the user") + +onApplyCollectorIntervalClicked() +local intervalRejected = assert(asyncCallback, "failed collector interval callback was not registered") +intervalRejected({ exitCode = 1, stdout = "", stderr = "Authentication failed", timedOut = false }) +assert(errors[#errors].body == "Authentication failed", + "collector interval authentication failure did not show the returned error") + print("panel rendering tests passed") diff --git a/drive-health/tests/test_collect_raw.sh b/drive-health/tests/test_collect_raw.sh index 10487b5..0d5d42a 100755 --- a/drive-health/tests/test_collect_raw.sh +++ b/drive-health/tests/test_collect_raw.sh @@ -7,7 +7,7 @@ fixture_bin="$project_dir/tests/fixtures/bin" payload=$(PATH="$fixture_bin:$PATH" sh "$project_dir/scripts/collect_raw.sh") printf '%s\n' "$payload" | jq -e ' .schema == 2 - and .collector_version == "2.0.0" + and .collector_version == "2.0.1" and (.collection_id | type == "string" and length > 0) and ([.lsblk.blockdevices[].name] | sort) == ["/dev/nvme0n1", "/dev/sda", "/dev/zram0"] and (.smart | length) == 2 diff --git a/drive-health/tests/test_packaging.sh b/drive-health/tests/test_packaging.sh index 4972447..7cfeec4 100644 --- a/drive-health/tests/test_packaging.sh +++ b/drive-health/tests/test_packaging.sh @@ -4,6 +4,8 @@ set -eu project_dir=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) service_template="$project_dir/packaging/noctalia-drive-health.service.in" timer="$project_dir/packaging/noctalia-drive-health.timer" +interval_script="$project_dir/packaging/set-collector-interval.sh" +manage_script="$project_dir/packaging/manage-collector.sh" fixture=$(mktemp -d "${TMPDIR:-/tmp}/drive-health-packaging.XXXXXX") trap 'rm -rf -- "$fixture"' EXIT HUP INT TERM @@ -14,6 +16,12 @@ grep -q '^Group=1000$' "$fixture/noctalia-drive-health.service" grep -q '^RuntimeDirectoryMode=0750$' "$fixture/noctalia-drive-health.service" grep -q '^UMask=0027$' "$fixture/noctalia-drive-health.service" grep -q '^Unit=noctalia-drive-health.service$' "$fixture/noctalia-drive-health.timer" +grep -q '^OnUnitActiveSec=15min$' "$fixture/noctalia-drive-health.timer" + +bash -n "$interval_script" +bash -n "$manage_script" +grep -q 'packaging/manage-collector.sh' "$project_dir/packaging/install-system-collector.sh" +grep -q 'packaging/uninstall-system-collector.sh' "$project_dir/packaging/install-system-collector.sh" if grep -R -q 'noctalia-smart-monito[r]' "$project_dir"; then echo "generic legacy collector namespace must not be read, modified, or removed" >&2 @@ -27,7 +35,7 @@ fi declared_dependencies=$(sed -n 's/^dependencies = \[\(.*\)\]$/\1/p' "$project_dir/plugin.toml") for dependency in \ - lsblk smartctl sh date dirname mkdir mktemp rm sed cat chmod mv sudo env bash \ + lsblk smartctl sh date dirname mkdir mktemp rm sed cat chmod mv env bash \ install systemctl pkexec id tr pacman apt-get dnf zypper apk xbps-install emerge; do case "$declared_dependencies" in *\"$dependency\"*) ;; diff --git a/drive-health/translations/en.json b/drive-health/translations/en.json index e116e31..09b945c 100644 --- a/drive-health/translations/en.json +++ b/drive-health/translations/en.json @@ -37,31 +37,39 @@ "warning_temperature_time_increase": "{drive} accumulated {count} new minute(s) above its warning temperature." }, "collector": { + "action_install": "install or upgrade the collector", + "action_pause": "pause the collector", + "action_remove": "remove the collector", + "action_start": "start the collector", "basic_features": "Drive discovery, mounted folders, storage use, and temperatures exposed by Linux.", "basic_title": "Basic monitoring — no elevated service", "copy_install": "Copy command", "full_features": "Reliable health, endurance, error counters, sensor details, and background self-test progress.", "full_title": "Full SMART — optional", "install": "Install collector", + "interval": "Full SMART refresh: every {minutes} min", + "interval_applied": "Full SMART refresh schedule updated.", + "interval_authorizing": "Waiting for administrator approval to update the full SMART schedule…", + "interval_cancelled": "Administrator approval was cancelled.", + "interval_command_failed": "The administrator command did not complete.", + "interval_failed": "Could not update the full SMART schedule: {error}", + "interval_launch_failed": "Could not open the administrator authorization dialog.", + "interval_timeout": "Administrator approval timed out. Try again when you are ready to approve it.", "open_settings": "Open Plugins page", "pause": "Pause service", - "pause_terminal_opened": "A terminal opened. Review the command and approve sudo to stop the collector timer.", "remove": "Remove collector", - "remove_confirm": "Click Remove collector again to open the explicit privileged uninstall command.", - "settings_hint": "Enable or disable Full SMART from Settings → Plugins → Drive Health. Privileged actions always open a terminal for review.", + "remove_confirm": "Click Remove collector again to request authorization for the explicit uninstall action.", + "settings_hint": "Enable or disable Full SMART from Settings → Plugins → Drive Health. All privileged actions use the desktop authorization dialog.", "settings_opened": "Opened the Plugins page. Select the gear on Drive Health to change its settings.", "settings_title": "Collector settings", "start": "Start collector", - "start_terminal_opened": "A terminal opened. Review the command and approve sudo to enable and refresh the collector.", "status_disabled": "Basic mode is active; the optional system collector is not being used.", "status_healthy": "Collector {version} is installed and refreshing full SMART data.", "status_not_installed": "Install the read-only collector to unlock full SMART data.", "status_stale": "The collector is installed, but its cache is stale or unavailable.", "status_upgrade_required": "Collector {version} is outdated; upgrade to the bundled version.", "stop_service": "Stop background service", - "terminal_opened": "A terminal opened. Review the command and approve sudo to install or upgrade.", "title": "System collector", - "uninstall_terminal_opened": "A terminal opened. Review the removal command and approve sudo to continue.", "update_body": "Full SMART is enabled. Update collector {current} to {expected} from the Drive Health panel to keep all features working.", "update_title": "Drive Health collector update available", "upgrade": "Upgrade collector" @@ -75,16 +83,17 @@ "unknown": "unknown" }, "dependencies": { + "action_install": "install dependencies", "alert_body": "Install the missing dependencies to enable complete monitoring: {missing}. Open the Drive Health panel to review the command.", "alert_title": "Drive Health setup required", "collection_blocked": "Drive collection is paused because required commands are missing: {missing}", "command": "Suggested {manager} command", "copied": "Installation command copied.", "copy_command": "Copy command", - "install": "Open installer", - "install_body": "A terminal is opening with the suggested command. Review it and approve the sudo and package-manager prompts to continue.", - "install_title": "Review the dependency installation", - "manual_install": "No supported package manager with sudo was detected. Install the listed commands manually, then recheck.", + "install": "Install", + "install_body": "The desktop administrator authorization dialog will request approval before installing the required packages.", + "install_title": "Install dependencies", + "manual_install": "No supported package manager with desktop authorization was detected. Install the listed commands manually, then recheck.", "missing": "Missing commands: {missing}", "package_manager": "package manager", "recheck": "Recheck", @@ -160,6 +169,15 @@ "title": "Drive preferences", "warning_temperature": "Warning °C" }, + "privileged_action": { + "authorizing": "Waiting for administrator approval to {action}…", + "cancelled": "Administrator approval was cancelled.", + "command_failed": "The administrator command did not complete.", + "completed": "Completed: {action}.", + "failed": "Could not {action}: {error}", + "launch_failed": "Could not open the administrator authorization dialog.", + "timeout": "Administrator approval timed out. Try again when you are ready to approve it." + }, "self_test": { "authorization_cancelled": "Administrator approval was cancelled.", "authorization_required": "Polkit (pkexec) is required for background self-tests. Install your distribution's polkit package.", @@ -199,6 +217,10 @@ "description": "Warn when an established internal drive disappears for several scans.", "label": "Missing-drive alerts" }, + "full_smart_refresh_minutes": { + "description": "Minutes between privileged full SMART reads. Default: 15 minutes. Change the value here, then use Apply schedule in the collector controls and approve the administrator prompt.", + "label": "Full SMART refresh interval" + }, "history_interval_minutes": { "description": "Minutes between persisted temperature and endurance samples.", "label": "History sample interval" @@ -220,7 +242,7 @@ "label": "Recovery notifications" }, "refresh_seconds": { - "description": "Seconds between plugin refreshes. The optional root collector refreshes its cache every 30 seconds independently.", + "description": "Seconds between lightweight plugin refreshes for drive inventory, mounts, and available sysfs temperatures.", "label": "Refresh interval" }, "show_hdd": {