From e54ae3afb9f78c0886363de4e6e79d82848000b0 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 22 Sep 2026 08:55:28 -0500 Subject: [PATCH] feat(rust): mod configuration from the site, and an editor that will not rewrite a float MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R18's two tiers: a form generated from a config file's own values, and raw JSON for what a form cannot express. Admin → Rust mod config, one live round trip per action, nothing cached between a browser and a game host's disk. `configEdit.js` is the part that could not be done naively. JavaScript cannot tell `1` from `1.0`, and both mod frameworks deserialize a config into typed C# classes — so a read-modify-write silently rewrites every whole-numbered float as an integer on fields nobody touched, and a plugin that then throws at load does not come back. It never parses, mutates and re-serialises: it records the SOURCE SPAN of every value and splices literals into them, so an untouched `1.0` is still `1.0` and a number an admin types travels as text the whole way (D35/D36). The bridge's own config is editable with `Host`, `Port` and `ServerId` locked, in the form and in the raw tier, because either would cut the link carrying the edit or strand every row this site holds (D38). Credentials render masked with a reveal; the raw tier shows them (D37) and the audit trail never does. `rust_config_writes` records every save including the refused and the rolled back — an operator asking why a setting is not what they set needs to see that somebody tried. Three defects a browser walk found that 179 green tests did not: * every save of the bridge's own config was refused while the page said the opposite — a ` onChange(field, event.target.checked)} + /> + ) : ( + onChange(field, event.target.value)} + /> + )} + {field.secret && !field.locked && ( + + )} + + ) +} + +/** What the game said happened. The rollback case is the one worth reading. */ +function Report({ report }) { + if (!report) return null + + const tone = report.rolledBack ? '#e05a5a' : 'var(--ink)' + + return ( +
+

+ {report.rolledBack + ? 'The plugin did not come back, so the old file was put back automatically.' + : report.reloaded + ? 'Saved, and the plugin reloaded.' + : `Saved. ${report.reason || 'Nothing was reloaded.'}`} +

+ {report.rolledBack && report.reason && ( +

+ {report.reason} +

+ )} + {report.log && ( +
+          {report.log}
+        
+ )} + {report.files.some((f) => f.rewritten) && ( + + The plugin rewrote the file as it loaded — both frameworks add any settings a config is + missing and save it back, so what is on disk now is not byte-for-byte what was sent. + + )} +
+ ) +} + +export default function ModConfig() { + const [serverId, setServerId] = useState('') + const [path, setPath] = useState('') + const [tier, setTier] = useState('form') + const [edits, setEdits] = useState({}) + const [raw, setRaw] = useState('') + const [reload, setReload] = useState('') + const [revealed, setRevealed] = useState({}) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [report, setReport] = useState(null) + const [fileNonce, setFileNonce] = useState(0) + + const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), []) + + // The tree is asked for per server and never cached across one: what is on a + // host's disk has no stale answer worth showing, and a plugin loaded a minute + // ago has to be able to appear. + const { data: tree, error: treeError } = useAsync( + () => (serverId ? api.adminConfig.files(serverId) : Promise.resolve(null)), + [serverId], + ) + + const { data: file, error: fileError } = useAsync( + () => (serverId && path ? api.adminConfig.file(serverId, path) : Promise.resolve(null)), + [serverId, path, fileNonce], + ) + + const reset = useCallback(() => { + setEdits({}) + setRevealed({}) + setError('') + }, []) + + // A freshly opened file starts from what the host holds: the raw editor's text + // and the reload target's guess both come from the answer rather than from + // whatever the previous file left behind. + // + // **The guess is only taken when the dropdown actually offers it.** A ` setServerId(event.target.value)}> + + {rows.map((row) => ( + + ))} + + {tree && tree.root && ( +

+ {tree.root} + {tree.truncated ? ' · the walk stopped at its limit, so this is not the whole tree' : ''} +

+ )} + + + {serverId && treeError && } + + {serverId && !treeError && !tree && } + + {tree && ( + + {tree.plugins.length === 0 && ( +

+ This server reports no configuration files. +

+ )} + {tree.plugins.map((group) => ( +
+
+ + {group.title || group.plugin} + + + {group.loaded ? `loaded · ${group.version}` : 'not loaded'} + {group.isBridge ? ' · this bridge' : ''} + +
+ {group.files.map((entry) => ( +
+ + + {Math.round(entry.bytes / 102.4) / 10} KB + {entry.modified ? ` · changed ${ago(entry.modified)}` : ''} + {entry.reason ? ` · ${entry.reason}` : ''} + +
+ ))} + {!group.loaded && ( + + Nothing on this server is loaded under that name, so a save here is written and + not reloaded. It applies the next time the plugin loads. + + )} +
+ ))} +
+ )} + + {path && fileError && } + {path && !fileError && !file && } + + {file && ( + + + + + } + > + {file.parseError && ( + + This file is not valid JSON on the server ({file.parseError}), so there is nothing to + draw a form from. Raw JSON is the tier that can fix it. + + )} + + {file.isBridge && ( + + This is the bridge’s own configuration. Its address, port and server id are read-only + here — changing any of them from the website would cut the link carrying the change, + or strand every row this site holds for this server. They are editable on the host + itself. This plugin also cannot be reloaded from here. + + )} + + {tier === 'form' && file.fields && ( +
+ {file.fields + .filter((field) => field.path !== '') + .map((field) => ( + setRevealed((current) => ({ ...current, [p]: !current[p] }))} + /> + ))} +
+ )} + + {tier === 'raw' && ( +