// power.js — pure power/session shaping + action routing. NO I/O, no UPower. // Dual-mode: node `require` (tests) + QML import ("power.js" as Power). // Same pattern as audio.js / brightness.js / network.js. No `.pragma library`. // // The native side (Power.qml) owns UPower devices and the actual exec; this // module owns the plain-data transforms and — crucially — actionPlan(), the // single routing table that says HOW each session action is carried out. That // table is pure, so the dangerous part (which argv runs for "poweroff") is // unit-tested instead of buried in QML. // --- Battery / device shaping ---------------------------------------------- // normalizePercent(value) — UPower percentage may arrive 0..1 or 0..100. function normalizePercent(value) { var n = Number(value || 0); if (n > 0 && n <= 1) return Math.round(n * 100); return Math.round(n); } // minutesToLabel(seconds) — "unknown" / "45m" / "2h" / "1h 20m". function minutesToLabel(seconds) { var value = Math.max(0, Math.round(Number(seconds || 0) / 60)); if (value <= 0) return "unknown"; var hours = Math.floor(value / 60); var minutes = value % 60; if (hours <= 0) return minutes + "m"; if (minutes <= 0) return hours + "h"; return hours + "h " + minutes + "m"; } // cleanState(raw) — collapse a UPowerDeviceState.toString() value to a compact // token: "Fully charged" -> "full", "Pending charge" -> "pendingcharge". function cleanState(raw) { return String(raw == null ? "unknown" : raw) .toLowerCase().replace(/\s+/g, "").replace("fullycharged", "full"); } // batteryRole(percent) — theme token by charge level. function batteryRole(percent) { var p = Number(percent || 0); if (p <= 10) return "error"; if (p <= 25) return "warning"; return "ui.accent"; } // timeEstimateLabel(state, secsToFull, secsToEmpty) — charging counts up to // full, otherwise down to empty. function timeEstimateLabel(state, secsToFull, secsToEmpty) { return cleanState(state) === "charging" ? minutesToLabel(secsToFull) : minutesToLabel(secsToEmpty); } function hasMainBattery(battery) { var b = battery || {}; var state = cleanState(b.state); return Number(b.percent || 0) > 0 || state !== "unknown" || String(b.timeEstimate || "unknown") !== "unknown" || Number(b.health || -1) >= 0; } // isLowPower(state) — running on battery (drives global animation throttling). function isLowPower(state) { var s = cleanState(state); return s.indexOf("discharging") >= 0 || s.indexOf("pendingdischarge") >= 0; } // --- Session actions ------------------------------------------------------- var SESSION_ACTIONS = ["lock", "suspend", "monitor-off", "monitor-on", "logout", "reboot", "poweroff"]; var DESTRUCTIVE_ACTIONS = ["logout", "reboot", "poweroff"]; // requiresConfirmation(action) — irreversible session actions are two-step. // suspend is recoverable, so it fires immediately. function requiresConfirmation(action) { return DESTRUCTIVE_ACTIONS.indexOf(String(action)) >= 0; } // actionPlan(action) — the routing table. Returns how Power.qml executes it: // { via: "lock" } -> lockService.request() // { via: "niri", args: [...] } -> niriService.action(args) // { via: "exec", argv: [...] } -> Quickshell.execDetached(argv) // { via: "none" } -> unknown action, do nothing // argv/args are discrete tokens (never a shell string) — no injection surface. function actionPlan(action) { switch (String(action)) { case "lock": return { via: "lock" }; case "monitor-off": return { via: "niri", args: ["power-off-monitors"] }; case "monitor-on": return { via: "niri", args: ["power-on-monitors"] }; // niri owns the session; quit ends it. --skip-confirmation because the // popup already two-step confirms (avoids niri's own Enter prompt). case "logout": return { via: "niri", args: ["quit", "--skip-confirmation"] }; case "suspend": return { via: "exec", argv: ["systemctl", "suspend"] }; case "reboot": return { via: "exec", argv: ["systemctl", "reboot"] }; case "poweroff": return { via: "exec", argv: ["systemctl", "poweroff"] }; default: return { via: "none" }; } } // actionLabel(action) — short grid label. function actionLabel(action) { if (action === "monitor-off") return "mon off"; if (action === "monitor-on") return "mon on"; if (action === "poweroff") return "power"; return String(action); } if (typeof module !== 'undefined' && module.exports) { module.exports = { normalizePercent: normalizePercent, minutesToLabel: minutesToLabel, cleanState: cleanState, batteryRole: batteryRole, timeEstimateLabel: timeEstimateLabel, hasMainBattery: hasMainBattery, isLowPower: isLowPower, SESSION_ACTIONS: SESSION_ACTIONS, DESTRUCTIVE_ACTIONS: DESTRUCTIVE_ACTIONS, requiresConfirmation: requiresConfirmation, actionPlan: actionPlan, actionLabel: actionLabel }; }