diff --git a/app/package-lock.json b/app/package-lock.json index 58fd182..8467e4a 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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", diff --git a/app/package.json b/app/package.json index 15aae97..93e695c 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/docs/meta-library.md b/docs/meta-library.md index e5268c1..561d660 100644 --- a/docs/meta-library.md +++ b/docs/meta-library.md @@ -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 diff --git a/docs/quest-import.md b/docs/quest-import.md index 55e0ebd..a67421b 100644 --- a/docs/quest-import.md +++ b/docs/quest-import.md @@ -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 diff --git a/tests/page/meta_rows.mjs b/tests/page/meta_rows.mjs index 5a3fb30..b83b43e 100644 --- a/tests/page/meta_rows.mjs +++ b/tests/page/meta_rows.mjs @@ -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 (!/
]*>Meta's API said HTTP 500Queued<\/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"); diff --git a/tests/test_install_queue.py b/tests/test_install_queue.py new file mode 100644 index 0000000..1dfc443 --- /dev/null +++ b/tests/test_install_queue.py @@ -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() diff --git a/tests/test_meta.py b/tests/test_meta.py index 2edbf5f..db1c4f9 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -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}')): diff --git a/tests/test_quest.py b/tests/test_quest.py index a79f5ed..ff24bf3 100644 --- a/tests/test_quest.py +++ b/tests/test_quest.py @@ -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']) diff --git a/ui/index.html b/ui/index.html index b56141b..bdb1f9f 100644 --- a/ui/index.html +++ b/ui/index.html @@ -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.
+

Import from my Quest

@@ -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 => `
${esc(j.label)}
+
${esc(j.queued ? j.stage || "Queued" : `${j.stage || "Starting"}${j.percent != null ? ` · ${Math.round(j.percent)}%` : ""}`)}
+ ${j.queued ? `` : `Installing`}
`).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 `
${esc(a.package)}
${esc(bits.join(" · "))}
${installed.has(a.package) ? `On the Frame` : ""} -
`; + `; }).join("") : `
No games or apps you installed are on this Quest.
`; } $("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 ? `
Community: ${v[1]}
` : ""; const r = metaLib.results.get(a.package); const result = r ? `
${esc(r)}
` : ""; - 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 `
${esc(a.title)}
${esc(line)}
${problem}${skipped}${verdict}${result}
${on ? `On the Frame` : ""} + ${waits ? `Queued` : ""} ${can ? ` ` : ""} ${on ? `` : ""}
`; @@ -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(); diff --git a/ui/server.py b/ui/server.py index 8d7be93..b321d46 100755 --- a/ui/server.py +++ b/ui/server.py @@ -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":