mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Merge branch 'library-fixes' into apk-store-fixes
This commit is contained in:
commit
4589221661
12 files changed
+320
-75
No files matched your search
+8
-5
@@ -140,10 +140,12 @@ refresh covers both. The API is `POST /api/android` with
|
|||||||
`POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a
|
`POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a
|
||||||
background job. Batch results retain per-item errors, and the CLIs exit
|
background job. Batch results retain per-item errors, and the CLIs exit
|
||||||
nonzero if any failed. Apps and titles without complete artwork show **Add
|
nonzero if any failed. Apps and titles without complete artwork show **Add
|
||||||
artwork**, `list` prints the command, and when Frame Control lists them while
|
artwork** and `list` prints the command. Only entries marked `art_pending` at
|
||||||
Steam is running it re-applies their art in the background (at most every
|
install (a title Steam registered after an install made while it wasn't
|
||||||
five minutes), for example for a title Steam registered after an install made
|
running) are backfilled automatically, when Frame Control lists them with
|
||||||
while it wasn't running.
|
Steam running (at most every five minutes), and that backfill only fills
|
||||||
|
slots Steam has no art for: names, icons, flags and any art the user set are
|
||||||
|
kept. Older installs without the flag are refreshed only on request.
|
||||||
|
|
||||||
Steam's app overviews carry no `devkit_gameid` (checked 2026-09-28, build
|
Steam's app overviews carry no `devkit_gameid` (checked 2026-09-28, build
|
||||||
20260925.6191901, on every non-Steam shortcut). A title's shortcut is found by
|
20260925.6191901, on every non-Steam shortcut). A title's shortcut is found by
|
||||||
@@ -176,7 +178,8 @@ The launcher supervises Lepton and handles TERM/INT/HUP and normal exit by
|
|||||||
stopping its own container and child process group. A lock refuses duplicate launches;
|
stopping its own container and child process group. A lock refuses duplicate launches;
|
||||||
a container still running while the lock is free was orphaned by a killed
|
a container still running while the lock is free was orphaned by a killed
|
||||||
launcher and is stopped before the new launch. Lepton doesn't inherit the
|
launcher and is stopped before the new launch. Lepton doesn't inherit the
|
||||||
lock. Removing an app or title still deletes its files when Steam isn't
|
lock; the launcher records Lepton's process group and a later launch ends a
|
||||||
|
recorded group still running this APK. Removing an app or title still deletes its files when Steam isn't
|
||||||
running; tidying Steam's collections and artwork is best effort. Steam Stop uses `TerminateApp` with the exact
|
running; tidying Steam's collections and artwork is best effort. Steam Stop uses `TerminateApp` with the exact
|
||||||
64-bit game ID string. Frame Control's Stop additionally has a direct-container
|
64-bit game ID string. Frame Control's Stop additionally has a direct-container
|
||||||
fallback. The stable instance ID and compatdata paths remain unchanged.
|
fallback. The stable instance ID and compatdata paths remain unchanged.
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ fi
|
|||||||
exec 9>"$DIR/launch.lock"
|
exec 9>"$DIR/launch.lock"
|
||||||
flock -n 9 || { echo "Android app is already running" >&2; exit 1; }
|
flock -n 9 || { echo "Android app is already running" >&2; exit 1; }
|
||||||
CONTAINER="lepton-steamlaunch-$SteamAppId"
|
CONTAINER="lepton-steamlaunch-$SteamAppId"
|
||||||
|
# Lepton doesn't hold the lock, so a launcher SIGKILLed before Lepton made its
|
||||||
|
# container leaves a Lepton that nothing tracks: end that process group first.
|
||||||
|
# Only a group still running this app.apk, never an unrelated reused id.
|
||||||
|
PGID_FILE="$DIR/launch.pgid"
|
||||||
|
if [[ -f "$PGID_FILE" ]]; then
|
||||||
|
old="$(cat "$PGID_FILE")"
|
||||||
|
if [[ "$old" =~ ^[0-9]+$ ]] && ps -A -o pgid=,args= | awk -v g="$old" '$1 == g' | grep -qF -- "$DIR/app.apk"; then
|
||||||
|
echo "Stopping the previous launch (process group $old)" >&2
|
||||||
|
kill -TERM -- "-$old" 2>/dev/null || true
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 -- "-$old" 2>/dev/null || break; sleep 0.5; done
|
||||||
|
kill -KILL -- "-$old" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -f "$PGID_FILE"
|
||||||
|
fi
|
||||||
# Holding the lock means no launcher owns a running container: it was orphaned
|
# Holding the lock means no launcher owns a running container: it was orphaned
|
||||||
# (this script SIGKILLed), so stop it rather than refuse every later Play.
|
# (this script SIGKILLed), so stop it rather than refuse every later Play.
|
||||||
if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then
|
if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then
|
||||||
@@ -60,6 +74,7 @@ cleanup() {
|
|||||||
kill -KILL "$child" 2>/dev/null || true
|
kill -KILL "$child" 2>/dev/null || true
|
||||||
wait "$child" 2>/dev/null || true
|
wait "$child" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
rm -f "$PGID_FILE"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
trap 'exit 143' TERM
|
trap 'exit 143' TERM
|
||||||
@@ -68,6 +83,7 @@ trap 'exit 129' HUP
|
|||||||
# 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held.
|
# 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held.
|
||||||
setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- &
|
setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- &
|
||||||
child=$!
|
child=$!
|
||||||
|
echo "$child" > "$PGID_FILE"
|
||||||
rc=0
|
rc=0
|
||||||
wait "$child" || rc=$?
|
wait "$child" || rc=$?
|
||||||
child=""
|
child=""
|
||||||
|
|||||||
@@ -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 stop APPID
|
||||||
steam_shortcuts.py remove APPID
|
steam_shortcuts.py remove APPID
|
||||||
"""
|
"""
|
||||||
import base64, json, os, re, socket, struct, sys, urllib.request
|
import base64, glob, json, os, re, socket, struct, sys, urllib.request
|
||||||
|
|
||||||
DEVTOOLS = 'http://127.0.0.1:8080/json'
|
DEVTOOLS = 'http://127.0.0.1:8080/json'
|
||||||
|
|
||||||
@@ -198,9 +198,26 @@ def _render(plan, renderer, label, images):
|
|||||||
return {'paths': paths, 'warnings': list(result.get('warnings', []))}
|
return {'paths': paths, 'warnings': list(result.get('warnings', []))}
|
||||||
|
|
||||||
|
|
||||||
|
# Steam's own file for each custom-art type in userdata/*/config/grid/.
|
||||||
|
GRID_FILES = {0: 'p', 1: '_hero', 2: '_logo', 3: ''}
|
||||||
|
|
||||||
|
|
||||||
|
def custom_art(appid):
|
||||||
|
"""The custom-art types this shortcut already has in Steam, for any local user."""
|
||||||
|
found = set()
|
||||||
|
for kind, suffix in GRID_FILES.items():
|
||||||
|
pattern = os.path.expanduser(f'~/.local/share/Steam/userdata/*/config/grid/{int(appid)}{suffix}.*')
|
||||||
|
if any(os.path.splitext(p)[1].lower() in ('.png', '.jpg', '.jpeg') for p in glob.glob(pattern)):
|
||||||
|
found.add(kind)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
|
def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
|
||||||
|
"""options: category, details, fill_only (only empty slots and a missing icon; name and flags untouched)."""
|
||||||
options = options or {}
|
options = options or {}
|
||||||
category = options.get('category', 'Android')
|
category = options.get('category', 'Android')
|
||||||
|
fill = bool(options.get('fill_only'))
|
||||||
|
existing = custom_art(appid) if fill else set()
|
||||||
if set(artwork) != set(ASSETS):
|
if set(artwork) != set(ASSETS):
|
||||||
raise ValueError('all five Steam artwork slots are required')
|
raise ValueError('all five Steam artwork slots are required')
|
||||||
images = []
|
images = []
|
||||||
@@ -214,18 +231,22 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
|
|||||||
data = f.read(MAX_ART + 1)
|
data = f.read(MAX_ART + 1)
|
||||||
if len(data) > MAX_ART:
|
if len(data) > MAX_ART:
|
||||||
raise ValueError('artwork is too large')
|
raise ValueError('artwork is too large')
|
||||||
if slot != 'icon': # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon.
|
# Frame's custom-art API maps type 4 to Header; use SetShortcutIcon.
|
||||||
|
if slot != 'icon' and ASSETS[slot] not in existing:
|
||||||
images.append([ASSETS[slot], ext, base64.b64encode(data).decode()])
|
images.append([ASSETS[slot], ext, base64.b64encode(data).decode()])
|
||||||
return evaluate(f'''(async () => {{
|
return evaluate(f'''(async () => {{
|
||||||
const id = {int(appid)}, warnings = [];
|
const id = {int(appid)}, warnings = [], fill = {json.dumps(fill)};
|
||||||
|
const overview = appStore.GetAppOverviewByAppID(id);
|
||||||
|
if (!fill) {{
|
||||||
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
|
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
|
||||||
if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)});
|
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 ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
|
||||||
if (typeof SteamClient.Apps.SetShortcutSortAs === "function")
|
if (typeof SteamClient.Apps.SetShortcutSortAs === "function")
|
||||||
SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)});
|
SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)});
|
||||||
SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
|
}}
|
||||||
// null (devkit titles): leave the VR flag as Steam registered it.
|
if (!fill || !(overview && overview.icon_data)) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
|
||||||
if ({json.dumps(vr)} !== null) {{
|
// null (devkit titles) or filling gaps: leave the VR flag as Steam has it.
|
||||||
|
if ({json.dumps(vr)} !== null && !fill) {{
|
||||||
if (typeof SteamClient.Apps.SetShortcutIsVR === "function")
|
if (typeof SteamClient.Apps.SetShortcutIsVR === "function")
|
||||||
SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)});
|
SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)});
|
||||||
else warnings.push("Steam VR shortcut flag API unavailable");
|
else warnings.push("Steam VR shortcut flag API unavailable");
|
||||||
@@ -233,7 +254,7 @@ def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
|
|||||||
if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{
|
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.
|
// 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")
|
if (!fill && typeof SteamClient.Apps.ClearCustomArtworkForApp === "function")
|
||||||
try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}}
|
try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}}
|
||||||
await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type);
|
await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function build(steam) {
|
|||||||
});
|
});
|
||||||
const shortcutOverview = s => ({
|
const shortcutOverview = s => ({
|
||||||
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid,
|
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid,
|
||||||
|
icon_data: s.icon ? 'fake-icon' : undefined,
|
||||||
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
|
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
|
||||||
});
|
});
|
||||||
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
|
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ spec.loader.exec_module(shortcuts)
|
|||||||
|
|
||||||
@unittest.skipIf(os.name == 'nt', 'POSIX launcher')
|
@unittest.skipIf(os.name == 'nt', 'POSIX launcher')
|
||||||
class LauncherTests(unittest.TestCase):
|
class LauncherTests(unittest.TestCase):
|
||||||
def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False):
|
def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False, stale=None):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
d = Path(tmp)
|
d = Path(tmp)
|
||||||
app = d / 'Applications/Android/org.test.app'
|
app = d / 'Applications/Android/org.test.app'
|
||||||
@@ -60,6 +60,13 @@ class LauncherTests(unittest.TestCase):
|
|||||||
saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save'
|
saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save'
|
||||||
saved.parent.mkdir(parents=True)
|
saved.parent.mkdir(parents=True)
|
||||||
saved.write_text('saved game')
|
saved.write_text('saved game')
|
||||||
|
previous = None
|
||||||
|
if stale:
|
||||||
|
# A Lepton left by a SIGKILLed launcher (its own session), or an unrelated reused id.
|
||||||
|
args = [sys.executable, '-c', 'import time; time.sleep(30)']
|
||||||
|
previous = subprocess.Popen(args + ([str(app / 'app.apk')] if stale == 'lepton' else ['other']),
|
||||||
|
start_new_session=True)
|
||||||
|
(app / 'launch.pgid').write_text(str(previous.pid))
|
||||||
proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
try:
|
try:
|
||||||
if blocked:
|
if blocked:
|
||||||
@@ -73,6 +80,10 @@ class LauncherTests(unittest.TestCase):
|
|||||||
while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline:
|
while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline:
|
||||||
time.sleep(.02)
|
time.sleep(.02)
|
||||||
self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton')
|
self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton')
|
||||||
|
if previous:
|
||||||
|
self.assertEqual(previous.poll() is not None, stale == 'lepton')
|
||||||
|
self.assertEqual((app / 'launch.pgid').read_text().strip(),
|
||||||
|
(d / 'started').read_text().strip()) # the new group is recorded
|
||||||
self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock')
|
self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock')
|
||||||
if terminate:
|
if terminate:
|
||||||
self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session')
|
self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session')
|
||||||
@@ -84,7 +95,12 @@ class LauncherTests(unittest.TestCase):
|
|||||||
self.assertEqual(proc.returncode, 128 + sig if terminate else 23)
|
self.assertEqual(proc.returncode, 128 + sig if terminate else 23)
|
||||||
self.assertEqual(saved.read_text(), 'saved game')
|
self.assertEqual(saved.read_text(), 'saved game')
|
||||||
self.assertTrue((app / 'app.apk').exists())
|
self.assertTrue((app / 'app.apk').exists())
|
||||||
|
self.assertFalse((app / 'launch.pgid').exists())
|
||||||
finally:
|
finally:
|
||||||
|
if previous and previous.poll() is None:
|
||||||
|
previous.kill()
|
||||||
|
if previous:
|
||||||
|
previous.wait()
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
proc.communicate()
|
proc.communicate()
|
||||||
@@ -105,6 +121,12 @@ class LauncherTests(unittest.TestCase):
|
|||||||
def test_duplicate_launch_leaves_existing_session_alone(self):
|
def test_duplicate_launch_leaves_existing_session_alone(self):
|
||||||
self.exercise(False, blocked='LOCKED')
|
self.exercise(False, blocked='LOCKED')
|
||||||
|
|
||||||
|
def test_previous_launch_left_by_sigkill_is_reaped_first(self):
|
||||||
|
self.exercise(True, stale='lepton')
|
||||||
|
|
||||||
|
def test_reused_process_group_id_is_left_alone(self):
|
||||||
|
self.exercise(True, stale='other')
|
||||||
|
|
||||||
def test_orphaned_container_is_stopped_and_play_proceeds(self):
|
def test_orphaned_container_is_stopped_and_play_proceeds(self):
|
||||||
# Container running but the lock free: its launcher was SIGKILLed.
|
# Container running but the lock free: its launcher was SIGKILLed.
|
||||||
self.exercise(False, orphan=True)
|
self.exercise(False, orphan=True)
|
||||||
@@ -167,6 +189,57 @@ class ArtworkTests(unittest.TestCase):
|
|||||||
with self.assertRaises(SourceError):
|
with self.assertRaises(SourceError):
|
||||||
art.fetch('file:///etc/passwd')
|
art.fetch('file:///etc/passwd')
|
||||||
|
|
||||||
|
def trickle(self, head, seconds):
|
||||||
|
# A server that answers one byte every 20 ms, over a socketpair standing in for the network.
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
from apk_sources import _images
|
||||||
|
client, server = socket.socketpair()
|
||||||
|
def serve():
|
||||||
|
try:
|
||||||
|
server.recv(65536)
|
||||||
|
for byte in head + b'x' * 1000:
|
||||||
|
server.sendall(bytes([byte]))
|
||||||
|
time.sleep(0.02)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
server.close()
|
||||||
|
threading.Thread(target=serve, daemon=True).start()
|
||||||
|
public = [(2, 1, 6, '', ('93.184.216.34', 80))]
|
||||||
|
with patch.object(_images.socket, 'getaddrinfo', return_value=public), \
|
||||||
|
patch.object(_images.socket, 'create_connection', return_value=client):
|
||||||
|
start = time.monotonic()
|
||||||
|
with self.assertRaisesRegex(_images.SourceError, 'too long'):
|
||||||
|
_images.get('http://example.org/a.png', deadline=start + seconds)
|
||||||
|
return time.monotonic() - start
|
||||||
|
|
||||||
|
def test_deadline_bounds_trickling_headers_and_body(self):
|
||||||
|
self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n', 0.15), 0.4)
|
||||||
|
self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\n', 0.15), 0.4) # headers never finish
|
||||||
|
|
||||||
|
def test_deadline_covers_name_resolution(self):
|
||||||
|
import threading
|
||||||
|
from apk_sources import _images
|
||||||
|
gate = threading.Event()
|
||||||
|
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []):
|
||||||
|
start = time.monotonic()
|
||||||
|
with self.assertRaisesRegex(_images.SourceError, 'too long'):
|
||||||
|
_images.get('https://example.org/a.png', deadline=start + 0.1)
|
||||||
|
self.assertLess(time.monotonic() - start, 0.4)
|
||||||
|
gate.set()
|
||||||
|
with self.assertRaisesRegex(_images.SourceError, 'too long'):
|
||||||
|
_images.get('https://example.org/a.png', deadline=time.monotonic() - 1)
|
||||||
|
|
||||||
|
def test_steamgriddb_uses_the_bounded_fetch_without_redirects(self):
|
||||||
|
import frame_steamgriddb as sgdb
|
||||||
|
from apk_sources import _images
|
||||||
|
with patch.object(_images, 'get', return_value=b'{"success": true, "data": [1]}') as get:
|
||||||
|
self.assertEqual(sgdb._get('/search/x', 'secret', time.monotonic() + 5), [1])
|
||||||
|
self.assertEqual(get.call_args.kwargs['redirects'], 0)
|
||||||
|
self.assertEqual(get.call_args.args[1]['Authorization'], 'Bearer secret')
|
||||||
|
self.assertLessEqual(get.call_args.kwargs['deadline'] - time.monotonic(), 5)
|
||||||
|
|
||||||
def test_supplied_jpeg(self):
|
def test_supplied_jpeg(self):
|
||||||
data = (FIXTURES / 'icon.jpg').read_bytes()
|
data = (FIXTURES / 'icon.jpg').read_bytes()
|
||||||
self.assertEqual(art.image_type(data), 'jpg')
|
self.assertEqual(art.image_type(data), 'jpg')
|
||||||
@@ -296,6 +369,44 @@ class InstallTests(unittest.TestCase):
|
|||||||
'.local/share/Steam/steamapps/shadercache/2800000001')
|
'.local/share/Steam/steamapps/shadercache/2800000001')
|
||||||
self.assertIn('Steam client running', result['library_warnings'][0])
|
self.assertIn('Steam client running', result['library_warnings'][0])
|
||||||
|
|
||||||
|
def test_remove_waits_for_refresh_and_is_never_undone(self):
|
||||||
|
import threading
|
||||||
|
state = {'meta': dict(self.existing, flatscreen=False), 'shortcuts': {3346865537}}
|
||||||
|
in_refresh, release, added = threading.Event(), threading.Event(), []
|
||||||
|
def meta(pkg):
|
||||||
|
if not state['meta']:
|
||||||
|
raise android.FrameError(pkg + ' is not installed')
|
||||||
|
return dict(state['meta'])
|
||||||
|
def ssh(cmd, input=None, **kw):
|
||||||
|
if cmd.startswith('rm -rf Applications/Android/org.test.vr'):
|
||||||
|
state['meta'] = None
|
||||||
|
return json.dumps({'icon_png': ''}) if cmd == 'python3 -' else '/home/steamos'
|
||||||
|
def tool(*args, **kw):
|
||||||
|
if args[0] == 'list': return json.dumps([{'appid': a} for a in state['shortcuts']])
|
||||||
|
if args[0] == 'remove': state['shortcuts'].discard(int(args[1])); return '{"warnings": []}'
|
||||||
|
if args[0] == 'add': added.append(args); return '99'
|
||||||
|
if args[0] == 'render': return json.dumps({'paths': {s: '/tmp/' + s + '.png' for s in art.SLOTS}})
|
||||||
|
return '{"warnings": []}'
|
||||||
|
def prepare(*args, **kw):
|
||||||
|
in_refresh.set(); release.wait(5)
|
||||||
|
return self.images, []
|
||||||
|
with patch.object(android, '_meta_or_fail', side_effect=meta), patch.object(android, 'ssh', side_effect=ssh), \
|
||||||
|
patch.object(android, 'shortcut_tool', side_effect=tool), patch.object(android, 'stop'), \
|
||||||
|
patch.object(android, '_write_meta', side_effect=lambda d, m: state.__setitem__('meta', m)), \
|
||||||
|
patch.object(android.frame_artwork, 'prepare', side_effect=prepare):
|
||||||
|
refresh = threading.Thread(target=android.refresh_art, args=('org.test.vr',), kwargs={'fill_only': True})
|
||||||
|
refresh.start()
|
||||||
|
self.assertTrue(in_refresh.wait(5))
|
||||||
|
remove = threading.Thread(target=android.remove, args=('org.test.vr',))
|
||||||
|
remove.start()
|
||||||
|
remove.join(0.3)
|
||||||
|
self.assertTrue(remove.is_alive(), 'remove ran while a refresh was writing')
|
||||||
|
release.set(); refresh.join(5); remove.join(5)
|
||||||
|
self.assertIsNone(state['meta']); self.assertEqual(state['shortcuts'], set())
|
||||||
|
with self.assertRaisesRegex(android.FrameError, 'not installed'):
|
||||||
|
android.refresh_art('org.test.vr', fill_only=True) # a queued backfill after removal
|
||||||
|
self.assertEqual(added, [])
|
||||||
|
|
||||||
def test_stop_requests_steam_and_has_container_fallback(self):
|
def test_stop_requests_steam_and_has_container_fallback(self):
|
||||||
with patch.object(android, '_meta_or_fail', return_value=self.existing), \
|
with patch.object(android, '_meta_or_fail', return_value=self.existing), \
|
||||||
patch.object(android, 'shortcut_tool', side_effect=android.FrameError('offline')) as api, \
|
patch.object(android, 'shortcut_tool', side_effect=android.FrameError('offline')) as api, \
|
||||||
@@ -332,7 +443,7 @@ class SteamAPITests(unittest.TestCase):
|
|||||||
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
|
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
|
||||||
shortcuts.configure(42, 'Game', '', '', '/icon', None, slots, {'category': 'Sideloaded'})
|
shortcuts.configure(42, 'Game', '', '', '/icon', None, slots, {'category': 'Sideloaded'})
|
||||||
js = evaluate.call_args.args[0]
|
js = evaluate.call_args.args[0]
|
||||||
self.assertIn('if (null !== null)', js)
|
self.assertIn('if (null !== null && !fill)', js)
|
||||||
self.assertIn('const wanted = ["Sideloaded"]', js)
|
self.assertIn('const wanted = ["Sideloaded"]', js)
|
||||||
with patch.object(sys, 'argv', ['steam_shortcuts.py', 'configure', '42', 'Game', '', '', '/icon', '',
|
with patch.object(sys, 'argv', ['steam_shortcuts.py', 'configure', '42', 'Game', '', '', '/icon', '',
|
||||||
json.dumps(slots), '{}']), \
|
json.dumps(slots), '{}']), \
|
||||||
@@ -411,6 +522,36 @@ class SteamContextTests(unittest.TestCase):
|
|||||||
self.assertEqual(steam['shortcuts'], [])
|
self.assertEqual(steam['shortcuts'], [])
|
||||||
self.assertEqual(steam['collections'][0]['apps'], [999])
|
self.assertEqual(steam['collections'][0]['apps'], [999])
|
||||||
|
|
||||||
|
@unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
|
||||||
|
def test_fill_only_keeps_customised_name_icon_and_art(self):
|
||||||
|
custom = {'0': {'data': 'mine-grid', 'ext': 'png'}, '1': {'data': 'mine-hero', 'ext': 'jpg'}}
|
||||||
|
steam = {'apps': [], 'compat_tools': {}, 'collections': [],
|
||||||
|
'shortcuts': [{'appid': 42, 'name': 'My Name', 'icon': '/mine.png', 'vr': True, 'artwork': dict(custom)}]}
|
||||||
|
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,
|
||||||
|
'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 tempfile.TemporaryDirectory() as home:
|
||||||
|
grid = Path(home, '.local/share/Steam/userdata/1/config/grid')
|
||||||
|
grid.mkdir(parents=True)
|
||||||
|
(grid / '42p.png').write_bytes(b'mine')
|
||||||
|
(grid / '42_hero.jpg').write_bytes(b'mine')
|
||||||
|
with patch.dict(os.environ, {'HOME': home, 'USERPROFILE': home}), patch.object(shortcuts, 'evaluate', side_effect=evaluate):
|
||||||
|
self.assertEqual(shortcuts.custom_art(42), {0, 1})
|
||||||
|
shortcuts.configure(42, 'Generated', '/exe', '/dir', '/generated.png', False,
|
||||||
|
{slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS},
|
||||||
|
{'category': 'Sideloaded', 'fill_only': True})
|
||||||
|
s = steam['shortcuts'][0]
|
||||||
|
self.assertEqual((s['name'], s['icon'], s['vr']), ('My Name', '/mine.png', True))
|
||||||
|
self.assertEqual({k: s['artwork'][k] for k in ('0', '1')}, custom)
|
||||||
|
self.assertEqual(set(s['artwork']), {'0', '1', '2', '3'})
|
||||||
|
|
||||||
@unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
|
@unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
|
||||||
def test_remove_without_collections_or_artwork_api_still_removes(self):
|
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"',
|
steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Game', 'exe': '"/home/steamos/devkit-game/G/g"',
|
||||||
|
|||||||
@@ -92,21 +92,34 @@ class EntryPoints(unittest.TestCase):
|
|||||||
server.titles({'action':'refresh-art', 'id':'G'})
|
server.titles({'action':'refresh-art', 'id':'G'})
|
||||||
title_refresh.assert_called_with('G')
|
title_refresh.assert_called_with('G')
|
||||||
|
|
||||||
def test_backfill_applies_missing_art_once_steam_answers(self):
|
def test_backfill_fills_only_entries_pending_since_install(self):
|
||||||
import threading
|
import threading
|
||||||
ran = threading.Event()
|
ran = threading.Event()
|
||||||
with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \
|
with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \
|
||||||
patch.object(android, 'refresh_art', side_effect=[RuntimeError('odd'), None]) as refresh, \
|
patch.object(android, 'refresh_art', side_effect=[RuntimeError('odd'), None]) as refresh, \
|
||||||
patch.object(titles, 'refresh_art', side_effect=lambda gid: ran.set()) as title_refresh:
|
patch.object(titles, 'refresh_art', side_effect=lambda gid, **kw: ran.set()) as title_refresh:
|
||||||
apps = [{'package':'org.a.x','art_missing':True}, {'package':'org.b.x','art_missing':True},
|
apps = [{'package':'org.a.x','art_pending':True}, {'package':'org.b.x','art_pending':True},
|
||||||
{'package':'org.c.x','art_missing':False}]
|
{'package':'org.c.x','art_missing':True}] # legacy: no record of art, but not pending
|
||||||
with contextlib.redirect_stderr(io.StringIO()):
|
with contextlib.redirect_stderr(io.StringIO()):
|
||||||
self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_missing':True}]))
|
self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_pending':True}]))
|
||||||
self.assertTrue(ran.wait(5))
|
self.assertTrue(ran.wait(5))
|
||||||
self.assertFalse(server.backfill_art(apps=apps)) # throttled
|
self.assertFalse(server.backfill_art(apps=apps)) # throttled
|
||||||
self.assertEqual([c.args for c in refresh.call_args_list], [('org.a.x',), ('org.b.x',)])
|
self.assertEqual([c.args for c in refresh.call_args_list], [('org.a.x',), ('org.b.x',)])
|
||||||
title_refresh.assert_called_once_with('G')
|
self.assertTrue(all(c.kwargs == {'fill_only': True} for c in refresh.call_args_list))
|
||||||
self.assertFalse(server.backfill_art(apps=[{'package':'org.c.x','art_missing':False}]))
|
title_refresh.assert_called_once_with('G', fill_only=True)
|
||||||
|
|
||||||
|
def test_upgrade_leaves_legacy_customised_titles_alone(self):
|
||||||
|
# A title installed before art_pending existed, whose art the user has customised in Steam.
|
||||||
|
legacy = [{'id':'Game','settings':{'compat_tool':'proton-experimental'},'argv':['game.exe'],
|
||||||
|
'meta':{'name':'Game','target':'game.exe','source':'game.zip'}}]
|
||||||
|
with patch.object(titles, 'ssh', return_value=json.dumps(legacy)):
|
||||||
|
listed = titles.list_titles()
|
||||||
|
self.assertEqual((listed[0]['art_pending'], listed[0]['art_missing']), (False, False))
|
||||||
|
with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \
|
||||||
|
patch.object(titles, 'refresh_art') as refresh, patch.object(android, 'refresh_art') as app_refresh:
|
||||||
|
self.assertFalse(server.backfill_art(apps=[{'package':'org.old.app','library_version':1}], titles=listed))
|
||||||
|
refresh.assert_not_called(); app_refresh.assert_not_called()
|
||||||
|
self.api.assert_not_called()
|
||||||
|
|
||||||
def test_art_missing_flags(self):
|
def test_art_missing_flags(self):
|
||||||
self.assertTrue(android.art_missing({'artwork': {}}))
|
self.assertTrue(android.art_missing({'artwork': {}}))
|
||||||
|
|||||||
@@ -234,7 +234,6 @@ class ServerGuards(unittest.TestCase):
|
|||||||
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
|
|
||||||
class ArtworkSettings(unittest.TestCase):
|
class ArtworkSettings(unittest.TestCase):
|
||||||
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
|
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
|
||||||
|
|
||||||
@@ -285,6 +284,7 @@ class ArtworkSettings(unittest.TestCase):
|
|||||||
self.assertLess(page.index("async function api("), page.index('<script src="/artwork-settings.js">'))
|
self.assertLess(page.index("async function api("), page.index('<script src="/artwork-settings.js">'))
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
|
||||||
class LocalMode(unittest.TestCase):
|
class LocalMode(unittest.TestCase):
|
||||||
"""FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key
|
"""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."""
|
guards /api/, and ssh goes to ui/local-bin/ssh, which runs commands here."""
|
||||||
|
|||||||
+60
-19
@@ -65,53 +65,94 @@ def image_type(data):
|
|||||||
|
|
||||||
def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
|
def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
|
||||||
"""deadline: time.monotonic() value by which the whole fetch, redirects included, must finish."""
|
"""deadline: time.monotonic() value by which the whole fetch, redirects included, must finish."""
|
||||||
|
data = get(url, {'Accept': 'image/png,image/jpeg,image/webp,image/gif'}, redirects, deadline, limit)
|
||||||
|
return data, image_type(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(host, port, timeout):
|
||||||
|
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
|
||||||
|
found = {}
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
found['addresses'] = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||||
|
except OSError as e:
|
||||||
|
found['error'] = e
|
||||||
|
worker = threading.Thread(target=run, daemon=True)
|
||||||
|
worker.start()
|
||||||
|
worker.join(timeout)
|
||||||
|
if 'error' in found:
|
||||||
|
raise found['error']
|
||||||
|
if 'addresses' not in found:
|
||||||
|
raise SourceError('Artwork download took too long')
|
||||||
|
return found['addresses']
|
||||||
|
|
||||||
|
|
||||||
|
def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE):
|
||||||
|
"""GET a public HTTP(S) URL within an overall deadline (default 60 s), redirects included.
|
||||||
|
|
||||||
|
A watchdog shuts the socket at the deadline, so a server trickling bytes can't outlast it."""
|
||||||
|
deadline = time.monotonic() + 60 if deadline is None else deadline
|
||||||
|
|
||||||
|
def left():
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise SourceError('Artwork download took too long')
|
||||||
|
return remaining
|
||||||
if not valid_url(url):
|
if not valid_url(url):
|
||||||
raise SourceError('Artwork URL is not allowed')
|
raise SourceError('Artwork URL is not allowed')
|
||||||
|
|
||||||
def remaining():
|
|
||||||
if deadline is None:
|
|
||||||
return 10
|
|
||||||
left = deadline - time.monotonic()
|
|
||||||
if left <= 0:
|
|
||||||
raise SourceError('Artwork download took too long')
|
|
||||||
return min(10, left)
|
|
||||||
p = urlsplit(url)
|
p = urlsplit(url)
|
||||||
port = p.port or (443 if p.scheme == 'https' else 80)
|
port = p.port or (443 if p.scheme == 'https' else 80)
|
||||||
addresses = socket.getaddrinfo(p.hostname, port, type=socket.SOCK_STREAM)
|
addresses = _resolve(p.hostname, port, left())
|
||||||
if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses):
|
if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses):
|
||||||
raise SourceError('Private network artwork is not allowed')
|
raise SourceError('Private network artwork is not allowed')
|
||||||
# Connect to the checked IP, never resolve again between validation and use.
|
# Connect to the checked IP, never resolve again between validation and use.
|
||||||
sock = socket.create_connection((addresses[0][4][0], port), timeout=remaining())
|
live = [socket.create_connection((addresses[0][4][0], port), timeout=min(10, left()))]
|
||||||
|
|
||||||
|
def expire():
|
||||||
|
try:
|
||||||
|
live[0].shutdown(socket.SHUT_RDWR)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
watchdog = threading.Timer(left(), expire)
|
||||||
|
watchdog.daemon = True
|
||||||
|
watchdog.start()
|
||||||
conn = http.client.HTTPConnection(p.hostname, port, timeout=10)
|
conn = http.client.HTTPConnection(p.hostname, port, timeout=10)
|
||||||
try:
|
try:
|
||||||
if p.scheme == 'https':
|
if p.scheme == 'https':
|
||||||
sock = ssl.create_default_context().wrap_socket(sock, server_hostname=p.hostname)
|
live[0] = ssl.create_default_context().wrap_socket(live[0], server_hostname=p.hostname)
|
||||||
conn.sock = sock
|
conn.sock = live[0]
|
||||||
path = p.path or '/'
|
path = p.path or '/'
|
||||||
if p.query:
|
if p.query:
|
||||||
path += '?' + p.query
|
path += '?' + p.query
|
||||||
conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', 'Accept': 'image/png,image/jpeg,image/webp,image/gif'})
|
conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', **(headers or {})})
|
||||||
sock.settimeout(remaining())
|
live[0].settimeout(min(10, left()))
|
||||||
response = conn.getresponse()
|
response = conn.getresponse()
|
||||||
if response.status in (301, 302, 303, 307, 308) and redirects:
|
if response.status in (301, 302, 303, 307, 308) and redirects:
|
||||||
target = urljoin(url, response.getheader('Location', ''))
|
target = urljoin(url, response.getheader('Location', ''))
|
||||||
conn.close()
|
conn.close()
|
||||||
return fetch(target, redirects - 1, deadline, limit)
|
return get(target, headers, redirects - 1, deadline, limit)
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
raise SourceError('Artwork is unavailable')
|
raise SourceError('Artwork is unavailable')
|
||||||
data = b''
|
data = b''
|
||||||
while len(data) <= limit:
|
while len(data) <= limit:
|
||||||
sock.settimeout(remaining())
|
live[0].settimeout(min(10, left()))
|
||||||
chunk = response.read(min(65536, limit + 1 - len(data)))
|
chunk = response.read1(min(16384, limit + 1 - len(data))) # one receive at most
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
data += chunk
|
data += chunk
|
||||||
if len(data) > limit:
|
if len(data) > limit:
|
||||||
raise SourceError('Artwork is too large')
|
raise SourceError('Artwork is too large')
|
||||||
return data, image_type(data)
|
left()
|
||||||
|
return data
|
||||||
|
except (OSError, http.client.HTTPException) as e:
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise SourceError('Artwork download took too long') from e
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
watchdog.cancel()
|
||||||
conn.close()
|
conn.close()
|
||||||
sock.close()
|
live[0].close()
|
||||||
|
|
||||||
|
|
||||||
def remember(url, data):
|
def remember(url, data):
|
||||||
|
|||||||
+18
-8
@@ -221,10 +221,11 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
|||||||
|
|
||||||
|
|
||||||
def apply_library(shortcut, label, directory, images, vr=None, 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'):
|
category='Android', fill_only=False):
|
||||||
"""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)."""
|
vr None leaves Steam's VR flag as it is (devkit titles declare their own). fill_only (automatic
|
||||||
|
backfill) sets only slots Steam has no art for, and never the name, exe or flags."""
|
||||||
home = home or ssh('echo $HOME').strip()
|
home = home or ssh('echo $HOME').strip()
|
||||||
d = directory
|
d = directory
|
||||||
ssh(f'mkdir -p {shlex.quote(d)}/artwork')
|
ssh(f'mkdir -p {shlex.quote(d)}/artwork')
|
||||||
@@ -244,7 +245,8 @@ def apply_library(shortcut, label, directory, images, vr=None, home=None, exe=''
|
|||||||
raise FrameError('Steam artwork renderer did not produce every slot')
|
raise FrameError('Steam artwork renderer did not produce every slot')
|
||||||
result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'],
|
result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'],
|
||||||
'' if vr is None else '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))
|
json.dumps({'category': category, 'details': details or {},
|
||||||
|
'fill_only': fill_only}), timeout=120))
|
||||||
result['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
|
result['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
|
||||||
result['artwork'] = art
|
result['artwork'] = art
|
||||||
if category == 'Android':
|
if category == 'Android':
|
||||||
@@ -277,8 +279,10 @@ def art_missing(m):
|
|||||||
return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS)
|
return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS)
|
||||||
|
|
||||||
|
|
||||||
def refresh_art(pkg=None, artwork=None):
|
def refresh_art(pkg=None, artwork=None, fill_only=False):
|
||||||
"""Refresh existing APK library entries without reinstalling or stopping them."""
|
"""Refresh existing APK library entries without reinstalling or stopping them.
|
||||||
|
|
||||||
|
fill_only: the automatic backfill; it only fills empty Steam slots (see apply_library)."""
|
||||||
if pkg is None:
|
if pkg is None:
|
||||||
results = []
|
results = []
|
||||||
for app in list_apps():
|
for app in list_apps():
|
||||||
@@ -287,7 +291,7 @@ def refresh_art(pkg=None, artwork=None):
|
|||||||
except Exception as e: # one app's failure must not stop the others
|
except Exception as e: # one app's failure must not stop the others
|
||||||
results.append({'package': app['package'], 'label': app.get('label'), 'error': str(e) or type(e).__name__})
|
results.append({'package': app['package'], 'label': app.get('label'), 'error': str(e) or type(e).__name__})
|
||||||
return results
|
return results
|
||||||
with _install_lock:
|
with _install_lock: # shared with install and remove: a removed app is never recreated
|
||||||
m = _meta_or_fail(pkg)
|
m = _meta_or_fail(pkg)
|
||||||
d = f'{APPS_DIR}/{pkg}'
|
d = f'{APPS_DIR}/{pkg}'
|
||||||
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
|
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
|
||||||
@@ -315,7 +319,8 @@ def refresh_art(pkg=None, artwork=None):
|
|||||||
created = True
|
created = True
|
||||||
try:
|
try:
|
||||||
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
|
result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
|
||||||
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m)
|
home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m,
|
||||||
|
fill_only=fill_only and not created)
|
||||||
except Exception:
|
except Exception:
|
||||||
if created:
|
if created:
|
||||||
try:
|
try:
|
||||||
@@ -323,7 +328,7 @@ def refresh_art(pkg=None, artwork=None):
|
|||||||
except FrameError:
|
except FrameError:
|
||||||
pass # keep the render error, not the cleanup's
|
pass # keep the render error, not the cleanup's
|
||||||
raise
|
raise
|
||||||
m.update(artwork=result.get('artwork', {}), library_version=2)
|
m.update(artwork=result.get('artwork', {}), library_version=2, art_pending=False)
|
||||||
m['library_warnings'] = warnings + result.get('warnings', [])
|
m['library_warnings'] = warnings + result.get('warnings', [])
|
||||||
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
|
||||||
_write_meta(d, m)
|
_write_meta(d, m)
|
||||||
@@ -411,6 +416,11 @@ def stop(pkg):
|
|||||||
|
|
||||||
|
|
||||||
def remove(pkg, keep_data=False):
|
def remove(pkg, keep_data=False):
|
||||||
|
with _install_lock: # never while an install or artwork refresh of any app is writing
|
||||||
|
return _remove(pkg, keep_data)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove(pkg, keep_data):
|
||||||
m = _meta_or_fail(pkg)
|
m = _meta_or_fail(pkg)
|
||||||
stop(pkg)
|
stop(pkg)
|
||||||
if m['shortcut']:
|
if m['shortcut']:
|
||||||
|
|||||||
+5
-14
@@ -6,7 +6,6 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
import frame_host
|
import frame_host
|
||||||
|
|
||||||
@@ -51,19 +50,11 @@ def save_settings(body):
|
|||||||
|
|
||||||
|
|
||||||
def _get(path, key, deadline=None):
|
def _get(path, key, deadline=None):
|
||||||
timeout = 12 if deadline is None else min(12, deadline - time.monotonic())
|
from apk_sources import _images
|
||||||
if timeout <= 0:
|
# No redirects: the credential never goes anywhere but the API. Errors never contain it.
|
||||||
raise ValueError('SteamGridDB lookup took too long')
|
data = _images.get(API + path, {'Authorization': 'Bearer ' + key, 'Accept': 'application/json'}, redirects=0,
|
||||||
request = urllib.request.Request(API + path, headers={'Authorization': 'Bearer ' + key,
|
deadline=time.monotonic() + 12 if deadline is None else min(deadline, time.monotonic() + 12),
|
||||||
'User-Agent': 'FrameControl/1.0'})
|
limit=MAX_JSON)
|
||||||
# Do not carry the credential to redirects or include it in error messages.
|
|
||||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
def redirect_request(self, *args, **kwargs):
|
|
||||||
return None
|
|
||||||
with urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout) as response:
|
|
||||||
data = response.read(MAX_JSON + 1)
|
|
||||||
if len(data) > MAX_JSON:
|
|
||||||
raise ValueError('SteamGridDB response too large')
|
|
||||||
result = json.loads(data)
|
result = json.loads(data)
|
||||||
if not isinstance(result, dict) or not result.get('success') or not isinstance(result.get('data'), list):
|
if not isinstance(result, dict) or not result.get('success') or not isinstance(result.get('data'), list):
|
||||||
raise ValueError('SteamGridDB lookup failed')
|
raise ValueError('SteamGridDB lookup failed')
|
||||||
|
|||||||
+14
-7
@@ -696,7 +696,9 @@ def _install(plan, step, artwork=None):
|
|||||||
reply = _json_out(ssh(f'{PY}steam-client-create-shortcut --parms {shlex.quote(json.dumps(parms))}',
|
reply = _json_out(ssh(f'{PY}steam-client-create-shortcut --parms {shlex.quote(json.dumps(parms))}',
|
||||||
timeout=90), 'steam-client-create-shortcut')
|
timeout=90), 'steam-client-create-shortcut')
|
||||||
meta = {'id': gid, 'name': plan['name'], 'target': plan['target'], 'runtime': plan['runtime'],
|
meta = {'id': gid, 'name': plan['name'], 'target': plan['target'], 'runtime': plan['runtime'],
|
||||||
'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S')}
|
'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
|
||||||
|
# Until art is applied: the only entries automatic backfill may touch.
|
||||||
|
'art_pending': True}
|
||||||
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||||
registered = True # the files stay: Steam registers them once it's running
|
registered = True # the files stay: Steam registers them once it's running
|
||||||
if 'error' in reply:
|
if 'error' in reply:
|
||||||
@@ -709,7 +711,7 @@ def _install(plan, step, artwork=None):
|
|||||||
raise FrameError('Steam registered the title but its shortcut is not available for mandatory artwork; retry install')
|
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,
|
result = frame_android.apply_library(shortcut, plan['name'], directory + '/.frame-artwork', images,
|
||||||
category='Sideloaded', details={'source': plan['source']})
|
category='Sideloaded', details={'source': plan['source']})
|
||||||
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}),
|
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), art_pending=False,
|
||||||
library_warnings=warnings + result.get('warnings', []))
|
library_warnings=warnings + result.get('warnings', []))
|
||||||
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||||
library_ready = True
|
library_ready = True
|
||||||
@@ -768,8 +770,10 @@ def _home():
|
|||||||
return ssh('echo $HOME', timeout=30).strip()
|
return ssh('echo $HOME', timeout=30).strip()
|
||||||
|
|
||||||
|
|
||||||
def refresh_art(gid=None, artwork=None):
|
def refresh_art(gid=None, artwork=None, fill_only=False):
|
||||||
"""Render and apply Steam artwork for Frame Control's titles (all of them when gid is None)."""
|
"""Render and apply Steam artwork for Frame Control's titles (all of them when gid is None).
|
||||||
|
|
||||||
|
fill_only: the automatic backfill; it only fills empty Steam slots (see apply_library)."""
|
||||||
if gid is None:
|
if gid is None:
|
||||||
results = []
|
results = []
|
||||||
for t in list_titles():
|
for t in list_titles():
|
||||||
@@ -809,8 +813,9 @@ print(json.dumps({{'artwork': cached, 'icon': icon}}))
|
|||||||
if not shortcut:
|
if not shortcut:
|
||||||
raise FrameError(f"Steam hasn't registered {gid} yet; with Steam running on the Frame, refresh again")
|
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,
|
result = frame_android.apply_library(shortcut, name, directory + '/.frame-artwork', images,
|
||||||
category='Sideloaded', details={'source': meta.get('source')})
|
category='Sideloaded', details={'source': meta.get('source')},
|
||||||
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}),
|
fill_only=fill_only)
|
||||||
|
meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), art_pending=False,
|
||||||
library_warnings=warnings + result.get('warnings', []),
|
library_warnings=warnings + result.get('warnings', []),
|
||||||
artwork_refreshed=time.strftime('%Y-%m-%dT%H:%M:%S'))
|
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)
|
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||||
@@ -852,7 +857,9 @@ def list_titles():
|
|||||||
'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'),
|
'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'),
|
||||||
'source': str(meta.get('source') or ''), 'size': meta.get('size'),
|
'source': str(meta.get('source') or ''), 'size': meta.get('size'),
|
||||||
'installed': meta.get('installed'), 'registered': t.get('settings') is not None,
|
'installed': meta.get('installed'), 'registered': t.get('settings') is not None,
|
||||||
'frame_control': bool(meta), 'art_missing': bool(meta) and frame_android.art_missing(meta)})
|
'frame_control': bool(meta),
|
||||||
|
# Only a title whose install couldn't apply art; older installs have no flag and keep theirs.
|
||||||
|
'art_pending': bool(meta.get('art_pending')), 'art_missing': bool(meta.get('art_pending'))})
|
||||||
return titles
|
return titles
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -153,11 +153,12 @@ _backfill_lock = threading.Lock()
|
|||||||
|
|
||||||
|
|
||||||
def backfill_art(apps=(), titles=()):
|
def backfill_art(apps=(), titles=()):
|
||||||
"""Give installs that lack Steam artwork (Steam wasn't running, say) their art once Steam is up.
|
"""Apply art Frame Control couldn't at install time (Steam wasn't running), once Steam is up.
|
||||||
|
|
||||||
Runs in the background, at most once every five minutes; refresh-art reports failures on demand."""
|
Only entries marked art_pending at install; it fills empty Steam slots and never replaces art or
|
||||||
pkgs = [a["package"] for a in apps if a.get("art_missing")]
|
names the user may have customised. Runs in the background, at most once every five minutes."""
|
||||||
gids = [t["id"] for t in titles if t.get("art_missing")]
|
pkgs = [a["package"] for a in apps if a.get("art_pending")]
|
||||||
|
gids = [t["id"] for t in titles if t.get("art_pending")]
|
||||||
with _backfill_lock:
|
with _backfill_lock:
|
||||||
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
|
if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300:
|
||||||
return False
|
return False
|
||||||
@@ -169,7 +170,7 @@ def backfill_art(apps=(), titles=()):
|
|||||||
for refresh, key in [(frame_android.refresh_art, p) for p in pkgs] + \
|
for refresh, key in [(frame_android.refresh_art, p) for p in pkgs] + \
|
||||||
[(frame_titles.refresh_art, g) for g in gids]:
|
[(frame_titles.refresh_art, g) for g in gids]:
|
||||||
try:
|
try:
|
||||||
refresh(key)
|
refresh(key, fill_only=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"artwork backfill for {key}: {e}", file=sys.stderr)
|
print(f"artwork backfill for {key}: {e}", file=sys.stderr)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
Reference in new issue
Block a user