Artwork settings: send the UI key through api(), so the panel and refresh work

Every /api call needs X-Frame-UI; the panel's own fetch() got 403s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 23:06:04 +10:00
1 parent 8315c7c3aa
commit 060801c674
2 files changed
+60 -11

No files matched your search

+50
View File
@@ -235,6 +235,56 @@ class ServerGuards(unittest.TestCase):
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
class ArtworkSettings(unittest.TestCase):
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
def test_settings_need_and_accept_the_ui_key(self):
with tempfile.TemporaryDirectory() as home:
port = free_port()
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1",
"HOME": home, "APPDATA": home, "XDG_DATA_HOME": home}
for name in ("STEAMGRIDDB_API_KEY", "FRAME_STEAMGRIDDB_API_KEY", "FRAME_UI_KEY"):
env.pop(name, None)
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(port)],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
def request(method, path, body=None, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
conn.request(method, path, body=json.dumps(body).encode() if body is not None else None,
headers=headers or {})
r = conn.getresponse()
payload = r.read()
conn.close()
return r.status, payload
for _ in range(100):
try:
if request("GET", "/")[0] == 200:
break
except OSError:
time.sleep(0.05)
key = {"X-Frame-UI": "1", "Content-Type": "application/json"}
self.assertEqual(request("GET", "/api/settings/artwork")[0], 403)
self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403)
status, payload = request("GET", "/api/settings/artwork", headers=key)
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False))
status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key)
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, True))
self.assertNotIn(b"abc_1", payload)
status, payload = request("GET", "/api/settings/artwork", headers=key)
self.assertTrue(json.loads(payload)["steamgriddb_configured"])
self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "a b"}, key)[0], 400)
finally:
proc.terminate()
proc.wait(timeout=10)
def test_panel_script_uses_the_keyed_api_helper(self):
script = (ROOT / "ui" / "artwork-settings.js").read_text()
self.assertNotIn("fetch(", script)
self.assertIn("api('/api/settings/artwork'", script)
page = (ROOT / "ui" / "index.html").read_text()
self.assertLess(page.index("async function api("), page.index('<script src="/artwork-settings.js">'))
class LocalMode(unittest.TestCase):
"""FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key
guards /api/, and ssh goes to ui/local-bin/ssh, which runs commands here."""
+10 -11
View File
@@ -1,15 +1,12 @@
// Uses index.html's api(), which sends the X-Frame-UI key every /api call needs.
(() => {
const get=id=>document.getElementById(id), status=get('steamGridStatus');
async function request(url,body) {
const r=await fetch(url,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const data=await r.json(); if(!r.ok) throw Error(data.error||'Request failed'); return data;
}
function show(data) {
status.textContent=data.environment?'SteamGridDB key is set by an environment variable.':
data.steamgriddb_configured?'SteamGridDB key saved.':'No key configured. Source images and generated art are enabled.';
}
async function save(value) {
try {show(await request('/api/settings/artwork',{steamgriddb_api_key:value}));get('steamGridKey').value='';}
try {show(await api('/api/settings/artwork',{steamgriddb_api_key:value}));get('steamGridKey').value='';}
catch(e) {status.textContent=e.message;}
}
get('saveSteamGridKey').onclick=()=>save(get('steamGridKey').value.trim());
@@ -17,15 +14,17 @@
get('refreshAndroidArt').onclick=async()=>{
const button=get('refreshAndroidArt');button.disabled=true;
try {
const result=await runJob('Refresh Android artwork','android-artwork',()=>request('/api/android',{action:'refresh-art',all:true}));
const result=await runJob('Refresh library artwork','library-artwork',()=>api('/api/android',{action:'refresh-art',all:true}));
if (result) {
const apps=Array.isArray(result.apps)?result.apps:[result.apps];
const failed=apps.filter(a=>a.error);
status.textContent=failed.length?`${failed.length} refresh failed: ${failed.map(a=>a.package+': '+a.error).join('; ')}`:
`Refreshed artwork for ${apps.length} apps.`;
const items=[...(result.apps||[]),...(result.titles||[])];
const failed=items.filter(a=>a.error);
status.textContent=failed.length?`${failed.length} of ${items.length} failed: ${failed.map(a=>(a.package||a.id)+': '+a.error).join('; ')}`:
`Refreshed artwork for ${items.length} apps and titles.`;
if (typeof loadAndroid==='function') loadAndroid();
if (typeof loadTitles==='function') loadTitles();
}
} catch(e) {status.textContent=e.message;}
finally {button.disabled=false;}
};
request('/api/settings/artwork').then(show).catch(e=>{status.textContent=e.message;});
api('/api/settings/artwork').then(show).catch(e=>{status.textContent=e.message;});
})();