mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +02:00
Permission-gated step bridge for sandboxed mods
The sandbox blocks love.system and love.filesystem, which orphans the native step bridge (#452, #489): its one consumer can no longer call syncHealthSteps or read steps_pending.json (#1186). Adds a "steps" manifest permission (shown to the player like the others) gating a mod.steps facade: available() probes the bridge quietly, sync() forwards the async refresh, poll() hands the mod its copy of a delivery. The engine owns the pending file -- mods never name a path and receive only { steps, from, to }. Without the permission the acting calls name it, following the network gate. No new events, hooks or registries; nothing removed. RFC 0009. Tests: tests/modkit/cases/steps_bridge.lua (no-mod cold bridge, permissioned sync/poll, per-mod copies, contract-field filtering, malformed-delivery drop, unpermissioned refusal, bridgeless build). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
| `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.event` | `mod.events`, `mod.hooks` |
|
| `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
|
The rest of `love` passes through unchanged, so graphics, audio, timers and
|
||||||
input work as they always have.
|
input work as they always have.
|
||||||
|
|||||||
@@ -528,3 +528,27 @@ local state, percent = mod.device:powerInfo()
|
|||||||
`"charging"`, or `"charged"`. `percent` is `0` through `100`, or `nil` when
|
`"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
|
the platform cannot report it. The facade is read-only and does not expose
|
||||||
URL launching, clipboard access, or other system operations.
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
+27
-1
@@ -20,6 +20,7 @@ local Events = require("src.mods.Events")
|
|||||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||||
local Hooks = require("src.mods.Hooks")
|
local Hooks = require("src.mods.Hooks")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Steps = require("src.mods.Steps")
|
||||||
|
|
||||||
local Loader = {}
|
local Loader = {}
|
||||||
Loader.__index = Loader
|
Loader.__index = Loader
|
||||||
@@ -251,7 +252,7 @@ function Loader.new(opts)
|
|||||||
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||||
exports = {}, migrations = {}, order = {},
|
exports = {}, migrations = {}, order = {},
|
||||||
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
||||||
modInput = {}, modEnv = {},
|
modInput = {}, modEnv = {}, stepsQueues = {},
|
||||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||||
dev = dev,
|
dev = dev,
|
||||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||||
@@ -1009,6 +1010,30 @@ function Loader:_api(mod)
|
|||||||
return state, percent
|
return state, percent
|
||||||
end,
|
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 /
|
-- 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 = {
|
||||||
@@ -1226,6 +1251,7 @@ function Loader:_rollback(modId)
|
|||||||
self.optionSchemas[modId] = nil
|
self.optionSchemas[modId] = nil
|
||||||
self.migrations[modId] = nil
|
self.migrations[modId] = nil
|
||||||
self.modSave[modId] = nil
|
self.modSave[modId] = nil
|
||||||
|
self.stepsQueues[modId] = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- a mod that explicitly swears it stays link-compatible while writing into a
|
-- a mod that explicitly swears it stays link-compatible while writing into a
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ local Version = require("src.core.Version")
|
|||||||
local Manifest = {}
|
local Manifest = {}
|
||||||
|
|
||||||
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
|
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
|
-- link-relevant registries; a mod that writes into one of these while
|
||||||
-- declaring affects_link = false gets an attributed warning from the loader
|
-- declaring affects_link = false gets an attributed warning from the loader
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ 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 = "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
|
local loveProxy
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user