Merge pull request #99: Queue Android and Quest installs

Queue Android and Quest installs; prepare Frame Control 0.4.7
This commit is contained in:
Alex Southwell authored and GitHub committed 2026-10-15 13:32:54 +11:00
commit bcdd9d0830
10 files changed
+329 -57

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.6",
"version": "0.4.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.6",
"version": "0.4.7",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.6",
"version": "0.4.7",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+6 -2
View File
@@ -124,7 +124,10 @@ the redaction look for it.
it has game data to add: once to add it, once for the test), watches it for
about 30 seconds, and records what happened. **Test again** repeats only
the test.
5. **Disconnect** deletes the token from your computer and, in the desktop
5. Choose **Install** on as many games as you like: they wait in the
**Install queue** and install one after another, with Quest imports and
Android store installs. **Remove** takes one out before it starts.
6. **Disconnect** deletes the token from your computer and, in the desktop
app, signs out of Meta in Frame Control (clears the sign-in window's
storage). Games already on the Frame stay.
@@ -145,7 +148,8 @@ The local API: `GET /api/meta` (connected or not, never the token),
`{"action": "connect", "token": "...", "acknowledged": true}`,
`{"action": "disconnect"}`, `{"action": "cancel-signin", "attempt": "..."}`, `{"action": "install", "app": "APP_ID", "test": false,
"allowDowngrade": false}` or `{"action": "test", "package": "..."}`. Install and
test return a background job (`/api/job`). Sign in with Meta uses the same
test return a background job (`/api/job`); an install waits its turn in the
install queue (`/api/queue`, see [Import from my Quest](quest-import.md#using-it)). Sign in with Meta uses the same
`connect` action: the desktop app's main process (`app/meta-signin.js`) posts
it with the cookie's value and an `"attempt"` id (8 to 64 letters, digits or
`-`) once the page's acknowledgement box is ticked. A connect whose attempt
+12 -1
View File
@@ -45,6 +45,13 @@ files in Android/data** to bring the game's external files too (some games
keep saves there). Progress shows in the panel; the result and any reason it
stopped appear in the message log.
You can choose **Import** on several games at once: they wait in the **Install
queue** (above the Quest panel) and import one after another, together with
installs from the Android store and My Meta library. **Remove** takes a game
out of the queue before it starts. Each queued import runs on the headset that
was selected when you queued it; if you switch headsets first, it stops with a
message instead.
A game with game data (an OBB folder, or Android/data when ticked) takes two
imports, because Android can only take that data while the game's Lepton
instance is running, and there is no way to start the instance without the
@@ -69,7 +76,11 @@ python3 ui/frame_quest.py import com.example.game --data --serial 1WMHH000XXXXXX
The API is `GET /api/quest` (the Quest, or guidance when there isn't one, and
its apps) and `POST /api/quest` with
`{"action": "import", "package": "...", "serial": "...", "data": false}`,
which returns a background job (`/api/job`) with a stage and percentage.
which returns a background job (`/api/job`) with a stage and percentage. The
job waits in the install queue first (`"queued": true`, stage `Queued: N
installs before it`). `GET /api/queue` lists the running install and those
waiting; `POST /api/queue` with `{"action": "remove", "job": "..."}` removes a
waiting one (409 once it has started).
## What an import does
+6 -3
View File
@@ -23,9 +23,9 @@ const metaLib = { connected: true, results: new Map(), apps: [
data_files: 0, skipped_files: ["main.com.crytek.climb.obb"] },
{ id: "2", title: "Beat Saber", package: "com.beatgames.beatsaber", problem: "Meta's API said HTTP 500", skipped_files: [] },
] };
const render = new Function("$", "metaLib", "androidApps", "installing",
const render = new Function("$", "metaLib", "androidApps", "installing", "queued",
grab("esc") + grab("questSize") + "const META_VERDICT = {};\n" + grab("renderMetaApps") + "renderMetaApps();");
render(id => els[id], metaLib, [], new Set());
render(id => els[id], metaLib, [], new Set(["meta:1"]), new Set(["meta:1"]));
const html = els.metaApps.innerHTML;
if (els.metaCount.textContent !== "2 apps") fail("count: " + els.metaCount.textContent);
const climb = html.split('class="item"')[1];
@@ -34,7 +34,10 @@ if (!/Not installed: 1 required data file \(main\.com\.crytek\.climb\.obb\)/.tes
const saber = html.split('class="item"')[2];
if (!/<div class="s v-no"[^>]*>Meta&#39;s API said HTTP 500</.test(saber)) fail("problem not on its own line: " + saber);
if (/Not installed/.test(saber)) fail("warning without skipped files");
// Waiting in the install queue: marked, and not offered again.
if (!/<span class="tag">Queued<\/span>/.test(climb) || !/data-meta-install="1" disabled/.test(climb)) fail("queued not shown: " + climb);
if (/Queued|disabled/.test(saber)) fail("queued on the wrong row: " + saber);
metaLib.apps = metaLib.apps.slice(0, 1);
render(id => els[id], metaLib, [], new Set());
render(id => els[id], metaLib, [], new Set(), new Set());
if (els.metaCount.textContent !== "1 app") fail("singular count: " + els.metaCount.textContent);
console.log("ok");
+127
View File
@@ -0,0 +1,127 @@
"""The install queue (server.queue_job): Android, Quest and Meta installs run one after another."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
from pathlib import Path
import sys
import threading
import time
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'ui'))
import server # noqa: E402
def wait(job, timeout=5):
end = time.time() + timeout
while time.time() < end:
j = server.job_status(f'id={job}')
if j['done']:
return j
time.sleep(0.01)
raise AssertionError(f'job {job} never finished')
class InstallQueue(unittest.TestCase):
def setUp(self):
self.assertEqual(server._queue, [])
def blocker(self):
"""A queued install that runs until released."""
go, started = threading.Event(), threading.Event()
def work():
started.set()
go.wait(5)
return {'message': 'first done'}
job = server.queue_job('Install first', work)['job']
self.assertTrue(started.wait(5))
self.addCleanup(go.set)
return job, go
def test_runs_in_order_one_at_a_time(self):
order, running = [], []
def work(name):
def run(report):
running.append(name)
self.assertEqual(len(running), 1, 'two installs ran at once')
report('Copying', 50)
time.sleep(0.02)
order.append(name)
running.remove(name)
return {'message': f'{name} done'}
return run
first, go = self.blocker()
started = [server.queue_job(f'Install {n}', work(n), progress=True) for n in ('a', 'b', 'c')]
self.assertEqual([s['queued'] for s in started], [1, 2, 3])
self.assertIn('queued, 3 before it', started[2]['message'])
q = server.install_queue()['jobs']
self.assertEqual([j['label'] for j in q], ['Install first', 'Install a', 'Install b', 'Install c'])
self.assertEqual([j['queued'] for j in q], [False, True, True, True])
self.assertEqual(q[1]['stage'], 'Queued: 1 install before it')
self.assertEqual(q[3]['stage'], 'Queued: 3 installs before it')
go.set()
self.assertEqual(wait(first)['message'], 'first done')
for s, n in zip(started, 'abc'):
j = wait(s['job'])
self.assertEqual((j['message'], j['error'], j['queued']), (f'{n} done', None, False))
self.assertEqual(order, ['a', 'b', 'c'])
self.assertEqual(server.install_queue(), {'jobs': []})
def test_a_failure_does_not_stop_the_rest(self):
def fail():
raise server.Failure('no room on the Frame')
bad = server.queue_job('Install bad', fail)['job']
good = server.queue_job('Install good', lambda: {'message': 'ok'})['job']
self.assertEqual(wait(bad)['error'], 'no room on the Frame')
self.assertEqual(wait(good)['message'], 'ok')
def test_a_waiting_install_can_be_removed_but_not_a_running_one(self):
ran = []
first, go = self.blocker()
second = server.queue_job('Install second', lambda: ran.append(2) or {})['job']
third = server.queue_job('Install third', lambda: ran.append(3) or {})['job']
self.assertIn('Removed Install second', server.queue_action({'action': 'remove', 'job': second})['message'])
j = server.job_status(f'id={second}')
self.assertTrue(j['done'] and j['cancelled'])
self.assertEqual(server.job_status(f'id={third}')['stage'], 'Queued: 1 install before it')
for body, status in (({'action': 'remove', 'job': first}, 409), ({'action': 'remove', 'job': second}, 409),
({'action': 'nope', 'job': third}, 400)):
with self.assertRaises(server.Failure) as e:
server.queue_action(body)
self.assertEqual(e.exception.status, status)
go.set()
wait(third)
self.assertEqual(ran, [3])
def test_runs_only_on_the_headset_it_was_queued_for(self):
class Link:
device = 'frame-a'
def active_device(self):
return {'id': self.device}
link = Link()
with patch.object(server, 'LINK', link):
first, go = self.blocker()
ran = []
later = server.queue_job('Install later', lambda: ran.append(1) or {})['job']
link.device = 'frame-b'
go.set()
self.assertIn('switched headsets', wait(later)['error'])
self.assertEqual(ran, [])
def test_installs_from_each_place_are_queued(self):
with patch.object(server, 'ensure_master'), patch.object(server, 'queue_job', return_value={'job': 'j'}) as q:
server.quest({'action': 'import', 'package': 'com.example.game'})
server.meta({'action': 'install', 'app': '123'})
with patch.object(server.frame_catalog, 'app'):
server.android({'action': 'install', 'package': 'com.example.game'})
server.android({'action': 'install', 'package': 'com.example.game', 'url': 'https://f-droid.org/x.apk'})
with patch.object(server.apk_search, 'resolve', return_value=(None, {'enabled': True})):
server.source_install({'source': 'fdroid', 'id': 'com.example.game'})
self.assertEqual(q.call_count, 5)
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -1224,7 +1224,7 @@ class Server(unittest.TestCase):
self.assertEqual({a['id'] for a in status['apps']}, {'111', '222'})
self.assertIn('sharing', status)
self.assertNotIn(TOKEN, json.dumps([result, status]))
with patch.object(s, 'ensure_master'), patch.object(s, 'start_job', return_value={'job': 'j'}) as job:
with patch.object(s, 'ensure_master'), patch.object(s, 'queue_job', return_value={'job': 'j'}) as job:
self.assertEqual(s.meta({'action': 'install', 'app': '111', 'test': True}), {'job': 'j'})
work = job.call_args.args[1]
with patch.object(meta, 'install', side_effect=KeyError(f'access_token={TOKEN}')):
+1 -1
View File
@@ -1130,7 +1130,7 @@ class Server(unittest.TestCase):
with self.assertRaises(s.Failure) as e:
s.quest(body)
self.assertEqual(e.exception.status, 400, body)
with patch.object(s, 'ensure_master'), patch.object(s, 'start_job', return_value={'job': 'j'}) as job:
with patch.object(s, 'ensure_master'), patch.object(s, 'queue_job', return_value={'job': 'j'}) as job:
self.assertEqual(s.quest({'action': 'import', 'package': PKG, 'serial': QUEST['serial'], 'data': True}),
{'job': 'j'})
self.assertTrue(job.call_args.kwargs['progress'])
+61 -16
View File
@@ -1006,6 +1006,12 @@
library under its own name and opens in its own panel. Test launches it and records
whether it stays up; Report records how it worked for you.</div>
</div>
<section class="panel" id="installQueue" hidden>
<div class="shelf-head"><h2>Install queue</h2><span class="count" id="queueCount"></span></div>
<div class="list" id="queueList"></div>
<div class="hint">Games from the store, your Quest and your Meta library install one after another.
Remove takes a game out before it starts.</div>
</section>
<section class="panel" id="quest">
<div class="shelf-head"><h2>Import from my Quest</h2><span class="count" id="questCount"></span><span class="spacer"></span>
<button class="small" id="questScan" title="Look for a Meta Quest plugged into this computer by USB">Look for my Quest</button></div>
@@ -2687,32 +2693,65 @@ document.body.addEventListener("click", async (e) => {
// Installs run as server jobs: start one, then poll until it's done. The
// bottom bar counts what's running; `installing` keeps re-rendered lists from
// offering Install again for something already on its way.
const installing = new Set();
// Android installs wait their turn in the server's install queue (queue_job in
// server.py); `queued` holds the ones still waiting.
const installing = new Set(), queued = new Set();
let jobsRunning = 0;
function showJobs() {
const waiting = queued.size, running = jobsRunning - waiting;
$("jobs").hidden = !jobsRunning;
$("jobsText").textContent = `${jobsRunning} install${jobsRunning === 1 ? "" : "s"} running`;
$("jobsText").textContent = [running ? `${running} install${running === 1 ? "" : "s"} running` : "",
waiting ? `${waiting} queued` : ""].filter(Boolean).join(", ");
}
async function runJob(label, key, start, onProgress = () => {}) {
installing.add(key); jobsRunning++; showJobs();
log(label + "…");
try {
const { job } = await start();
const started = await start();
const { job } = started;
log(started.queued ? started.message : label + "…");
if (started.queued) { queued.add(key); showJobs(); }
if (started.queued != null) loadQueue();
for (let misses = 0; ;) {
await new Promise(ok => setTimeout(ok, 1500));
let j;
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
if (j.queued !== queued.has(key)) { j.queued ? queued.add(key) : queued.delete(key); showJobs(); }
onProgress(j);
if (!j.done) continue;
if (j.cancelled) { log(`${label}: removed from the install queue`); return null; }
if (j.error) throw new Error(j.error);
log(j.message, "ok"); toast(j.message);
return j.result || {};
}
} catch (e) {
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
} finally { installing.delete(key); jobsRunning--; showJobs(); }
} finally { installing.delete(key); queued.delete(key); jobsRunning--; showJobs(); }
}
// The install queue as the server has it: also what an earlier visit to this page queued.
let queueTimer;
async function loadQueue() {
clearTimeout(queueTimer);
let jobs;
try { ({ jobs } = await api("/api/queue")); } catch { jobs = null; }
if (jobs) {
$("installQueue").hidden = !jobs.length;
$("queueCount").textContent = jobs.length ? `${jobs.length}` : "";
$("queueList").innerHTML = jobs.map(j => `<div class="item"><div class="grow"><div class="t">${esc(j.label)}</div>
<div class="s">${esc(j.queued ? j.stage || "Queued" : `${j.stage || "Starting"}${j.percent != null ? ` · ${Math.round(j.percent)}%` : ""}`)}</div></div>
${j.queued ? `<button class="small" data-unqueue="${esc(j.job)}">Remove</button>` : `<span class="tag">Installing</span>`}</div>`).join("");
}
if (!jobs || jobs.length) queueTimer = setTimeout(loadQueue, 2000);
}
$("queueList").addEventListener("click", async e => {
const b = e.target.closest("[data-unqueue]");
if (!b) return;
b.disabled = true;
try { const d = await api("/api/queue", { action: "remove", job: b.dataset.unqueue }); toast(d.message); }
catch (err) { toast(err.message, true); }
loadQueue();
});
loadQueue();
const POWER = { suspend: ["Put the Frame to sleep?", "Sleep"], reboot: ["Restart the Frame?", "Restart"],
poweroff: ["Shut the Frame down?", "Shut down"] };
function askPower(what) {
@@ -3381,6 +3420,7 @@ async function installStoreApp() {
if(existing&&!existing.done)return;
const state={stage:'Downloading',percent:null,done:false};sourceState.jobs.set(key,state);updateInstall();
const result=await runJob(`Install ${o.name}`,key,()=>api('/api/sources/install',{source:o.source,id:o.id,version_code:o.version_code??null}),j=>{Object.assign(state,{stage:j.stage||'Downloading',percent:j.percent??null,error:j.error});updateInstall();});
if(result===null){sourceState.jobs.delete(key);updateInstall();return;} // removed from the install queue
Object.assign(state,{done:true,result,error:result?null:state.error||"We couldn't finish the installation. Try again."});updateInstall();
if(result)loadAndroid();
}
@@ -3484,7 +3524,7 @@ async function loadQuest() {
if (a.meta) bits.push("Meta app: needs Meta's services");
return `<div class="item"><div class="grow"><div class="t">${esc(a.package)}</div><div class="s">${esc(bits.join(" · "))}</div></div>
${installed.has(a.package) ? `<span class="tag">On the Frame</span>` : ""}
<button class="small" data-quest-import="${esc(a.package)}"${installing.has("quest:" + a.package) ? " disabled" : ""}>${installed.has(a.package) ? "Import again" : "Import"}</button></div>`;
<button class="small" data-quest-import="${esc(a.package)}"${installing.has("quest:" + a.package) ? " disabled" : ""}>${queued.has("quest:" + a.package) ? "Queued" : installed.has(a.package) ? "Import again" : "Import"}</button></div>`;
}).join("") : `<div class="sub">No games or apps you installed are on this Quest.</div>`;
}
$("questScan").onclick = loadQuest;
@@ -3493,14 +3533,16 @@ $("questApps").addEventListener("click", e => {
if (!b) return;
const pkg = b.dataset.questImport, key = "quest:" + pkg;
if (installing.has(key)) return;
b.disabled = true;
b.disabled = true; b.textContent = "Queued";
const bar = $("questProgress");
bar.hidden = false; bar.value = 0;
// Several can be queued: the bar and the status line follow whichever is installing.
runJob(`Import ${pkg} from Quest`, key,
() => api("/api/quest", { action: "import", package: pkg, serial: quest.serial, data: $("questData").checked, allowDowngrade: $("questOlder").checked }),
j => { if (j.stage) $("questState").textContent = `${pkg}: ${j.stage}`; if (j.percent != null) bar.value = j.percent; })
.then(r => { bar.hidden = true; if (r && r.app) { $("questState").textContent = r.message || `Imported ${pkg}`; loadAndroid(); }
else { b.disabled = false; $("questState").textContent = `${pkg} wasn't imported; the reason is in the message log below.`; } });
j => { if (j.queued || j.done) return; b.textContent = "Importing…"; bar.hidden = false; bar.dataset.owner = key;
if (j.stage) $("questState").textContent = `${pkg}: ${j.stage}`; bar.value = j.percent ?? 0; })
.then(r => { if (bar.dataset.owner === key) bar.hidden = true; if (r && r.app) { b.textContent = "Imported"; $("questState").textContent = r.message || `Imported ${pkg}`; loadAndroid(); }
else { b.disabled = false; b.textContent = "Import";
if (r !== null) $("questState").textContent = `${pkg} wasn't imported; the reason is in the message log below.`; } });
});
// ---- My Meta library, Experimental (frame_meta.py): the person's own token, games they own ----
@@ -3549,11 +3591,12 @@ function renderMetaApps() {
const verdict = v ? `<div class="s ${v[0]}" title="${esc((a.verdict.lines || []).join("\n"))}">Community: ${v[1]}</div>` : "";
const r = metaLib.results.get(a.package);
const result = r ? `<div class="s">${esc(r)}</div>` : "";
const busy = installing.has("meta:" + a.id) ? " disabled" : "";
const busy = installing.has("meta:" + a.id) ? " disabled" : "", waits = queued.has("meta:" + a.id);
const on = installed.has(a.package);
const can = a.package && !a.problem;
return `<div class="item"><div class="grow"><div class="t">${esc(a.title)}</div><div class="s" title="${esc(line)}">${esc(line)}</div>${problem}${skipped}${verdict}${result}</div>
${on ? `<span class="tag">On the Frame</span>` : ""}
${waits ? `<span class="tag">Queued</span>` : ""}
${can ? `<button class="small" data-meta-install="${esc(a.id)}"${busy}>${on ? "Install again" : "Install"}</button>
<button class="small" data-meta-install="${esc(a.id)}" data-test="1"${busy} title="Install, start it, watch it for about 30 s and record whether it ran">Install and test</button>` : ""}
${on ? `<button class="small" data-meta-test="${esc(a.package)}"${busy} title="Start it again, watch it for about 30 s and record whether it ran">Test again</button>` : ""}</div>`;
@@ -3645,13 +3688,15 @@ $("metaApps").addEventListener("click", async e => {
if (!app || installing.has("meta:" + app.id)) return;
if (test && !confirm(`Install and test ${app.title}?\n\nFrame Control starts it in the headset (twice if it has game data to add), `
+ "watches it for about 30 seconds and records whether it ran.")) return;
b.disabled = true; bar.hidden = false; bar.value = 0;
b.disabled = true;
// Several can be queued: the bar and the status line follow whichever is installing.
const r = await runJob(`${test ? "Install and test" : "Install"} ${app.title} from Meta`, "meta:" + app.id,
() => api("/api/meta", { action: "install", app: app.id, test, allowDowngrade: $("metaOlder").checked }),
j => { if (j.stage) $("metaState").textContent = `${app.title}: ${j.stage}`; if (j.percent != null) bar.value = j.percent; });
bar.hidden = true;
j => { renderMetaApps(); if (j.queued || j.done) return; bar.hidden = false; bar.dataset.owner = "meta:" + app.id;
if (j.stage) $("metaState").textContent = `${app.title}: ${j.stage}`; bar.value = j.percent ?? 0; });
if (bar.dataset.owner === "meta:" + app.id) bar.hidden = true; // not while another install still shows its progress
if (r && r.message) { $("metaState").textContent = r.message; metaLib.results.set(app.package, r.test ? r.test.message : r.message); }
else $("metaState").textContent = `${app.title} wasn't installed; the reason is in the message log below.`;
else if (r !== null) $("metaState").textContent = `${app.title} wasn't installed; the reason is in the message log below.`;
loadAndroid(); renderMetaApps(); if (test) loadReports();
});
loadMeta();
+112 -30
View File
@@ -137,7 +137,7 @@ LINK = None # the connector (frame_link.Link); None on the Frame itself
# install's clean-up) to the other headset.
_work_lock = threading.Lock()
_work = [0]
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt"}
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt", "/api/queue"}
@contextlib.contextmanager
@@ -286,40 +286,120 @@ def backfill_art(apps=(), titles=()):
return True
def start_job(label, work, progress=False):
"""Run work() in the background. It returns a dict with a "message"."""
def _new_job_locked(label, **fields):
now = time.time()
with _jobs_lock:
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
del _jobs[j]
job = secrets.token_hex(8)
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now}
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
del _jobs[j]
job = secrets.token_hex(8)
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now, **fields}
return job
def _run_job(job, label, work, progress):
def report(stage, percent=None):
with _jobs_lock:
_jobs[job].update(stage=stage, percent=percent)
def run():
fields = {}
try:
result = work(report) if progress else work()
fields = {"message": result.get("message") or f"{label}: done", "result": result}
except (Failure, frame_android.FrameError) as e:
fields = {"error": unreachable(str(e)) or str(e)}
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
except SourceError as e: # already user-readable, and about a store, not the Frame
fields = {"error": str(e)}
except Exception as e:
fields = {"error": f"{type(e).__name__}: {e}"}
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
finally:
with _jobs_lock:
_jobs[job].update(fields, done=True, time=time.time())
fields = {}
try:
result = work(report) if progress else work()
fields = {"message": result.get("message") or f"{label}: done", "result": result}
except (Failure, frame_android.FrameError) as e:
fields = {"error": unreachable(str(e)) or str(e)}
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
except SourceError as e: # already user-readable, and about a store, not the Frame
fields = {"error": str(e)}
except Exception as e:
fields = {"error": f"{type(e).__name__}: {e}"}
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
finally:
with _jobs_lock:
_jobs[job].update(fields, done=True, time=time.time())
busy_thread(run).start()
def start_job(label, work, progress=False):
"""Run work() in the background. It returns a dict with a "message"."""
with _jobs_lock:
job = _new_job_locked(label)
busy_thread(_run_job, job, label, work, progress).start()
return {"message": f"{label}…", "job": job}
# ---- The install queue: Android games and apps, one after another ----------
#
# Installs from the Android store, Import from my Quest and My Meta library each copy
# a game into a Lepton instance, and each refuses to start while another is running.
# Queued, the page can line up several: one worker runs them in order, each as a
# normal job (/api/job) that reads "queued" until its turn. A queued install can be
# removed until it starts; each runs on the headset it was queued for, or not at all.
_queue = [] # [job, label, work, progress, headset id], in order; the running one is not here
_queue_state = {"worker": False, "running": None}
def _restage_locked():
ahead = 1 if _queue_state["running"] else 0
for i, (job, *_) in enumerate(_queue):
n = ahead + i
_jobs[job]["stage"] = f"Queued: {n} install{'' if n == 1 else 's'} before it" if n else "Queued: next"
def queue_job(label, work, progress=False):
"""Like start_job, but runs after every install queued before it."""
device = LINK.active_device()["id"] if LINK else None
with _jobs_lock:
job = _new_job_locked(label, queued=True, stage=None, percent=None)
_queue.append([job, label, work, progress, device])
_restage_locked()
ahead = len(_queue) - 1 + (1 if _queue_state["running"] else 0)
start, _queue_state["worker"] = not _queue_state["worker"], True
if start:
threading.Thread(target=_drain_queue, daemon=True).start()
return {"message": f"{label}: queued, {ahead} before it" if ahead else f"{label}…", "job": job, "queued": ahead}
def _drain_queue():
while True:
with _jobs_lock:
if not _queue:
_queue_state.update(worker=False, running=None)
return
job, label, work, progress, device = _queue.pop(0)
_queue_state["running"] = job
_jobs[job].update(queued=False, stage=None)
_restage_locked()
def guarded(*report, work=work, device=device):
with working(device): # refused if the app switched headsets since it was queued
return work(*report)
_run_job(job, label, guarded, progress)
def install_queue():
"""The running install and those waiting, in order."""
with _jobs_lock:
ids = ([_queue_state["running"]] if _queue_state["running"] else []) + [q[0] for q in _queue]
return {"jobs": [{"job": j, "label": _jobs[j]["label"], "queued": bool(_jobs[j].get("queued")),
"stage": _jobs[j].get("stage"), "percent": _jobs[j].get("percent")}
for j in ids if j in _jobs]}
def queue_action(body):
"""{"action": "remove", "job"}: take a waiting install out of the queue."""
if body.get("action") != "remove":
raise Failure("unknown action", 400)
job = body.get("job")
with _jobs_lock:
for i, q in enumerate(_queue):
if q[0] == job:
del _queue[i]
_jobs[job].update(done=True, queued=False, cancelled=True, stage=None, time=time.time(),
error="Removed from the install queue")
_restage_locked()
return {"message": f"Removed {_jobs[job]['label']} from the install queue"}
raise Failure("that install isn't waiting in the queue (it may have started or finished)", 409)
def job_status(query):
with _jobs_lock:
job = _jobs.get((parse_qs(query).get("id") or [""])[0])
@@ -1298,7 +1378,7 @@ def android(body):
m = frame_apk_versions.install(pkg, url) if url else frame_catalog.install(pkg)
message = f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel."
return {"message": f"{message} {frame_android.layer_note(m)}".strip(), "app": m}
return start_job(f"Install {pkg}", work)
return queue_job(f"Install {pkg}", work)
if action == "refresh-art":
if not pkg and not body.get("all"):
raise Failure('choose a package or all apps', 400)
@@ -1369,7 +1449,7 @@ def quest(body):
if type(data) is not bool or type(older) is not bool:
raise Failure("data and allowDowngrade must be true or false", 400)
ensure_master()
return start_job(f"Import {pkg} from Quest",
return queue_job(f"Import {pkg} from Quest",
lambda report: frame_quest.import_game(pkg, serial, data=data, progress=report,
allow_downgrade=older), progress=True)
@@ -1450,7 +1530,7 @@ def meta(body):
raise frame_meta.MetaError(frame_meta.redact(e, 1200)) from None
except Exception as e: # never let an unexpected error's text carry the token
raise frame_meta.MetaError(frame_meta.redact(f"{type(e).__name__}: {e}", 600)) from None
return start_job(f"{'Install and test' if test else 'Install'} {app_id} from Meta", work, progress=True)
return queue_job(f"{'Install and test' if test else 'Install'} {app_id} from Meta", work, progress=True)
if action == "test":
pkg = str(body.get("package", ""))
if not frame_android.PKG_RE.match(pkg):
@@ -2344,7 +2424,7 @@ def source_install(body):
raise SourceError('This source is disabled')
except SourceError as e:
raise Failure(str(e), 400)
return start_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
return queue_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
def source_manage(body):
@@ -2387,7 +2467,7 @@ POST = {
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
"/api/input": remote_input, "/api/touch": remote_touch,
"/api/settings/artwork": frame_steamgriddb.save_settings,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel,
@@ -2631,6 +2711,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
elif path == "/api/job":
self.send_json(job_status(url.query))
elif path == "/api/queue":
self.send_json(install_queue())
elif path == "/api/android/displays":
self.send_json(android_displays())
elif path == "/api/android/reports":