diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 7f1dc558..ccdcf5ad 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -256,7 +256,7 @@ engine's globals. Every chunk you author gets it: `main.lua`, your | `require("ffi")`, `require("love.*")` | the `love` table you are given | | `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` | | `love.thread`, `love.event` | `mod.events`, `mod.hooks` | -| `love.system` | `mod.device:powerInfo()` for battery information | +| `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge | 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 629ced50..69519e2b 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -528,3 +528,27 @@ local state, percent = mod.device:powerInfo() `"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. + +## Real-world steps + +On iOS and Android the game counts the player's real-world steps natively +(HealthKit / the hardware step counter). A mod reaches that bridge through +the `steps` permission in `manifest.json`, which the player sees in the +mod manager like every other permission: + +```lua +if mod.steps:available() then + mod.steps:sync() -- async; OS consent sheet on first use +end +-- later, at a quiet moment: +local walk = mod.steps:poll() -- { steps = n, from = ?, to = ? } or nil +``` + +`available()` is `false` on builds without the bridge (desktop) and for +mods without the permission, so a probe is always safe. `sync()` asks the +platform to refresh its count and returns whether there was a bridge to +ask. `poll()` returns the next delivery for this mod — the engine consumes +the native side's pending file itself, each permissioned mod receives its +own copy of a delivery, and steps are anchored natively so the same walk +is never delivered twice. Without the permission, `sync` and `poll` raise +an error naming it. diff --git a/docs/rfcs/0009-step-bridge-permission.md b/docs/rfcs/0009-step-bridge-permission.md new file mode 100644 index 00000000..b0462c31 --- /dev/null +++ b/docs/rfcs/0009-step-bridge-permission.md @@ -0,0 +1,68 @@ +# RFC 0009 — Permission-gated step bridge for sandboxed mods + +## Status + +Proposed. Engine: `Steps.lua` (new), `Loader.lua`, `Manifest.lua`, +`Sandbox.lua`. Test: `tests/modkit/cases/steps_bridge.lua`. Issue: #1186. + +## Motivation + +The iOS and Android builds count the player's real-world steps natively +(#452, #489), exposed to Lua as `love.system.syncHealthSteps()` and +delivered as `steps_pending.json` in the save-directory root. The sandbox +correctly blocks both — `love.system` also launches URLs, and the file API +names paths — but that leaves the bridge with no consumer: the mod that +step counting was built for (Pokéwalker, steps→EXP) can no longer be +written. + +## The decision it extends + +Extends the mod sandbox in `src/mods/Sandbox.lua` (blocked host modules +stay blocked; legitimate operations receive narrow engine-owned facades) +and the permission model `network` established: a `manifest.json` +permission the player sees in the mod manager that genuinely gates a +capability. + +## The exact API delta + +A new manifest permission token, `steps`, and a `mod.steps` facade: + +- `mod.steps:available() -> boolean` — whether this build carries the + native bridge. Answers `false` without the permission, so a probe stays + quiet. +- `mod.steps:sync() -> boolean` — asks the platform to refresh its count + (async; the OS consent sheet still appears on first use, exactly as + before the sandbox). `false` when there is no bridge. +- `mod.steps:poll() -> { steps = n, from = iso?, to = iso? } | nil` — the + next delivery for this mod, engine-consumed from the pending file. Each + permissioned mod receives its own copy of a delivery. + +Without the permission, `sync` and `poll` raise an error naming the +missing permission, the way the network gate does. The engine owns the +pending file: mods never learn its name or location, and only the three +contract fields travel. No new event, hook, or registry names. + +## Migration note for existing mods + +Mods that called `love.system.syncHealthSteps()` and read +`steps_pending.json` themselves add `"steps"` to `permissions` and switch +to `mod.steps:sync()` / `mod.steps:poll()`. No other mod changes. + +## Parity tests + +- **No mod:** with nothing installed the bridge is never called and a + pending file on disk is left untouched. +- **Mod API:** a fixture mod with the permission syncs and receives a + delivery through the public loader; two permissioned mods both receive + the same walk; a second poll returns nil. +- **No permission:** `available()` is false and the acting calls name the + missing permission; the sandbox suite keeps proving direct + `love.system` access is refused. +- **Malformed delivery:** a bad or empty pending file is dropped whole + rather than crashing a poll (the native anchor only advances on a + successful sync, so nothing is lost). + +## Deprecation etiquette + +Nothing deprecated. The facade is additive; the sandbox's `love.system` +and `love.filesystem` blocks remain in force. diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 15a7e0a6..7253d5b2 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -20,6 +20,7 @@ local Events = require("src.mods.Events") local Gen2Compat = require("src.mods.Gen2Compat") local Hooks = require("src.mods.Hooks") local Runtime = require("src.mods.Runtime") +local Steps = require("src.mods.Steps") local Loader = {} Loader.__index = Loader @@ -251,7 +252,7 @@ function Loader.new(opts) events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, exports = {}, migrations = {}, order = {}, modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {}, - modInput = {}, modEnv = {}, + modInput = {}, modEnv = {}, stepsQueues = {}, fs = (opts and opts.fs) or (love and love.filesystem), dev = dev, -- Which generation this boot is (1 or 2). Fixed at construction: the @@ -1009,6 +1010,30 @@ function Loader:_api(mod) return state, percent end, }, + -- The native step bridge (#1186), behind the "steps" permission the + -- player sees in the mod manager: sync asks the platform to refresh + -- its count, poll hands this mod its copy of what the bridge + -- delivered. The engine owns the pending file -- a mod never names a + -- path, it only receives { steps, from, to }. available() answers + -- false without the permission (a probe stays quiet); the calls that + -- would do something name the missing permission instead, the way the + -- network gate does. + steps = (function() + if mod.manifest.permissionSet.steps then + loader.stepsQueues[modId] = loader.stepsQueues[modId] or {} + return { + available = function() return Steps.available() end, + sync = function() return Steps.sync() end, + poll = function() return Steps.poll(loader, modId) end, + } + end + local function refuse() + error(('[%s] mod.steps needs the "steps" permission in ' + .. "manifest.json"):format(modId), 2) + end + return { available = function() return false end, + sync = refuse, poll = refuse } + end)(), -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final save = { @@ -1226,6 +1251,7 @@ function Loader:_rollback(modId) self.optionSchemas[modId] = nil self.migrations[modId] = nil self.modSave[modId] = nil + self.stepsQueues[modId] = nil end -- a mod that explicitly swears it stays link-compatible while writing into a diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 373797a8..58c7fa26 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -10,7 +10,8 @@ local Version = require("src.core.Version") local Manifest = {} Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true } -Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true } +Manifest.PERMISSIONS = { network = true, filesystem = true, + engine_internals = true, steps = true } -- link-relevant registries; a mod that writes into one of these while -- declaring affects_link = false gets an attributed warning from the loader diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua index eb261888..195ad3d2 100644 --- a/src/mods/Sandbox.lua +++ b/src/mods/Sandbox.lua @@ -69,7 +69,8 @@ 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 = "mod.device:powerInfo() for battery information", event = true, + system = "mod.device:powerInfo() for battery information, mod.steps for " + .. "the step bridge", event = true, } local loveProxy diff --git a/src/mods/Steps.lua b/src/mods/Steps.lua new file mode 100644 index 00000000..4261d89a --- /dev/null +++ b/src/mods/Steps.lua @@ -0,0 +1,84 @@ +-- The scoped seam for the native step bridge (#1186). +-- +-- The iOS/Android builds count the player's real-world steps natively +-- (#452, #489) and deliver them by writing steps_pending.json into the +-- save-directory root. Before the sandbox, the Pokéwalker mod called +-- love.system.syncHealthSteps() and consumed that file itself; the sandbox +-- blocks both, which is correct -- love.system launches URLs and the file +-- API names paths -- but it left the bridge with no consumer at all. +-- +-- This module is the narrow replacement, gated by the "steps" permission +-- in manifest.json (the network model: a permission the player sees that +-- genuinely gates a capability). The engine owns the file: mods never +-- learn its name or location, they receive only the three contract fields +-- ({ steps, from, to }), each permissioned mod gets its own copy, and the +-- merge-don't-overwrite anchor semantics stay on the native side where +-- they always lived. +-- +-- No frame pump: the file is looked for lazily when a mod polls, so a +-- build with no permissioned mod installed never touches the bridge or +-- the disk. + +local Json = require("src.link.Json") + +local Steps = {} + +-- The native contract's drop point, in the save-directory root (see +-- mobile/ios and mobile/android step bridges). +Steps.PENDING = "steps_pending.json" + +local function bridge() + return _G.love and _G.love.system and _G.love.system.syncHealthSteps +end + +-- Whether this build carries the native bridge. Desktop builds do not; +-- a mod uses this to stay dormant without probing love.system. +function Steps.available() + return bridge() ~= nil +end + +-- Ask the native side to refresh its count. Async: the result lands in +-- the pending file and comes back through a later poll. The platform's +-- own consent sheet (HealthKit / ACTIVITY_RECOGNITION) still appears on +-- first use, exactly as it did pre-sandbox. false when there is no +-- bridge to ask. +function Steps.sync() + local fn = bridge() + if not fn then return false end + fn() + return true +end + +-- Consume the pending file, if one has appeared, and fan its payload out +-- to every permissioned mod's queue. Only the contract fields travel; +-- anything else in the file stays in the file's grave. A malformed or +-- empty delivery is dropped whole -- the native anchor only advances on a +-- successful sync, so nothing is lost to a bad write. +function Steps.pump(loader) + local fs = _G.love and _G.love.filesystem + if not (fs and fs.getInfo(Steps.PENDING, "file")) then return end + local raw = fs.read(Steps.PENDING) + fs.remove(Steps.PENDING) + if not raw then return end + local ok, decoded = pcall(Json.decode, raw) + if not ok or type(decoded) ~= "table" then return end + local steps = tonumber(decoded.steps) + if not steps or steps <= 0 then return end + local payload = { steps = steps, from = decoded.from, to = decoded.to } + for _, queue in pairs(loader.stepsQueues) do + queue[#queue + 1] = { steps = payload.steps, from = payload.from, + to = payload.to } + end +end + +-- The next delivery for this mod, or nil. Each permissioned mod consumes +-- its own queue, so two mods both see the same walk (pre-sandbox, whoever +-- read the file first won). +function Steps.poll(loader, modId) + Steps.pump(loader) + local queue = loader.stepsQueues[modId] + if not queue then return nil end + return table.remove(queue, 1) +end + +return Steps diff --git a/tests/modkit/cases/steps_bridge.lua b/tests/modkit/cases/steps_bridge.lua new file mode 100644 index 00000000..37199c64 --- /dev/null +++ b/tests/modkit/cases/steps_bridge.lua @@ -0,0 +1,141 @@ +-- The "steps" permission gates the native step bridge (#1186): a +-- permissioned mod syncs and polls deliveries without ever seeing +-- love.system or the pending file; an unpermissioned mod gets a quiet +-- available() = false and loud, permission-naming refusals from the +-- calls that would act. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Steps = require("src.mods.Steps") + +local WALKER = { + ["mods/step_walker/manifest.json"] = [[{ + "id": "step_walker", + "name": "Step Walker", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "permissions": ["steps"] + }]], + ["mods/step_walker/main.lua"] = [[ + local mod = ... + mod.exports.available = mod.steps:available() + mod.exports.synced = mod.steps:sync() + mod.exports.poll = function() return mod.steps:poll() end + ]], +} + +local SECOND = { + ["mods/step_second/manifest.json"] = [[{ + "id": "step_second", + "name": "Step Second", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "permissions": ["steps"] + }]], + ["mods/step_second/main.lua"] = [[ + local mod = ... + mod.exports.poll = function() return mod.steps:poll() end + ]], +} + +local UNPERMISSIONED = { + ["mods/step_probe/manifest.json"] = [[{ + "id": "step_probe", + "name": "Step Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/step_probe/main.lua"] = [[ + local mod = ... + mod.exports.available = mod.steps:available() + local ok, err = pcall(function() return mod.steps:sync() end) + mod.exports.refused = not ok and tostring(err) or false + ]], +} + +local function merged(...) + local out = {} + for _, fixture in ipairs({ ... }) do + for path, body in pairs(fixture) do out[path] = body end + end + return out +end + +local savedSync = T.love.system.syncHealthSteps +local syncCalls = 0 +T.love.system.syncHealthSteps = function() + syncCalls = syncCalls + 1 + return true +end + +-- no mod: the bridge stays cold and a pending delivery stays on disk +T.love.filesystem.write(Steps.PENDING, '{"steps": 4312}') +local vanilla = T.sdk.loadNone({}) +T.eq(syncCalls, 0, "no mod leaves the step bridge cold") +T.check(T.love.filesystem.read(Steps.PENDING) ~= nil, + "no mod leaves the pending delivery untouched") +vanilla.release() + +-- a permissioned mod syncs and receives the delivery +local run = T.sdk.loadMods({ "mods/step_walker", "mods/step_second" }, + { fs = T.sdk.memfs(merged(WALKER, SECOND)) }) +T.eq(#run.errors, 0, + "the permissioned mods load clean (" .. tostring(run.errors[1]) .. ")") +local walker = run.loader.exports.step_walker +T.eq(walker.available, true, "available() sees the bridge") +T.eq(walker.synced, true, "sync() reaches the bridge") +T.eq(syncCalls, 1, "one sync call makes one bridge call") + +local delivery = walker.poll() +T.check(delivery and delivery.steps == 4312, + "poll() hands the mod the delivered step count") +T.check(T.love.filesystem.read(Steps.PENDING) == nil, + "the engine consumed the pending file, not the mod") +T.eq(walker.poll(), nil, "a delivery is handed out once per mod") + +local second = run.loader.exports.step_second +local secondDelivery = second.poll() +T.check(secondDelivery and secondDelivery.steps == 4312, + "a second permissioned mod receives its own copy of the walk") +T.check(secondDelivery ~= delivery, "copies, not a shared table") + +-- contract fields only, and a malformed delivery is dropped whole +T.love.filesystem.write(Steps.PENDING, + '{"steps": 12, "from": "a", "to": "b", "path": "/etc/passwd"}') +delivery = walker.poll() +T.eq(delivery.steps, 12, "the steps field travels") +T.eq(delivery.from, "a", "the from field travels") +T.eq(delivery.path, nil, "fields outside the contract do not travel") +T.love.filesystem.write(Steps.PENDING, "not json at all") +T.eq(walker.poll(), nil, "a malformed delivery is dropped, not raised") +T.check(T.love.filesystem.read(Steps.PENDING) == nil, + "and the bad file does not wedge the pump") +run.release() + +-- without the permission: quiet probe, loud act +local probe = T.sdk.loadMods({ "mods/step_probe" }, + { fs = T.sdk.memfs(UNPERMISSIONED) }) +T.eq(#probe.errors, 0, + "the unpermissioned mod loads clean (" .. tostring(probe.errors[1]) .. ")") +local out = probe.loader.exports.step_probe +T.eq(out.available, false, "available() is quietly false without the permission") +T.check(out.refused and out.refused:find('"steps" permission', 1, true), + "sync() without the permission names it") +probe.release() + +-- no bridge on this build: sync reports it, nothing raises +T.love.system.syncHealthSteps = nil +local ashore = T.sdk.loadMods({ "mods/step_walker" }, + { fs = T.sdk.memfs(WALKER) }) +local dry = ashore.loader.exports.step_walker +T.eq(dry.available, false, "available() is false without a native bridge") +T.eq(dry.synced, false, "sync() reports there was no bridge to ask") +ashore.release() + +T.love.system.syncHealthSteps = savedSync +T.love.filesystem.remove(Steps.PENDING) +T.finish("steps_bridge")