mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-28 02:03:21 +01:00
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:
10 files changed
+329
-57
No files matched your search
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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'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");
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user