mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Steam library: safe removal and title matching, artwork within Steam's limit
- Remove: collections and artwork clearing are best effort in Steam's JS, and the host carries on to delete the files when Steam isn't running (Android apps and devkit titles). - Devkit titles: the shortcut is found by devkit id, the saved id, or an executable/start folder inside the title's folder; never by display name. Steam's overviews don't carry devkit_gameid (checked on the Frame 2026-09-28), so 'list' now reads exe/start dir from app details. - Photo-based grid/wide/hero slots render as JPEG (a noise-heavy 3840x1240 hero was over 12 MiB as PNG on the Frame); the logo stays transparent PNG. Rendering gets 75 s and retries once with generated art. Each slot is cleared before it is set, since Steam keeps .png and .jpg side by side. - Devkit titles keep their own VR flag (vr=None skips SetShortcutIsVR) and their Sideloaded collection. - A failed title install's cleanup can't replace the original error. - refresh_art for devkit titles (frame_titles.refresh_art). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
baefa105a9
commit
f0db805d4b
9 files changed
+378
-85
No files matched your search
@@ -10,7 +10,7 @@ async function renderLibraryArtwork(input) {
|
||||
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');
|
||||
if (!img.width || !img.height || img.width*img.height > 16777216) throw Error('dimensions');
|
||||
images[slot] = img;
|
||||
} catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); }
|
||||
}
|
||||
@@ -138,7 +138,9 @@ async function renderLibraryArtwork(input) {
|
||||
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];
|
||||
// Photos as PNG can pass Steam's 12 MiB limit at hero size; the logo keeps its transparency.
|
||||
const jpeg=scene && slot!=='logo' && slot!=='icon';
|
||||
result[slot]=[jpeg?'jpg':'png', canvas.toDataURL(jpeg?'image/jpeg':'image/png',.9).split(',')[1]];
|
||||
}
|
||||
return {images:result,warnings,font};
|
||||
}
|
||||
@@ -24,10 +24,10 @@ def target_ws():
|
||||
class WS:
|
||||
"""Just enough RFC 6455 for one CDP request/response on loopback."""
|
||||
|
||||
def __init__(self, url):
|
||||
def __init__(self, url, timeout=20):
|
||||
host_port, path = url[len('ws://'):].split('/', 1)
|
||||
host, port = host_port.split(':')
|
||||
self.s = socket.create_connection((host, int(port)), timeout=20)
|
||||
self.s = socket.create_connection((host, int(port)), timeout=timeout)
|
||||
key = base64.b64encode(os.urandom(16)).decode()
|
||||
self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n'
|
||||
f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n'
|
||||
@@ -71,8 +71,8 @@ class WS:
|
||||
return msg.decode()
|
||||
|
||||
|
||||
def evaluate(js):
|
||||
ws = WS(target_ws())
|
||||
def evaluate(js, timeout=20):
|
||||
ws = WS(target_ws(), timeout)
|
||||
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
|
||||
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
|
||||
while True:
|
||||
@@ -89,8 +89,8 @@ def evaluate(js):
|
||||
ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4}
|
||||
|
||||
|
||||
def collections_js(appid, vr=None, category='Android'):
|
||||
wanted = [] if vr is None else [category, *(['Android VR'] if vr and category == 'Android' else [])]
|
||||
def collections_js(appid, wanted=()):
|
||||
wanted = list(wanted)
|
||||
return f'''async function syncCollections() {{
|
||||
const wanted = {json.dumps(wanted)};
|
||||
if (typeof collectionStore === "undefined" ||
|
||||
@@ -147,6 +147,9 @@ def notes_js(name, details):
|
||||
}}'''
|
||||
|
||||
|
||||
MAX_ART = 12 * 1024 * 1024 # Steam's custom artwork limit per slot
|
||||
|
||||
|
||||
def render(plan):
|
||||
with open(plan) as f:
|
||||
source = json.load(f)
|
||||
@@ -154,32 +157,50 @@ def render(plan):
|
||||
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:
|
||||
data = f.read(MAX_ART + 1)
|
||||
if len(data) > MAX_ART:
|
||||
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}) + ')')
|
||||
try:
|
||||
return _render(plan, renderer, source['label'], images)
|
||||
except (ValueError, OSError, EOFError, SystemExit) as e:
|
||||
# Generated art from the icon alone always fits; a photo that didn't must not fail the install.
|
||||
result = _render(plan, renderer, source['label'], {k: v for k, v in images.items() if k == 'icon'})
|
||||
result['warnings'].insert(0, 'Source artwork could not be rendered (' + str(e)[:120] + '); generated art used')
|
||||
return result
|
||||
|
||||
|
||||
def _render(plan, renderer, label, images):
|
||||
# A 4K photo takes seconds to decode and encode on the Frame; allow well beyond that.
|
||||
result = evaluate(renderer + '\nrenderLibraryArtwork(' + json.dumps({'label': label, 'images': images}) + ')',
|
||||
timeout=75)
|
||||
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')
|
||||
for slot, (ext, encoded) in result['images'].items():
|
||||
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:
|
||||
signature = {'png': b'\x89PNG\r\n\x1a\n', 'jpg': b'\xff\xd8\xff'}.get(ext)
|
||||
if not signature or not data.startswith(signature) or len(data) > MAX_ART:
|
||||
raise ValueError(slot + ' render is ' + str(len(data)) + ' bytes of ' + str(ext))
|
||||
paths[slot] = os.path.join(os.path.dirname(plan), slot + '.' + ext)
|
||||
with open(paths[slot] + '.tmp', 'wb') as f:
|
||||
f.write(data)
|
||||
for slot, path in paths.items():
|
||||
os.replace(path + '.tmp', path)
|
||||
paths[slot] = path
|
||||
return {'paths': paths, 'warnings': result.get('warnings', [])}
|
||||
for stale in ('png', 'jpg'):
|
||||
other = os.path.join(os.path.dirname(plan), slot + '.' + stale)
|
||||
if other != path and os.path.exists(other):
|
||||
os.remove(other)
|
||||
return {'paths': paths, 'warnings': list(result.get('warnings', []))}
|
||||
|
||||
|
||||
def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
|
||||
options = options or {}
|
||||
category = options.get('category', 'Android')
|
||||
if set(artwork) != set(ASSETS):
|
||||
raise ValueError('all five Steam artwork slots are required')
|
||||
images = []
|
||||
@@ -190,8 +211,8 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
|
||||
if ext not in ('png', 'jpg'):
|
||||
raise ValueError('artwork must be PNG or JPEG')
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read(12 * 1024 * 1024 + 1)
|
||||
if len(data) > 12 * 1024 * 1024:
|
||||
data = f.read(MAX_ART + 1)
|
||||
if len(data) > MAX_ART:
|
||||
raise ValueError('artwork is too large')
|
||||
if slot != 'icon': # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon.
|
||||
images.append([ASSETS[slot], ext, base64.b64encode(data).decode()])
|
||||
@@ -203,30 +224,41 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
|
||||
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)});
|
||||
else warnings.push("Steam VR shortcut flag API unavailable");
|
||||
// null (devkit titles): leave the VR flag as Steam registered it.
|
||||
if ({json.dumps(vr)} !== null) {{
|
||||
if (typeof SteamClient.Apps.SetShortcutIsVR === "function")
|
||||
SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)});
|
||||
else warnings.push("Steam VR shortcut flag API unavailable");
|
||||
}}
|
||||
if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{
|
||||
for (const [type, ext, data] of {json.dumps(images)})
|
||||
for (const [type, ext, data] of {json.dumps(images)}) {{
|
||||
// Steam keeps a slot's PNG and JPEG side by side; clear it so a stale one can't win.
|
||||
if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function")
|
||||
try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}}
|
||||
await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type);
|
||||
}}
|
||||
}} else throw new Error("Steam artwork API unavailable; installation is incomplete");
|
||||
{collections_js(int(appid), vr, options.get('category', 'Android'))}
|
||||
{collections_js(int(appid), [category] + (['Android VR'] if vr and category == 'Android' else []))}
|
||||
try {{ warnings.push(...await syncCollections()); }}
|
||||
catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
|
||||
{notes_js(name, options.get('details', {}))}
|
||||
return {{warnings}};
|
||||
}})()''')
|
||||
}})()''', timeout=60)
|
||||
|
||||
|
||||
def remove(appid):
|
||||
# Collections and artwork are tidy-up: only a missing RemoveShortcut may fail the removal.
|
||||
return evaluate(f'''(async () => {{
|
||||
const id = {int(appid)};
|
||||
const id = {int(appid)}, warnings = [];
|
||||
{collections_js(int(appid))}
|
||||
const warnings = await syncCollections();
|
||||
try {{ warnings.push(...await syncCollections()); }}
|
||||
catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
|
||||
if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{
|
||||
for (const type of [0, 1, 2, 3])
|
||||
await SteamClient.Apps.ClearCustomArtworkForApp(id, type);
|
||||
}} else throw new Error("Steam artwork removal API unavailable");
|
||||
for (const type of [0, 1, 2, 3]) {{
|
||||
try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }}
|
||||
catch (e) {{ warnings.push("Steam artwork " + type + ": " + String(e)); }}
|
||||
}}
|
||||
}} else warnings.push("Steam artwork removal API unavailable");
|
||||
SteamClient.Apps.RemoveShortcut(id);
|
||||
return {{warnings}};
|
||||
}})()''')
|
||||
@@ -246,13 +278,24 @@ 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, devkit_gameid: a.devkit_gameid})))()'''
|
||||
# Overviews carry no exe or devkit id (checked 2026-09-28); app details do, once registered.
|
||||
js = '''(async () => Promise.all(appStore.allApps.filter(a => a.app_type === 1073741824).map(async a => {
|
||||
let d = typeof appDetailsStore !== "undefined" && appDetailsStore.GetAppDetails(a.appid);
|
||||
if (!d && typeof SteamClient.Apps.RegisterForAppDetails === "function") d = await new Promise(ok => {
|
||||
let reg;
|
||||
const timer = setTimeout(() => { if (reg) reg.unregister(); ok(null); }, 3000);
|
||||
reg = SteamClient.Apps.RegisterForAppDetails(a.appid, x => {
|
||||
clearTimeout(timer); setTimeout(() => reg && reg.unregister()); ok(x); });
|
||||
});
|
||||
return {appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid,
|
||||
exe: d ? d.strShortcutExe || "" : "", start_dir: d ? d.strShortcutStartDir || "" : ""};
|
||||
})))()'''
|
||||
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]),
|
||||
vr = {'1': True, '0': False}.get(args[5]) # '' leaves Steam's VR flag alone
|
||||
print(json.dumps(configure(int(args[0]), *args[1:5], vr, 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)')
|
||||
|
||||
@@ -23,7 +23,8 @@ function surface() {
|
||||
drawImage(){canvas.draws++;},getImageData:()=>({data:new Uint8ClampedArray(canvas.width*canvas.height*4)}),
|
||||
createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}})};
|
||||
for(const method of ['save','restore','beginPath','rect','roundRect','clip','fillRect','putImageData','arc','fill','stroke'])ctx[method]=()=>{};
|
||||
canvas.getContext=()=>ctx;canvas.toDataURL=()=> 'data:image/png;base64,'+png(canvas.width,canvas.height);
|
||||
canvas.getContext=()=>ctx;canvas.toDataURL=type=>type==='image/jpeg'?'data:image/jpeg;base64,'+Buffer.from('ffd8ffe000104a464946','hex').toString('base64'):
|
||||
'data:image/png;base64,'+png(canvas.width,canvas.height);
|
||||
canvases.push(canvas);return canvas;
|
||||
}};
|
||||
class Image {constructor(){this.width=2;this.height=2;} async decode(){if(this.src.includes('YmFk'))throw Error('bad image');}}
|
||||
|
||||
@@ -71,6 +71,13 @@ function build(steam) {
|
||||
NewUnsavedCollection(name, filter, apps) { return collection({name, apps: apps.map(a => a.appid)}); },
|
||||
async SaveCollection(c) { if (!steam.collections.includes(c.value)) steam.collections.push(c.value); },
|
||||
},
|
||||
// Shortcut exe/start folder live in app details, not overviews (Frame, 2026-09-28).
|
||||
appDetailsStore: {
|
||||
GetAppDetails(id) {
|
||||
const s = findShortcut(id);
|
||||
return s ? { strShortcutExe: s.exe, strShortcutStartDir: s.start_dir, bShortcutIsVR: !!s.vr } : null;
|
||||
},
|
||||
},
|
||||
appStore: {
|
||||
get allApps() { return allApps(); },
|
||||
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
||||
|
||||
+6
-1
@@ -6,8 +6,13 @@ const stub=require(process.cwd()+'/tests/fakeframe/rootfs/usr/local/lib/fakefram
|
||||
const result=await ctx.renderLibraryArtwork({label:'Example Game',images:{icon:['png','fixture']}});
|
||||
assert.deepEqual(Object.keys(result.images),['grid','wide','hero','logo','icon']);
|
||||
for(const [slot,size] of Object.entries({grid:[600,900],wide:[920,430],hero:[3840,1240],logo:[1280,480],icon:[256,256]})) {
|
||||
const b=Buffer.from(result.images[slot],'base64');assert.equal(b.readUInt32BE(16),size[0]);assert.equal(b.readUInt32BE(20),size[1]);
|
||||
assert.equal(result.images[slot][0],'png');
|
||||
const b=Buffer.from(result.images[slot][1],'base64');assert.equal(b.readUInt32BE(16),size[0]);assert.equal(b.readUInt32BE(20),size[1]);
|
||||
}
|
||||
// A photo scene is JPEG (Steam's 12 MiB limit at hero size); the logo stays transparent PNG.
|
||||
const photo=await ctx.renderLibraryArtwork({label:'Photo',images:{hero:['jpg','fixture'],banner:['jpg','fixture']}});
|
||||
for(const slot of ['wide','hero'])assert.equal(photo.images[slot][0],'jpg');
|
||||
for(const slot of ['grid','logo','icon'])assert.equal(photo.images[slot][0],'png');
|
||||
const hero=surface.canvases.find(c=>c.width===3840);assert.equal(hero.text.length,0);
|
||||
const logo=surface.canvases.find(c=>c.width===1280);assert(logo.text.length);assert.equal(logo.draws,0);
|
||||
const before=surface.canvases.length;await ctx.renderLibraryArtwork({label:'No Icon',images:{}});
|
||||
|
||||
@@ -280,19 +280,21 @@ class InstallTests(unittest.TestCase):
|
||||
self.assertIn(('remove', '3346865537'), [c.args for c in api.call_args_list])
|
||||
self.assertTrue(any(c.args[0] == 'rm -rf Applications/Android/org.test.vr' for c in ssh.call_args_list))
|
||||
|
||||
def test_remove_keeps_data_when_requested_and_surfaces_api_failure(self):
|
||||
with patch.object(android, '_meta_or_fail', return_value=dict(self.existing)), \
|
||||
def test_remove_keeps_data_when_requested_and_survives_steam_failure(self):
|
||||
with patch.object(android, '_meta_or_fail', side_effect=lambda pkg: dict(self.existing)), \
|
||||
patch.object(android, 'stop'), \
|
||||
patch.object(android, 'shortcut_tool', return_value='{"warnings": []}') as api, \
|
||||
patch.object(android, 'ssh') as ssh:
|
||||
android.remove('org.test.vr', keep_data=True)
|
||||
api.assert_called_once_with('remove', '3346865537')
|
||||
ssh.assert_called_once_with('rm -rf Applications/Android/org.test.vr')
|
||||
api.side_effect = android.FrameError('CDP unavailable')
|
||||
api.side_effect = android.FrameError('SharedJSContext not found: is the Steam client running?')
|
||||
ssh.reset_mock()
|
||||
with self.assertRaises(android.FrameError):
|
||||
android.remove('org.test.vr')
|
||||
ssh.assert_not_called()
|
||||
result = android.remove('org.test.vr')
|
||||
ssh.assert_called_once_with('rm -rf Applications/Android/org.test.vr '
|
||||
'.local/share/Steam/steamapps/compatdata/2800000001 '
|
||||
'.local/share/Steam/steamapps/shadercache/2800000001')
|
||||
self.assertIn('Steam client running', result['library_warnings'][0])
|
||||
|
||||
def test_stop_requests_steam_and_has_container_fallback(self):
|
||||
with patch.object(android, '_meta_or_fail', return_value=self.existing), \
|
||||
@@ -312,6 +314,7 @@ class SteamAPITests(unittest.TestCase):
|
||||
self.assertIn('SetShortcutIsVR(id, true)', js)
|
||||
self.assertIn('SetShortcutName(id, "A \\"name\\"\\n")', js)
|
||||
self.assertIn('SetCustomArtworkForApp(id, data, ext, type)', js)
|
||||
self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('SetCustomArtworkForApp(id, data, ext, type)'))
|
||||
self.assertEqual(shortcuts.ASSETS, {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4})
|
||||
self.assertIn('NewUnsavedCollection(name, undefined, [app])', js)
|
||||
|
||||
@@ -322,6 +325,49 @@ class SteamAPITests(unittest.TestCase):
|
||||
self.assertIn('[0, 1, 2, 3]', js)
|
||||
self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('RemoveShortcut(id)'))
|
||||
self.assertIn('const wanted = []', js)
|
||||
self.assertNotIn('throw', js) # tidy-up failures are warnings; RemoveShortcut always runs
|
||||
|
||||
def test_devkit_configure_leaves_vr_flag_and_uses_sideloaded(self):
|
||||
slots = {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}
|
||||
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
|
||||
shortcuts.configure(42, 'Game', '', '', '/icon', None, slots, {'category': 'Sideloaded'})
|
||||
js = evaluate.call_args.args[0]
|
||||
self.assertIn('if (null !== null)', js)
|
||||
self.assertIn('const wanted = ["Sideloaded"]', js)
|
||||
with patch.object(sys, 'argv', ['steam_shortcuts.py', 'configure', '42', 'Game', '', '', '/icon', '',
|
||||
json.dumps(slots), '{}']), \
|
||||
patch.object(shortcuts, 'configure', return_value={}) as configure, patch('builtins.print'):
|
||||
shortcuts.main()
|
||||
self.assertIsNone(configure.call_args.args[5])
|
||||
|
||||
def test_render_writes_jpeg_and_retries_oversized_photo_with_generated_art(self):
|
||||
import base64
|
||||
png = base64.b64encode((FIXTURES / 'icon.png').read_bytes()).decode()
|
||||
jpg = base64.b64encode((FIXTURES / 'icon.jpg').read_bytes()).decode()
|
||||
huge = base64.b64encode(b'\xff\xd8\xff' + b'\0' * (12 * 1024 * 1024)).decode()
|
||||
calls = []
|
||||
def evaluate(js, timeout=20):
|
||||
calls.append((json.loads(js[js.rindex('renderLibraryArtwork(') + 21:-1]), timeout))
|
||||
hero = ['jpg', huge] if len(calls) == 1 else ['png', png]
|
||||
return {'images': {'grid': ['jpg', jpg], 'wide': ['jpg', jpg], 'hero': hero,
|
||||
'logo': ['png', png], 'icon': ['png', png]}, 'warnings': []}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for name in ('icon.png', 'hero.jpg'):
|
||||
Path(tmp, 'source-' + name).write_bytes((FIXTURES / ('icon.jpg' if name.endswith('jpg') else 'icon.png')).read_bytes())
|
||||
Path(tmp, 'hero.png').write_bytes(b'stale')
|
||||
plan = Path(tmp, 'input.json')
|
||||
plan.write_text(json.dumps({'label': 'Game', 'images': {'icon': str(Path(tmp, 'source-icon.png')),
|
||||
'hero': str(Path(tmp, 'source-hero.jpg'))}}))
|
||||
with patch.object(shortcuts, 'evaluate', side_effect=evaluate):
|
||||
result = shortcuts.render(str(plan))
|
||||
self.assertEqual(set(calls[0][0]['images']), {'icon', 'hero'})
|
||||
self.assertEqual(set(calls[1][0]['images']), {'icon'})
|
||||
self.assertEqual([c[1] for c in calls], [75, 75])
|
||||
self.assertTrue(result['paths']['grid'].endswith('grid.jpg'))
|
||||
self.assertTrue(result['paths']['hero'].endswith('hero.png'))
|
||||
self.assertIn('generated art used', result['warnings'][0])
|
||||
self.assertFalse(Path(tmp, 'hero.jpg').exists())
|
||||
self.assertEqual(Path(tmp, 'grid.jpg').read_bytes(), (FIXTURES / 'icon.jpg').read_bytes())
|
||||
|
||||
def test_stop_uses_exact_64_bit_game_id_string(self):
|
||||
with patch.object(sys, 'argv', ['steam_shortcuts.py', 'stop', '3346865537']), \
|
||||
@@ -335,7 +381,7 @@ class SteamContextTests(unittest.TestCase):
|
||||
def test_collection_lifecycle_and_native_artwork_calls(self):
|
||||
steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Before'}], 'compat_tools': {},
|
||||
'collections': [{'name': 'Android', 'apps': [999]}]}
|
||||
def evaluate(expression):
|
||||
def evaluate(expression, timeout=20):
|
||||
nonlocal steam
|
||||
proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')],
|
||||
input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True,
|
||||
@@ -357,10 +403,35 @@ class SteamContextTests(unittest.TestCase):
|
||||
{slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS})
|
||||
self.assertEqual(steam['shortcuts'][0]['name'], 'Renamed')
|
||||
self.assertEqual(steam['collections'][1]['apps'], [])
|
||||
with patch.object(sys, 'argv', ['steam_shortcuts.py', 'list']), patch('builtins.print') as out:
|
||||
shortcuts.main()
|
||||
self.assertEqual(json.loads(out.call_args.args[0]),
|
||||
[{'appid': 42, 'name': 'Renamed', 'exe': '/exe', 'start_dir': '/dir'}])
|
||||
shortcuts.remove(42)
|
||||
self.assertEqual(steam['shortcuts'], [])
|
||||
self.assertEqual(steam['collections'][0]['apps'], [999])
|
||||
|
||||
@unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
|
||||
def test_remove_without_collections_or_artwork_api_still_removes(self):
|
||||
steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Game', 'exe': '"/home/steamos/devkit-game/G/g"',
|
||||
'start_dir': '/home/steamos/devkit-game/G'}], 'compat_tools': {}}
|
||||
def evaluate(expression, timeout=20):
|
||||
nonlocal steam
|
||||
expression = ('delete globalThis.collectionStore;'
|
||||
'SteamClient.Apps.ClearCustomArtworkForApp = async () => { throw Error("busy"); };' + expression)
|
||||
proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')],
|
||||
input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True,
|
||||
'steam': steam}) + '\n',
|
||||
text=True, capture_output=True, timeout=10, check=True)
|
||||
reply = json.loads(proc.stdout)
|
||||
self.assertNotIn('exceptionDetails', reply['result'])
|
||||
steam = reply['steam']
|
||||
return reply['result']['result'].get('value')
|
||||
with patch.object(shortcuts, 'evaluate', side_effect=evaluate):
|
||||
result = shortcuts.remove(42)
|
||||
self.assertEqual(steam['shortcuts'], [])
|
||||
self.assertEqual(len(result['warnings']), 5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -110,11 +110,83 @@ class EntryPoints(unittest.TestCase):
|
||||
config = next(c.args for c in self.api.call_args_list if c.args[0] == 'configure')
|
||||
self.assertEqual(json.loads(config[8])['category'], 'Sideloaded')
|
||||
self.assertEqual(config[3:5], ('','')) # Never replace devkit's executable/runtime wiring.
|
||||
self.assertEqual(config[6], '') # nor the VR flag the title declares
|
||||
self.assertEqual(set(result['artwork']), set(artwork.SLOTS))
|
||||
|
||||
def test_native_renamed_shortcut_uses_saved_identity(self):
|
||||
self.api.side_effect = lambda *args, **kw: '[{"appid":42,"name":"Renamed"}]'
|
||||
with patch.object(titles, 'ssh', return_value='{"shortcut":42}'):
|
||||
self.assertEqual(titles._library_shortcut('Original', 'Original'), 42)
|
||||
self.assertEqual(titles._library_shortcut('Original', '/home/steamos/devkit-game/Original'), 42)
|
||||
|
||||
def test_native_shortcut_never_matched_by_name_alone(self):
|
||||
d = '/home/steamos/devkit-game/Game'
|
||||
shortcuts = [{'appid':1,'name':'Game','exe':'"/home/steamos/.local/bin/game"','start_dir':'/home/steamos'},
|
||||
{'appid':2,'name':'Other','exe':'"/home/steamos/devkit-game/Game2/g.exe"','start_dir':''}]
|
||||
self.api.side_effect = lambda *args, **kw: json.dumps(shortcuts)
|
||||
with patch.object(titles, 'ssh', return_value=''):
|
||||
self.assertIsNone(titles._library_shortcut('Game', d))
|
||||
shortcuts.append({'appid':3,'name':'Renamed','exe':'"/home/steamos/devkit-game/Game/bin/g.exe"','start_dir':''})
|
||||
self.assertEqual(titles._library_shortcut('Game', d), 3)
|
||||
shortcuts.append({'appid':4,'name':'Copy','exe':'','start_dir':d})
|
||||
with self.assertRaisesRegex(android.FrameError, 'ambiguous'):
|
||||
titles._library_shortcut('Game', d)
|
||||
|
||||
def test_native_cleanup_failure_keeps_original_error(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe',
|
||||
'runtime':'proton-experimental','source':'example.zip'}
|
||||
def ssh(cmd, **kwargs):
|
||||
if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}'
|
||||
if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}'
|
||||
return ''
|
||||
def steam(*args, **kwargs):
|
||||
if args[0] == 'remove': raise android.FrameError('Steam went away')
|
||||
return self.steam(*args)
|
||||
self.api.side_effect = steam
|
||||
with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \
|
||||
patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True), \
|
||||
patch.object(android, 'apply_library', side_effect=android.FrameError('render failed')):
|
||||
with self.assertRaisesRegex(android.FrameError, 'render failed'):
|
||||
titles._install(plan, lambda *args: None)
|
||||
|
||||
def test_native_remove_survives_steam_being_down(self):
|
||||
cmds = []
|
||||
def ssh(cmd, **kwargs):
|
||||
cmds.append(cmd)
|
||||
return 'yes' if 'test -d' in cmd else '/home/steamos' if 'HOME' in cmd else ''
|
||||
self.api.side_effect = android.FrameError('SharedJSContext not found')
|
||||
with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'):
|
||||
titles.remove('Game')
|
||||
self.assertTrue(any('steamos-delete --delete-title Game' in c for c in cmds))
|
||||
|
||||
def test_native_refresh_art_backfills_registered_title(self):
|
||||
meta = {'id':'Game','name':'My Game','source':'game.zip'}
|
||||
writes = []
|
||||
def ssh(cmd, input=None, **kwargs):
|
||||
if 'test -d' in cmd: return 'yes'
|
||||
if 'HOME' in cmd: return '/home/steamos'
|
||||
if cmd.startswith('cat devkit-game/Game-framecontrol.json'): return json.dumps(meta)
|
||||
if cmd == 'python3 -':
|
||||
self.assertIn("/home/steamos/devkit-game/Game/.frame-artwork", input)
|
||||
return json.dumps({'artwork': {'banner': 'YmFubmVy'}, 'icon': ''})
|
||||
if cmd.startswith('cat > devkit-game/Game-framecontrol.json'): writes.append(json.loads(input))
|
||||
return ''
|
||||
shortcuts = [{'appid':7,'name':'My Game','exe':'','start_dir':'/home/steamos/devkit-game/Game'}]
|
||||
self.api.side_effect = lambda *args, **kw: json.dumps(shortcuts) if args[0] == 'list' else self.steam(*args)
|
||||
with patch.object(titles, 'ssh', side_effect=ssh), \
|
||||
patch.object(artwork, 'prepare', return_value=({}, [])) as prepare:
|
||||
result = titles.refresh_art('Game')
|
||||
self.assertEqual(prepare.call_args.args[2], {'banner': b'banner'})
|
||||
self.assertEqual(result['shortcut'], 7)
|
||||
self.assertEqual(set(writes[-1]['artwork']), set(artwork.SLOTS))
|
||||
self.assert_art()
|
||||
shortcuts.clear()
|
||||
with patch.object(titles, 'ssh', side_effect=ssh), \
|
||||
patch.object(titles, 'list_titles', return_value=[{'id':'Game','name':'My Game','frame_control':True},
|
||||
{'id':'Valve','name':'V','frame_control':False}]):
|
||||
results = titles.refresh_art()
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertIn("hasn't registered", results[0]['error'])
|
||||
|
||||
def test_native_failure_removes_new_blank_shortcut(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
|
||||
+44
-25
@@ -11,7 +11,7 @@ Python stdlib only. CLI: python3 ui/frame_android.py
|
||||
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
|
||||
refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
"""
|
||||
import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
import base64, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
|
||||
|
||||
import frame_apk
|
||||
import frame_artwork
|
||||
@@ -220,9 +220,11 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
|
||||
|
||||
|
||||
def apply_library(shortcut, label, directory, images, vr=False, home=None, exe='', start_dir='', details=None,
|
||||
def apply_library(shortcut, label, directory, images, vr=None, home=None, exe='', start_dir='', details=None,
|
||||
category='Android'):
|
||||
"""Mandatory for every sideload: render all five slots before reporting success."""
|
||||
"""Mandatory for every sideload: render all five slots before reporting success.
|
||||
|
||||
vr None leaves Steam's VR flag as it is (devkit titles declare their own)."""
|
||||
home = home or ssh('echo $HOME').strip()
|
||||
d = directory
|
||||
ssh(f'mkdir -p {shlex.quote(d)}/artwork')
|
||||
@@ -235,12 +237,13 @@ def apply_library(shortcut, label, directory, images, vr=False, home=None, exe='
|
||||
plan = f'{d}/artwork/input.json'
|
||||
ssh(f'cat > {shlex.quote(plan)}', input=json.dumps(manifest))
|
||||
absolute = f'{home}/{plan}' if not plan.startswith('/') else plan
|
||||
rendered = json.loads(shortcut_tool('render', absolute, timeout=120))
|
||||
# The Frame retries once with generated art, each attempt allowed 75 s.
|
||||
rendered = json.loads(shortcut_tool('render', absolute, timeout=200))
|
||||
art = rendered['paths']
|
||||
if set(art) != set(frame_artwork.SLOTS):
|
||||
raise FrameError('Steam artwork renderer did not produce every slot')
|
||||
result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'],
|
||||
'1' if vr else '0', json.dumps(art),
|
||||
'' if vr is None else '1' if vr else '0', json.dumps(art),
|
||||
json.dumps({'category': category, 'details': details or {}}), timeout=120))
|
||||
result['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
|
||||
result['artwork'] = art
|
||||
@@ -249,6 +252,31 @@ def apply_library(shortcut, label, directory, images, vr=False, home=None, exe='
|
||||
return result
|
||||
|
||||
|
||||
def cached_art_script(d):
|
||||
"""Frame-side Python that loads the source images kept beside d's last render into `cached`."""
|
||||
return f"""import base64, json, os
|
||||
cached = {{}}
|
||||
directory = os.path.realpath({d!r})
|
||||
try:
|
||||
with open(os.path.join(directory, 'artwork/input.json')) as f:
|
||||
plan = json.load(f)
|
||||
for slot, path in plan.get('images', {{}}).items():
|
||||
if os.path.commonpath([os.path.realpath(path), directory]) != directory:
|
||||
continue
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read(12 * 1024 * 1024 + 1)
|
||||
if len(data) <= 12 * 1024 * 1024:
|
||||
cached[slot] = base64.b64encode(data).decode()
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
pass
|
||||
"""
|
||||
|
||||
|
||||
def art_missing(m):
|
||||
"""True when a Frame Control install has no complete Steam artwork on record."""
|
||||
return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS)
|
||||
|
||||
|
||||
def refresh_art(pkg=None, artwork=None):
|
||||
"""Refresh existing APK library entries without reinstalling or stopping them."""
|
||||
if pkg is None:
|
||||
@@ -272,24 +300,8 @@ def refresh_art(pkg=None, artwork=None):
|
||||
script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
|
||||
script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
|
||||
script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n"
|
||||
script += "import os\ninfo['artwork'] = {}\n"
|
||||
script += f"directory = os.path.realpath({d!r})\n"
|
||||
script += """try:
|
||||
with open(os.path.join(directory, 'artwork/input.json')) as f:
|
||||
cached = json.load(f)
|
||||
for slot, path in cached.get('images', {}).items():
|
||||
if os.path.commonpath([os.path.realpath(path), directory]) != directory:
|
||||
continue
|
||||
with open(path, 'rb') as f:
|
||||
data = f.read(12 * 1024 * 1024 + 1)
|
||||
if len(data) <= 12 * 1024 * 1024:
|
||||
info['artwork'][slot] = base64.b64encode(data).decode()
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
print(json.dumps(info))
|
||||
"""
|
||||
script += cached_art_script(d) + "info['artwork'] = cached\nprint(json.dumps(info))\n"
|
||||
info = json.loads(ssh('python3 -', input=script))
|
||||
import base64
|
||||
icon = base64.b64decode(info['icon_png'])
|
||||
cached = {k: base64.b64decode(v) for k, v in info.get('artwork', {}).items()}
|
||||
images, warnings = frame_artwork.prepare(m['label'], icon, artwork if artwork is not None else cached)
|
||||
@@ -306,7 +318,10 @@ print(json.dumps(info))
|
||||
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m)
|
||||
except Exception:
|
||||
if created:
|
||||
shortcut_tool('remove', str(m['shortcut']))
|
||||
try:
|
||||
shortcut_tool('remove', str(m['shortcut']))
|
||||
except FrameError:
|
||||
pass # keep the render error, not the cleanup's
|
||||
raise
|
||||
m.update(artwork=result.get('artwork', {}), library_version=2)
|
||||
m['library_warnings'] = warnings + result.get('warnings', [])
|
||||
@@ -398,8 +413,12 @@ def remove(pkg, keep_data=False):
|
||||
m = _meta_or_fail(pkg)
|
||||
stop(pkg)
|
||||
if m['shortcut']:
|
||||
result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}')
|
||||
m['library_warnings'] = result.get('warnings', [])
|
||||
# Best effort: Steam may not be running, and the files must still go.
|
||||
try:
|
||||
result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}')
|
||||
m['library_warnings'] = result.get('warnings', [])
|
||||
except (FrameError, ValueError, AttributeError) as e:
|
||||
m['library_warnings'] = [f'Steam shortcut not removed: {e}']
|
||||
iid = int(m['instance'])
|
||||
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
|
||||
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
|
||||
|
||||
+89
-16
@@ -16,9 +16,9 @@ checked on a headset; see docs/sideloading.md.
|
||||
Python stdlib only. CLI:
|
||||
python3 ui/frame_titles.py inspect PATH
|
||||
python3 ui/frame_titles.py install PATH [--name N] [--exe REL] [--runtime R]
|
||||
python3 ui/frame_titles.py list | launch ID | remove ID
|
||||
python3 ui/frame_titles.py list | launch ID | remove ID | refresh-art ID|--all
|
||||
"""
|
||||
import hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile
|
||||
import base64, hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile
|
||||
|
||||
import frame_android
|
||||
import frame_host
|
||||
@@ -704,12 +704,13 @@ def _install(plan, step, artwork=None):
|
||||
hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else ''
|
||||
raise FrameError(f"Uploaded, but Steam didn't register it: {err}.{hint}")
|
||||
steam_registered = True
|
||||
shortcut = _library_shortcut(gid, plan['name'])
|
||||
shortcut = _library_shortcut(gid, directory)
|
||||
if not shortcut:
|
||||
raise FrameError('Steam registered the title but its shortcut is not available for mandatory artwork; retry install')
|
||||
result = frame_android.apply_library(shortcut, plan['name'], directory + '/.frame-artwork', images,
|
||||
category='Sideloaded', details={'source': plan['source']})
|
||||
meta.update(shortcut=shortcut, library_warnings=warnings + result.get('warnings', []))
|
||||
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}),
|
||||
library_warnings=warnings + result.get('warnings', []))
|
||||
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||
library_ready = True
|
||||
step('Done', 1.0)
|
||||
@@ -717,11 +718,15 @@ def _install(plan, step, artwork=None):
|
||||
return meta
|
||||
finally:
|
||||
if steam_registered and not library_ready and not existed:
|
||||
# A newly registered title must not remain as a blank library tile.
|
||||
if shortcut:
|
||||
frame_android.shortcut_tool('remove', str(shortcut))
|
||||
else:
|
||||
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
|
||||
# A newly registered title must not remain as a blank library tile. Cleanup
|
||||
# failures are swallowed so the error that got us here is the one reported.
|
||||
try:
|
||||
if shortcut:
|
||||
frame_android.shortcut_tool('remove', str(shortcut))
|
||||
else:
|
||||
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
|
||||
except FrameError:
|
||||
pass
|
||||
registered = False
|
||||
if not registered and not existed:
|
||||
# A first install that failed part-way: don't leave an orphan folder behind.
|
||||
@@ -732,22 +737,86 @@ def _install(plan, step, artwork=None):
|
||||
|
||||
|
||||
|
||||
def _library_shortcut(gid, name):
|
||||
def _library_shortcut(gid, directory):
|
||||
"""The Steam shortcut of title gid, only ever one that is provably this title's.
|
||||
|
||||
Steam's app overviews don't expose devkit_gameid (checked 2026-09-28, build 20260922.6101926),
|
||||
so after the saved id this matches the shortcut's executable or start folder inside directory.
|
||||
Never by display name: another non-Steam shortcut could share it and would be renamed or deleted.
|
||||
"""
|
||||
shortcuts = json.loads(frame_android.shortcut_tool('list'))
|
||||
matches = [s for s in shortcuts if s.get('devkit_gameid') == gid]
|
||||
if not matches:
|
||||
try:
|
||||
meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json 2>/dev/null || true'))
|
||||
matches = [s for s in shortcuts if s.get('appid') == meta.get('shortcut')]
|
||||
meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json 2>/dev/null || true') or 'null')
|
||||
matches = [s for s in shortcuts if meta.get('shortcut') and s.get('appid') == meta.get('shortcut')]
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
if not matches:
|
||||
matches = [s for s in shortcuts if s.get('name') in (gid, name)]
|
||||
root = posixpath.normpath(directory)
|
||||
|
||||
def inside(path):
|
||||
path = str(path or '').strip().strip('"')
|
||||
return bool(path) and (posixpath.normpath(path) + '/').startswith(root + '/')
|
||||
matches = [s for s in shortcuts if inside(s.get('exe')) or inside(s.get('start_dir'))]
|
||||
if len(matches) > 1:
|
||||
raise FrameError('ambiguous Steam shortcut for ' + gid)
|
||||
return int(matches[0]['appid']) if matches else None
|
||||
|
||||
|
||||
def _home():
|
||||
return ssh('echo $HOME', timeout=30).strip()
|
||||
|
||||
|
||||
def refresh_art(gid=None, artwork=None):
|
||||
"""Render and apply Steam artwork for Frame Control's titles (all of them when gid is None)."""
|
||||
if gid is None:
|
||||
results = []
|
||||
for t in list_titles():
|
||||
if not t['frame_control']:
|
||||
continue
|
||||
try:
|
||||
results.append(refresh_art(t['id'], artwork))
|
||||
except Exception as e: # report each title; one failure doesn't stop the rest
|
||||
results.append({'id': t['id'], 'name': t['name'], 'error': str(e) or type(e).__name__})
|
||||
return results
|
||||
with _install_lock:
|
||||
gid = _check_id(gid)
|
||||
try:
|
||||
meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json', timeout=30))
|
||||
except (FrameError, ValueError):
|
||||
meta = None
|
||||
if not isinstance(meta, dict):
|
||||
raise FrameError(f'{gid} was not installed by Frame Control')
|
||||
directory = f'{_home()}/{GAMES}/{gid}'
|
||||
script = frame_android.cached_art_script(directory + '/.frame-artwork') + f"""
|
||||
icon = ''
|
||||
for name in ('icon.png', 'logo.png'):
|
||||
try:
|
||||
with open(os.path.join({directory!r}, name), 'rb') as f:
|
||||
icon = base64.b64encode(f.read(12 * 1024 * 1024 + 1)).decode()
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
print(json.dumps({{'artwork': cached, 'icon': icon}}))
|
||||
"""
|
||||
found = _json_out(ssh('python3 -', input=script, timeout=60), 'the title artwork')
|
||||
cached = {k: base64.b64decode(v) for k, v in (found.get('artwork') or {}).items()}
|
||||
icon = base64.b64decode(found.get('icon') or '') or None
|
||||
name = str(meta.get('name') or gid)
|
||||
images, warnings = frame_android.frame_artwork.prepare(name, icon, artwork if artwork is not None else cached)
|
||||
shortcut = _library_shortcut(gid, directory)
|
||||
if not shortcut:
|
||||
raise FrameError(f"Steam hasn't registered {gid} yet; with Steam running on the Frame, refresh again")
|
||||
result = frame_android.apply_library(shortcut, name, directory + '/.frame-artwork', images,
|
||||
category='Sideloaded', details={'source': meta.get('source')})
|
||||
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}),
|
||||
library_warnings=warnings + result.get('warnings', []),
|
||||
artwork_refreshed=time.strftime('%Y-%m-%dT%H:%M:%S'))
|
||||
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||
return meta
|
||||
|
||||
|
||||
LIST_SCRIPT = r'''
|
||||
import json, os
|
||||
root = os.path.expanduser('~/devkit-game')
|
||||
@@ -814,9 +883,13 @@ def remove(gid):
|
||||
try:
|
||||
gid = _check_id(gid)
|
||||
ensure_utils()
|
||||
shortcut = _library_shortcut(gid, gid)
|
||||
if shortcut:
|
||||
frame_android.shortcut_tool('remove', str(shortcut))
|
||||
# Tidying Steam's side is best effort; steamos-delete removes the title regardless.
|
||||
try:
|
||||
shortcut = _library_shortcut(gid, f'{_home()}/{GAMES}/{gid}')
|
||||
if shortcut:
|
||||
frame_android.shortcut_tool('remove', str(shortcut))
|
||||
except (FrameError, ValueError):
|
||||
pass
|
||||
# steamos-delete removes the folder and syncs Steam's shortcuts; its json files stay, so clear them too.
|
||||
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
|
||||
ssh(f'rm -f {_json_files(gid)}', timeout=30)
|
||||
|
||||
Reference in new issue
Block a user