Expose read-only device power info to mods

This commit is contained in:
AverageConsumer
2026-08-12 22:13:32 +02:00
parent d38faab03c
commit e44769a48a
7 changed files with 130 additions and 3 deletions
+2 -1
View File
@@ -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 | | `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules |
| `require("ffi")`, `require("love.*")` | the `love` table you are given | | `require("ffi")`, `require("love.*")` | the `love` table you are given |
| `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` | | `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 The rest of `love` passes through unchanged, so graphics, audio, timers and
input work as they always have. input work as they always have.
+14
View File
@@ -514,3 +514,17 @@ local both = mod.datetime:dateTime(game, createdAt)
The live `game` supplies only the current option context. Formatting never The live `game` supplies only the current option context. Formatting never
mutates the save, options, or timestamp, and invalid timestamps return 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.
+49
View File
@@ -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.
+10
View File
@@ -999,6 +999,16 @@ function Loader:_api(mod)
return DateTime.dateTime(game, timestamp) return DateTime.dateTime(game, timestamp)
end, 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 / -- namespaced per mod; M11 backs these with save.modData /
-- options.modOptions, the shape mods compile against is already final -- options.modOptions, the shape mods compile against is already final
save = { save = {
+1 -1
View File
@@ -69,7 +69,7 @@ end
-- value is the replacement to name in the error, or true when there is none -- value is the replacement to name in the error, or true when there is none
local BLOCKED_LOVE = { local BLOCKED_LOVE = {
filesystem = "mod.storage and mod:read", thread = true, filesystem = "mod.storage and mod:read", thread = true,
system = true, event = true, system = "mod.device:powerInfo() for battery information", event = true,
} }
local loveProxy local loveProxy
+52
View File
@@ -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")
+2 -1
View File
@@ -125,7 +125,8 @@ T.eq(type(out.requireSemver), "table",
T.check(out.loveFilesystem and out.loveFilesystem:find("mod.storage", 1, true), T.check(out.loveFilesystem and out.loveFilesystem:find("mod.storage", 1, true),
"love.filesystem is refused and names the replacement") "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.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.eq(out.loveGraphics, "table", "the rest of love passes through")
T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade") T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade")