From e44769a48a4e885ff0522dd0ca6e694464237288 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:13:32 +0200 Subject: [PATCH] Expose read-only device power info to mods --- CONTRIBUTING-mods.md | 3 +- docs/modding.md | 14 +++++++ docs/rfcs/0008-device-power-info.md | 49 ++++++++++++++++++++++ src/mods/Loader.lua | 10 +++++ src/mods/Sandbox.lua | 2 +- tests/modkit/cases/device_power_info.lua | 52 ++++++++++++++++++++++++ tests/modkit/cases/sandbox.lua | 3 +- 7 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 docs/rfcs/0008-device-power-info.md create mode 100644 tests/modkit/cases/device_power_info.lua diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index d6148691..7f1dc558 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -255,7 +255,8 @@ engine's globals. Every chunk you author gets it: `main.lua`, your | `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules | | `require("ffi")`, `require("love.*")` | the `love` table you are given | | `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` | -| `love.thread`, `love.system`, `love.event` | `mod.events`, `mod.hooks` | +| `love.thread`, `love.event` | `mod.events`, `mod.hooks` | +| `love.system` | `mod.device:powerInfo()` for battery information | The rest of `love` passes through unchanged, so graphics, audio, timers and input work as they always have. diff --git a/docs/modding.md b/docs/modding.md index cfb70eb0..629ced50 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -514,3 +514,17 @@ local both = mod.datetime:dateTime(game, createdAt) The live `game` supplies only the current option context. Formatting never mutates the save, options, or timestamp, and invalid timestamps return `"----"`. + +## Device power information + +Sandboxed mods can read the host's battery state without receiving the rest +of `love.system`: + +```lua +local state, percent = mod.device:powerInfo() +``` + +`state` follows LÖVE's values: `"unknown"`, `"battery"`, `"nobattery"`, +`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when +the platform cannot report it. The facade is read-only and does not expose +URL launching, clipboard access, or other system operations. diff --git a/docs/rfcs/0008-device-power-info.md b/docs/rfcs/0008-device-power-info.md new file mode 100644 index 00000000..cafe668f --- /dev/null +++ b/docs/rfcs/0008-device-power-info.md @@ -0,0 +1,49 @@ +# RFC 0008 — Read-only device power information for sandboxed mods + +## Status + +Proposed. Engine: `Loader.lua`, `Sandbox.lua`. Test: +`tests/modkit/cases/device_power_info.lua`. + +## Motivation + +A handheld UI mod can show the player's battery state and warn before power +loss. The sandbox correctly removes `love.system` because that module also +launches URLs and exposes other host operations, but it leaves no scoped way +to read the harmless power values that LÖVE already provides. + +## The decision it extends + +Extends the mod sandbox in `src/mods/Sandbox.lua`: blocked host modules stay +blocked while legitimate operations receive narrow engine-owned facades. + +## The exact API delta + +Add `mod.device:powerInfo() -> state, percent`. + +The engine calls `love.system.getPowerInfo()` outside the mod sandbox and +returns only its first two values. `state` is one of LÖVE's standard power +states. `percent` is `0` through `100` or `nil`. When the platform has no +power-information backend, the result is `"unknown", nil`. + +No permission grants access to `love.system`; URL launching, clipboard access, +OS identification, and the module table itself remain unavailable. + +## Migration note for existing mods + +Mods that used `love.system.getPowerInfo()` replace that call with +`mod.device:powerInfo()`. No other mod changes. + +## Parity tests + +- **No mod:** loading no mods does not call the platform power backend. +- **Mod API:** a fixture mod loaded through the public loader receives state + and percentage through `mod.device`, while the existing sandbox suite keeps + proving that direct `love.system` access is refused. +- **Unavailable backend:** the public facade returns `"unknown", nil` rather + than inventing battery data or failing mod load. + +## Deprecation etiquette + +Nothing deprecated. The facade is additive; the sandbox's `love.system` block +remains in force. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 798f6def..15a7e0a6 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -999,6 +999,16 @@ function Loader:_api(mod) return DateTime.dateTime(game, timestamp) end, }, + -- The read-only part of love.system that device UIs legitimately need. + -- Do not expose the module: openURL and clipboard access stay sandboxed. + device = { + powerInfo = function() + local getPowerInfo = love and love.system and love.system.getPowerInfo + if not getPowerInfo then return "unknown", nil end + local state, percent = getPowerInfo() + return state, percent + end, + }, -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final save = { diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua index 1e18a4cf..eb261888 100644 --- a/src/mods/Sandbox.lua +++ b/src/mods/Sandbox.lua @@ -69,7 +69,7 @@ end -- value is the replacement to name in the error, or true when there is none local BLOCKED_LOVE = { filesystem = "mod.storage and mod:read", thread = true, - system = true, event = true, + system = "mod.device:powerInfo() for battery information", event = true, } local loveProxy diff --git a/tests/modkit/cases/device_power_info.lua b/tests/modkit/cases/device_power_info.lua new file mode 100644 index 00000000..f66f6af1 --- /dev/null +++ b/tests/modkit/cases/device_power_info.lua @@ -0,0 +1,52 @@ +-- A sandboxed mod may read battery state without receiving love.system and +-- its process-launching surface. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") + +local FIXTURE = { + ["mods/power_probe/manifest.json"] = [[{ + "id": "power_probe", + "name": "Power Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/power_probe/main.lua"] = [[ + local mod = ... + mod.exports.state, mod.exports.percent = mod.device:powerInfo() + ]], +} + +local saved = T.love.system.getPowerInfo +local calls = 0 +T.love.system.getPowerInfo = function() + calls = calls + 1 + return "charging", 42, 900 +end + +local vanilla = T.sdk.loadNone({}) +T.eq(calls, 0, "no mod leaves the device power backend cold") +vanilla.release() + +local run = T.sdk.loadMods({ "mods/power_probe" }, + { fs = T.sdk.memfs(FIXTURE) }) +T.eq(#run.errors, 0, + "the sandboxed power probe loads clean (" .. tostring(run.errors[1]) .. ")") +local out = run.loader.exports.power_probe or {} +T.eq(out.state, "charging", "the public facade reports battery state") +T.eq(out.percent, 42, "the public facade reports battery percentage") +T.eq(calls, 1, "one facade read makes one platform call") +run.release() + +T.love.system.getPowerInfo = nil +local unavailable = T.sdk.loadMods({ "mods/power_probe" }, + { fs = T.sdk.memfs(FIXTURE) }) +out = unavailable.loader.exports.power_probe or {} +T.eq(out.state, "unknown", "a missing platform backend has a stable state") +T.eq(out.percent, nil, "a missing platform backend has no invented percentage") +unavailable.release() + +T.love.system.getPowerInfo = saved +T.finish("device_power_info") diff --git a/tests/modkit/cases/sandbox.lua b/tests/modkit/cases/sandbox.lua index dff6767a..d9f629ea 100644 --- a/tests/modkit/cases/sandbox.lua +++ b/tests/modkit/cases/sandbox.lua @@ -125,7 +125,8 @@ T.eq(type(out.requireSemver), "table", T.check(out.loveFilesystem and out.loveFilesystem:find("mod.storage", 1, true), "love.filesystem is refused and names the replacement") T.check(out.loveThread ~= false, "love.thread is refused: it opens a full Lua state") -T.check(out.loveSystem ~= false, "love.system is refused: openURL launches anything") +T.check(out.loveSystem and out.loveSystem:find("mod.device:powerInfo()", 1, true), + "love.system is refused and names the scoped power replacement") T.eq(out.loveGraphics, "table", "the rest of love passes through") T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade")