Render complete Steam artwork for every sideload and fix VR shortcut identity

Add optional SteamGridDB settings, source-first fallbacks rendered with Steam canvas, backfill commands and shared APK/native library details. Verify live artwork and Open Saber Steam Play/Stop; document SuperTux's clipboard crash.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
saphidandGPT-6 Astra committed 2026-09-28 22:20:27 +10:00
1 parent c06b3285f2
commit f695f398de
18 files changed
+1097 -289

No files matched your search

+7
View File
@@ -20,6 +20,13 @@ done
# A number that isn't a real Steam app; it names this app's Lepton context.
export SteamAppId="$(cat "$DIR/instance.id")"
[[ "$SteamAppId" =~ ^[0-9]+$ ]] || { echo "invalid instance.id" >&2; exit 1; }
# Keep the stable Lepton context, but identify the Android VR client as its
# actual Steam shortcut. Lepton applies LEPTON_ENV_* after its own passthrough.
if [[ -f "$DIR/shortcut.id" ]]; then
shortcut="$(cat "$DIR/shortcut.id")"
[[ "$shortcut" =~ ^[0-9]+$ ]] || { echo "invalid shortcut.id" >&2; exit 1; }
export LEPTON_ENV_SteamAppId="$shortcut"
fi
exec 9>"$DIR/launch.lock"
flock -n 9 || { echo "Android app is already running" >&2; exit 1; }
CONTAINER="lepton-steamlaunch-$SteamAppId"
+144
View File
@@ -0,0 +1,144 @@
// Runs in Steam's Chromium context: identical fonts/rendering from every host OS.
async function renderLibraryArtwork(input) {
const sizes = {grid:[600,900], wide:[920,430], hero:[3840,1240], logo:[1280,480], icon:[256,256]};
const label = String(input.label || 'Untitled').trim().slice(0,180);
const font = '"Motiva Sans", "Noto Sans", Arial, sans-serif';
await document.fonts.load(`800 120px ${font}`, label);
const images = {}, warnings = [];
for (const [slot, item] of Object.entries(input.images || {})) {
try {
const img = new Image();
img.src = `data:image/${item[0]};base64,${item[1]}`;
await img.decode();
if (!img.width || !img.height || img.width*img.height > 8000000) throw Error('dimensions');
images[slot] = img;
} catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); }
}
let icon = images.icon;
if (icon) {
// Remove only a near-black matte connected to the outside of an opaque icon.
const cut=document.createElement('canvas');cut.width=icon.width;cut.height=icon.height;
const c=cut.getContext('2d');c.drawImage(icon,0,0);
const pixels=c.getImageData(0,0,cut.width,cut.height), d=pixels.data, w=cut.width,h=cut.height;
const corners=[0,w-1,(h-1)*w,h*w-1];
if(corners.every(i=>d[i*4+3]>250 && Math.max(d[i*4],d[i*4+1],d[i*4+2])<24)) {
const seen=new Uint8Array(w*h), queue=corners.slice();
for(let q=0;q<queue.length;q++) {
const i=queue[q];if(seen[i])continue;seen[i]=1;
if(Math.max(d[i*4],d[i*4+1],d[i*4+2])>24)continue;
d[i*4+3]=0;
if(i%w)queue.push(i-1);if(i%w<w-1)queue.push(i+1);
if(i>=w)queue.push(i-w);if(i<w*(h-1))queue.push(i+w);
}
c.putImageData(pixels,0,0);icon=cut;
}
}
// Quantized, saturated dominant colors avoid white/black icon margins.
let colors = [[48,91,137], [24,36,63]];
if (icon) {
const sample = document.createElement('canvas'); sample.width=48; sample.height=48;
const s=sample.getContext('2d'); s.drawImage(icon,0,0,48,48);
const data=s.getImageData(0,0,48,48).data, bins=new Map();
for (let i=0;i<data.length;i+=4) {
const rgb=[data[i],data[i+1],data[i+2]], hi=Math.max(...rgb), lo=Math.min(...rgb);
if (data[i+3]<150 || hi<45 || lo>220 || hi-lo<25) continue;
const key=rgb.map(v=>Math.round(v/32)*32).join(',');
bins.set(key,(bins.get(key)||0)+1);
}
const ranked=[...bins].sort((a,b)=>b[1]-a[1]);
if (ranked.length) {
colors[0]=ranked[0][0].split(',').map(Number);
colors[1]=(ranked.find(([key])=>key.split(',').reduce((n,v,i)=>n+Math.abs(Number(v)-colors[0][i]),0)>170)||ranked[0])[0].split(',').map(Number);
}
}
// Preserve hue while lifting muted icon colors into a richer background palette.
colors=colors.map(c=>{const low=Math.min(...c),range=Math.max(...c)-low||1;
return c.map(v=>45+(v-low)/range*165);});
const rgb=(c,a=1)=>`rgba(${c.map(v=>Math.min(255,Math.round(v))).join(',')},${a})`;
function image(ctx,img,x,y,w,h,cover=false) {
const scale=cover?Math.max(w/img.width,h/img.height):Math.min(w/img.width,h/img.height);
const dw=img.width*scale,dh=img.height*scale;
ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip();
ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); ctx.restore();
}
function title(ctx,w,h,top,bottom,maxSize) {
let lines=[],size=maxSize;
const maxWidth=w*.84;
for (;size>=18;size-=2) {
ctx.font=`800 ${size}px ${font}`;
lines=[]; let line='';
for (const word of label.split(/\s+/)) {
const next=line?line+' '+word:word;
if (line && ctx.measureText(next).width>maxWidth) {lines.push(line);line=word;} else line=next;
}
lines.push(line);
if (lines.length*size*1.08<=bottom-top && lines.every(l=>ctx.measureText(l).width<=maxWidth)) break;
}
if(lines.length===2) {
const words=lines[0].split(' ');
if(words.length>1) {
const first=words.slice(0,-1).join(' '), second=words.slice(-1)[0]+' '+lines[1];
if(ctx.measureText(second).width<=maxWidth &&
Math.abs(ctx.measureText(first).width-ctx.measureText(second).width)<
Math.abs(ctx.measureText(lines[0]).width-ctx.measureText(lines[1]).width)) lines=[first,second];
}
}
// A long unbroken label is still fitted, including scripts without spaces.
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillStyle='#fff';
ctx.shadowColor='rgba(0,0,0,.45)'; ctx.shadowBlur=size*.28; ctx.shadowOffsetY=size*.06;
let y=top+(bottom-top-lines.length*size*1.08)/2+size*.54;
for (const line of lines) {ctx.fillText(line,w/2,y,maxWidth); y+=size*1.08;}
ctx.shadowBlur=0; ctx.shadowOffsetY=0;
}
const result={};
for (const [slot,[w,h]] of Object.entries(sizes)) {
const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h;
const ctx=canvas.getContext('2d'); ctx.imageSmoothingQuality='high';
const direct=images[slot];
const feature=images.feature_graphic||images.banner;
const scene=direct || ((slot==='hero'||slot==='wide') && (feature||images.screenshot));
if (scene) {
if (slot==='logo'||slot==='icon') image(ctx,scene,0,0,w,h);
else image(ctx,scene,0,0,w,h,true);
} else if (slot==='logo') {
title(ctx,w,h,h*.08,h*.92,150);
} else {
const gradient=ctx.createLinearGradient(0,0,w,h);
gradient.addColorStop(0,rgb(colors[0].map(v=>v*.68)));
gradient.addColorStop(.6,rgb(colors[1].map(v=>v*.32)));
gradient.addColorStop(1,'#080c16'); ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h);
if (icon) {
ctx.save();ctx.globalAlpha=.16;ctx.filter=`blur(${Math.round(w*.055)}px) saturate(1.4)`;
image(ctx,icon,-w*.15,-h*.15,w*1.3,h*1.3,true);ctx.restore();
}
const glow=ctx.createRadialGradient(w*.5,h*.32,0,w*.5,h*.32,w*.8);
glow.addColorStop(0,rgb(colors[0],.27));glow.addColorStop(1,rgb(colors[1],0));
ctx.fillStyle=glow;ctx.fillRect(0,0,w,h);
const vignette=ctx.createLinearGradient(0,h*.25,0,h);
vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.56)');
ctx.fillStyle=vignette;ctx.fillRect(0,0,w,h);
const box=slot==='grid'?[w*.12,h*.14,w*.76,w*.76]:
slot==='wide'?[w*.36,h*.06,w*.28,h*.59]:
slot==='hero'?[w*.365,h*.12,w*.27,h*.78]:[w*.08,h*.08,w*.84,h*.84];
if (icon) {
ctx.save();ctx.shadowColor='rgba(0,0,0,.65)';ctx.shadowBlur=Math.min(w,h)*.055;
ctx.shadowOffsetY=Math.min(w,h)*.022;
// Opaque square icons read as deliberate app tiles, not pasted rectangles.
const [x,y,bw,bh]=box, side=Math.min(bw,bh);
if(slot!=='hero' && icon===images.icon) {
ctx.beginPath();ctx.roundRect(x+(bw-side)/2,y+(bh-side)/2,side,side,side*.14);ctx.clip();
}
image(ctx,icon,...box);ctx.restore();
} else if (slot !== 'hero') {
// A typographic monogram when the APK contains no usable image.
ctx.font=`800 ${Math.min(w,h)*.48}px ${font}`;ctx.fillStyle='rgba(255,255,255,.94)';
ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText([...label][0]||'A',w/2,h*.38);
}
if (slot==='grid') title(ctx,w,h,h*.7,h*.93,66);
if (slot==='wide') title(ctx,w,h,h*.69,h*.92,52);
// Hero intentionally has no title: Steam overlays the transparent logo.
}
result[slot]=canvas.toDataURL('image/png').split(',')[1];
}
return {images:result,warnings,font};
}
+78 -13
View File
@@ -9,7 +9,7 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
steam_shortcuts.py stop APPID
steam_shortcuts.py remove APPID
"""
import base64, json, os, socket, struct, sys, urllib.request
import base64, json, os, re, socket, struct, sys, urllib.request
DEVTOOLS = 'http://127.0.0.1:8080/json'
@@ -89,8 +89,8 @@ def evaluate(js):
ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4}
def collections_js(appid, vr=None):
wanted = [] if vr is None else ['Android', *(['Android VR'] if vr else [])]
def collections_js(appid, vr=None, category='Android'):
wanted = [] if vr is None else [category, *(['Android VR'] if vr and category == 'Android' else [])]
return f'''async function syncCollections() {{
const wanted = {json.dumps(wanted)};
if (typeof collectionStore === "undefined" ||
@@ -100,7 +100,7 @@ def collections_js(appid, vr=None):
return ["Steam collections API unavailable"];
const app = {{appid: {appid}}};
const warnings = [];
for (const name of ["Android", "Android VR"]) {{
for (const name of ["Android", "Android VR", "Sideloaded"]) {{
const matches = collectionStore.GetUserCollectionsByName(name);
let collection = matches.find(c => !c.bIsDynamic && c.bAllowsDragAndDrop);
if (wanted.includes(name)) {{
@@ -123,7 +123,65 @@ def collections_js(appid, vr=None):
}}'''
def configure(appid, name, exe, start_dir, icon, vr, artwork):
def notes_js(name, details):
filename = 'notes_shortcut_' + re.sub(r'[!-/:-@ \[\\\]\^`]', '_', name.strip())
content = '\n'.join(str(details[k]) for k in ('package', 'version', 'source') if details.get(k))
return f'''if (SteamClient.GameNotes && typeof SteamClient.GameNotes.GetNotes === "function" &&
typeof SteamClient.GameNotes.SaveNotes === "function") {{
try {{
const file = {json.dumps(filename)};
const previous = await SteamClient.GameNotes.GetNotes(file, file + "_images/");
if (previous.result !== 1 && previous.result !== 9) throw Error("read " + previous.result);
const data = previous.result === 1 ? JSON.parse(previous.notes) : {{notes: [], shortcut_name: {json.dumps(name)}}};
if (!Array.isArray(data.notes)) throw Error("unexpected notes format");
const id = "frame-control-library", now = Math.floor(Date.now()/1000);
const old = data.notes.find(n => n.id === id);
const note = {{id, shortcut_name: {json.dumps(name)}, title: "Installation details",
content: {json.dumps(content)}, ordinal: old ? old.ordinal : data.notes.length,
time_created: old ? old.time_created : now, time_modified: now}};
data.notes = data.notes.filter(n => n.id !== id).concat([note]);
const result = await SteamClient.GameNotes.SaveNotes(file, JSON.stringify(data));
if (result !== 1) throw Error("save " + result);
}} catch (e) {{ warnings.push("Steam notes: " + String(e)); }}
}}'''
def render(plan):
with open(plan) as f:
source = json.load(f)
images = {}
for slot, path in source['images'].items():
ext = os.path.splitext(path)[1][1:]
with open(path, 'rb') as f:
data = f.read(12 * 1024 * 1024 + 1)
if len(data) > 12 * 1024 * 1024:
raise ValueError('source artwork too large')
images[slot] = [ext, base64.b64encode(data).decode()]
renderer = globals().get('ART_RENDERER')
if renderer is None:
with open(os.path.join(os.path.dirname(__file__), 'library_artwork.js')) as f:
renderer = f.read()
result = evaluate(renderer + '\nrenderLibraryArtwork(' + json.dumps({'label': source['label'], 'images': images}) + ')')
if not isinstance(result, dict) or set(result.get('images', {})) != set(ASSETS):
raise ValueError('incomplete artwork render')
paths = {}
for slot, encoded in result['images'].items():
path = os.path.join(os.path.dirname(plan), slot + '.png')
data = base64.b64decode(encoded, validate=True)
if not data.startswith(b'\x89PNG\r\n\x1a\n') or len(data) > 12 * 1024 * 1024:
raise ValueError('invalid rendered image')
with open(path + '.tmp', 'wb') as f:
f.write(data)
os.replace(path + '.tmp', path)
paths[slot] = path
return {'paths': paths, 'warnings': result.get('warnings', [])}
def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
options = options or {}
if set(artwork) != set(ASSETS):
raise ValueError('all five Steam artwork slots are required')
images = []
for slot, path in artwork.items():
if slot not in ASSETS:
@@ -135,12 +193,15 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork):
data = f.read(12 * 1024 * 1024 + 1)
if len(data) > 12 * 1024 * 1024:
raise ValueError('artwork is too large')
images.append([ASSETS[slot], ext, base64.b64encode(data).decode()])
if slot != 'icon': # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon.
images.append([ASSETS[slot], ext, base64.b64encode(data).decode()])
return evaluate(f'''(async () => {{
const id = {int(appid)}, warnings = [];
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)});
SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)});
if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
if (typeof SteamClient.Apps.SetShortcutSortAs === "function")
SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)});
SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
if (typeof SteamClient.Apps.SetShortcutIsVR === "function")
SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)});
@@ -148,10 +209,11 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork):
if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{
for (const [type, ext, data] of {json.dumps(images)})
await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type);
}} else warnings.push("Steam artwork API unavailable");
{collections_js(int(appid), vr)}
}} else throw new Error("Steam artwork API unavailable; installation is incomplete");
{collections_js(int(appid), vr, options.get('category', 'Android'))}
try {{ warnings.push(...await syncCollections()); }}
catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
{notes_js(name, options.get('details', {}))}
return {{warnings}};
}})()''')
@@ -162,7 +224,7 @@ def remove(appid):
{collections_js(int(appid))}
const warnings = await syncCollections();
if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{
for (const type of [0, 1, 2, 3, 4])
for (const type of [0, 1, 2, 3])
await SteamClient.Apps.ClearCustomArtworkForApp(id, type);
}} else throw new Error("Steam artwork removal API unavailable");
SteamClient.Apps.RemoveShortcut(id);
@@ -185,10 +247,13 @@ def main():
print(evaluate(js))
elif cmd == 'list':
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
.map(a => ({appid: a.appid, name: a.display_name})))()'''
.map(a => ({appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid})))()'''
print(json.dumps(evaluate(js)))
elif cmd == 'render':
print(json.dumps(render(args[0])))
elif cmd == 'configure':
print(json.dumps(configure(int(args[0]), *args[1:5], args[5] == '1', json.loads(args[6]))))
print(json.dumps(configure(int(args[0]), *args[1:5], args[5] == '1', json.loads(args[6]),
json.loads(args[7]) if len(args) > 7 else None)))
elif cmd == 'stop':
evaluate(f'SteamClient.Apps.TerminateApp({json.dumps(str((int(args[0]) << 32) | 0x02000000))}, false)')
print('stopping')