mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Tabs, one offline banner, background installs, drop anywhere
The page was one 6,800px scroll with nine nav links (hidden below 1150px). It is now four tabs, Home, Games, Android and Tools, switched with 1-4; old section links still land on the right tab. When the Frame can't be reached, the server turns ssh's connection errors into one plain message (503, offline: true), the page shows a single banner with Retry and Set Up Connection, retries every 8 s, and reloads every panel when the Frame answers. Panels say "Waiting for the Frame" instead of raw ssh text. Flatpak and Android catalogue installs run as background jobs the page polls, so a slow install no longer holds a request for up to 15 minutes or reports a false failure; the bottom bar counts running installs. Files can be dropped anywhere in the window, as the README already said. Recent reports show the newest five, with Show all. Android display explains an empty or failed read. A topped-up headset on a charger reads as not charging rather than "still draining, using 0.0 W". Fixes a race where the catalogue and reports loads wrote the compat-db mirror's .tmp file at once and one failed with a 500. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d7fc0189b5
commit
0f770dc88a
9 files changed
+512
-141
No files matched your search
@@ -16,7 +16,7 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
|
||||
<br>
|
||||
|
||||
<img src="docs/img/frame-control.png" alt="Frame Control showing the headset view, battery and status, and the Steam library" width="900">
|
||||
<img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
|
||||
|
||||
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@ function fromUi(e) {
|
||||
}
|
||||
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||
|
||||
// frame-control://install links from websites (docs/web-install.md). They can
|
||||
// arrive before the window or server exists (macOS open-url on a cold launch),
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||
// as a title without zipping it (the local server reads it from there).
|
||||
// It can open Set Up Connection when the headset can't be reached.
|
||||
// It also receives frame-control://install links (docs/web-install.md): only
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||
onInstallLink: (cb) => {
|
||||
ipcRenderer.removeAllListeners("install-link");
|
||||
|
||||
@@ -17,6 +17,15 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has four tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports) and **Tools** (sending files and text,
|
||||
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
|
||||
anywhere in the window. When the Frame can't be reached, one banner says why in
|
||||
plain words and the app retries every few seconds, filling everything in once it
|
||||
answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
counts them while they run.
|
||||
|
||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||
floating panels, dashboard and controllers). Shows the left eye, like pointing
|
||||
a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 892 KiB After Width: | Height: | Size: 824 KiB |
@@ -185,11 +185,71 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(self.request("GET", "/api/webinstall/job?id=x", headers={"X-Frame-UI": "1"})[0], 404)
|
||||
self.assertEqual(self.post("/api/webinstall/cancel", {"job": "x"})[0], 404)
|
||||
|
||||
def test_unreachable_frame_is_one_clear_offline_error(self):
|
||||
status, _, payload = self.request("GET", "/api/status", headers={"X-Frame-UI": "1"})
|
||||
body = json.loads(payload)
|
||||
self.assertEqual(status, 503, body)
|
||||
self.assertTrue(body["offline"])
|
||||
self.assertIn("Can't find the Frame", body["error"])
|
||||
self.assertIn("frame-control-test.invalid", body["detail"]) # ssh's own words stay available
|
||||
|
||||
def test_flatpak_install_runs_as_a_job(self):
|
||||
status, started = self.post("/api/flatpak", {"id": "org.example.App", "action": "install"})
|
||||
self.assertEqual(status, 200, started)
|
||||
for _ in range(200):
|
||||
status, _, payload = self.request("GET", f"/api/job?id={started['job']}", headers={"X-Frame-UI": "1"})
|
||||
job = json.loads(payload)
|
||||
if job["done"]:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(job["done"])
|
||||
self.assertIn("Can't find the Frame", job["error"])
|
||||
self.assertEqual(self.request("GET", "/api/job?id=nope", headers={"X-Frame-UI": "1"})[0], 404)
|
||||
|
||||
def test_android_install_checks_the_package_before_starting(self):
|
||||
status, body = self.post("/api/android", {"action": "install", "package": "org.example.not.in.catalogue"})
|
||||
self.assertNotEqual(status, 200, body)
|
||||
self.assertNotIn("job", body)
|
||||
|
||||
def test_unknown_routes(self):
|
||||
self.assertEqual(self.request("GET", "/nope")[0], 404)
|
||||
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
||||
|
||||
|
||||
class UnreachableMessages(unittest.TestCase):
|
||||
"""Only ssh's own connection failures are reworded; other errors keep their text."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
import server
|
||||
cls.server = server
|
||||
|
||||
def test_ssh_connection_failures(self):
|
||||
cases = {
|
||||
"ssh: Could not resolve hostname frame: nodename nor servname provided": "Can't find",
|
||||
"ssh: connect to host frame.local port 22: Operation timed out": "isn't answering",
|
||||
"ssh: connect to host 192.168.1.9 port 22: Host is down": "isn't answering",
|
||||
"ssh: connect to host 192.168.1.9 port 22: No route to host": "isn't answering",
|
||||
"ssh: connect to host 192.168.1.9 port 22: Connection refused": "refused",
|
||||
"steamos@192.168.1.9: Permission denied (publickey,password).": "SSH key",
|
||||
"Host key verification failed.": "identity changed",
|
||||
"kex_exchange_identification: read: Connection reset by peer": "dropped",
|
||||
"Timed out talking to frame": "too long",
|
||||
}
|
||||
for raw, words in cases.items():
|
||||
body, status = self.server.error_body(raw)
|
||||
self.assertEqual(status, 503, raw)
|
||||
self.assertIn(words, body["error"], raw)
|
||||
self.assertTrue(body["offline"])
|
||||
|
||||
def test_other_errors_pass_through(self):
|
||||
for raw in ("bad Flatpak app ID", "error: No remote refs found for 'org.example.App'",
|
||||
"cp: cannot open 'x': Permission denied", "timed out waiting for Steam"):
|
||||
self.assertEqual(self.server.error_body(raw), ({"error": raw}, None), raw)
|
||||
|
||||
|
||||
class StatusProbe(unittest.TestCase):
|
||||
# frame_status.py only ever runs on the Frame (Linux); it needs os.statvfs.
|
||||
@unittest.skipIf(os.name == "nt", "Frame-side script; POSIX only")
|
||||
|
||||
+28
-16
@@ -24,6 +24,7 @@ FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'ste
|
||||
'label', 'source')
|
||||
TTL = 60 # seconds a fetched copy is reused
|
||||
_lock = threading.Lock()
|
||||
_load_lock = threading.Lock() # refreshing the cached reports (load); separate from _lock, which flush takes
|
||||
_mem = {'at': 0, 'reports': None, 'source': None}
|
||||
|
||||
|
||||
@@ -179,25 +180,36 @@ def _read_mirror():
|
||||
return []
|
||||
|
||||
|
||||
def _save_mirror(reports):
|
||||
"""Keep a copy for offline use. Failing to write it mustn't fail the read."""
|
||||
try:
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with open(MIRROR + '.tmp', 'w') as f:
|
||||
json.dump({'fetched': time.strftime('%Y-%m-%dT%H:%M:%S'), 'reports': reports}, f)
|
||||
os.replace(MIRROR + '.tmp', MIRROR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load():
|
||||
"""All reports: the database (cached for TTL s), else the offline mirror; plus unsent ones."""
|
||||
now = time.time()
|
||||
if _mem['reports'] is None or now - _mem['at'] > TTL:
|
||||
try:
|
||||
if not shared():
|
||||
raise DBError('no key')
|
||||
# The page asks for the catalogue and the reports at once: one refresh at a
|
||||
# time, so they share a fetch and never write the mirror's .tmp together.
|
||||
with _load_lock:
|
||||
now = time.time()
|
||||
if _mem['reports'] is None or now - _mem['at'] > TTL:
|
||||
try:
|
||||
flush()
|
||||
except Exception:
|
||||
pass # sending can fail for any reason; reading must still work
|
||||
reports, source = fetch_all(), 'lakebed'
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with open(MIRROR + '.tmp', 'w') as f:
|
||||
json.dump({'fetched': time.strftime('%Y-%m-%dT%H:%M:%S'), 'reports': reports}, f)
|
||||
os.replace(MIRROR + '.tmp', MIRROR)
|
||||
except DBError:
|
||||
reports, source = _read_mirror(), 'mirror'
|
||||
_mem.update(at=now, reports=reports, source=source)
|
||||
if not shared():
|
||||
raise DBError('no key')
|
||||
try:
|
||||
flush()
|
||||
except Exception:
|
||||
pass # sending can fail for any reason; reading must still work
|
||||
reports, source = fetch_all(), 'lakebed'
|
||||
_save_mirror(reports)
|
||||
except DBError:
|
||||
reports, source = _read_mirror(), 'mirror'
|
||||
_mem.update(at=now, reports=reports, source=source)
|
||||
sent = {r.get('id') for r in _mem['reports']}
|
||||
return _mem['reports'] + [r for r in _outbox() if r['id'] not in sent]
|
||||
|
||||
|
||||
+304
-113
@@ -60,7 +60,31 @@
|
||||
main { max-width: 1480px; margin: 0 auto; padding: 22px 28px 30px; display: flex; flex-direction: column; gap: 26px; }
|
||||
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
|
||||
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } nav { display: none; } }
|
||||
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: 0 18px; } nav a { padding: 0 10px; } }
|
||||
@media (max-width: 820px) { .brand b, #battChip { display: none; } }
|
||||
|
||||
/* ---- pages: one per tab; the header nav switches between them ---- */
|
||||
.page { display: flex; flex-direction: column; gap: 26px; }
|
||||
.page:not(.on) { display: none; }
|
||||
nav kbd { font: inherit; font-size: 10.5px; color: var(--dim); margin-left: 7px; letter-spacing: 0; }
|
||||
@media (max-width: 1150px) { nav kbd { display: none; } }
|
||||
|
||||
/* ---- offline banner ---- */
|
||||
.banner { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 4px;
|
||||
background: rgba(217,65,38,.12); box-shadow: inset 3px 0 0 var(--bad); }
|
||||
.banner svg { width: 22px; height: 22px; flex: none; color: #ff8a73; }
|
||||
.banner .grow { flex: 1; min-width: 0; }
|
||||
.banner .t { color: var(--bright); font-weight: 500; }
|
||||
.banner .s { color: var(--muted); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wait { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
|
||||
|
||||
/* ---- drop anywhere ---- */
|
||||
.dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none;
|
||||
background: rgba(14,20,27,.82); backdrop-filter: blur(3px); }
|
||||
.dropzone > div { padding: 42px 60px; border: 2px dashed var(--blue); border-radius: 8px; text-align: center;
|
||||
background: rgba(26,159,255,.08); color: var(--text); max-width: 520px; }
|
||||
.dropzone b { display: block; color: var(--bright); font-size: 20px; font-weight: 500; margin-bottom: 6px; }
|
||||
|
||||
/* ---- panels and section headers ---- */
|
||||
.panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%);
|
||||
@@ -203,6 +227,8 @@
|
||||
.bottombar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: 42px; display: flex; align-items: center; gap: 14px;
|
||||
padding: 0 28px; background: rgba(23,29,37,.96); box-shadow: 0 -1px 0 rgba(255,255,255,.04); cursor: pointer; }
|
||||
.bottombar .label { font-size: 12px; font-weight: 700; letter-spacing: 1.6px; color: var(--muted); }
|
||||
.bottombar .jobs { display: inline-flex; align-items: center; gap: 8px; color: var(--link); font-size: 12.5px; }
|
||||
.bottombar .jobs .spinner { width: 13px; height: 13px; border-width: 2px; }
|
||||
.bottombar .last { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: 13px; }
|
||||
.bottombar .last.e { color: #ff8a73; }
|
||||
.drawer { position: fixed; left: 0; right: 0; bottom: 42px; z-index: 19; max-height: 0; overflow: hidden;
|
||||
@@ -270,8 +296,8 @@
|
||||
.game-card .art { width: 100%; aspect-ratio: 231 / 87; border-radius: 2px; object-fit: cover; background: rgba(0,0,0,.25); display: block; }
|
||||
.game-card .price { color: var(--bright); font-size: 13px; }
|
||||
.game-card .price s { color: var(--dim); margin-right: 6px; }
|
||||
.game-card .row a { color: var(--link); text-decoration: none; font-size: 13px; }
|
||||
.game-card .row a:hover { color: #fff; }
|
||||
.cat-card .row a { color: var(--link); text-decoration: none; font-size: 13px; padding: 0 4px; }
|
||||
.cat-card .row a:hover { color: #fff; }
|
||||
.game-card .bar { height: 3px; background: rgba(255,255,255,.08); border-radius: 2px; overflow: hidden; }
|
||||
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
|
||||
.f-0 { color: var(--muted); }
|
||||
@@ -280,6 +306,7 @@
|
||||
padding: 8px 10px; font: inherit; font-size: 13px; }
|
||||
.disp select:focus { outline: none; border-color: var(--blue); }
|
||||
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
|
||||
.disp select:disabled { opacity: .6; }
|
||||
.disp .stat .v { font-size: 15px; }
|
||||
.disp .k2 { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; margin: 12px 0 5px; }
|
||||
.disp .seg { flex-wrap: wrap; }
|
||||
@@ -290,7 +317,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a class="brand" href="#view" aria-label="Frame Control">
|
||||
<a class="brand" href="#home" aria-label="Frame Control">
|
||||
<svg viewBox="0 0 32 32" aria-hidden="true">
|
||||
<defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1a9fff"/><stop offset="1" stop-color="#1a44c2"/></linearGradient></defs>
|
||||
<circle cx="16" cy="16" r="15" fill="url(#lg)"/>
|
||||
@@ -300,18 +327,13 @@
|
||||
<b>FRAME CONTROL</b>
|
||||
</a>
|
||||
<nav id="nav">
|
||||
<a href="#view" class="on">View</a>
|
||||
<a href="#shots">Shots</a>
|
||||
<a href="#library">Library</a>
|
||||
<a href="#getgames">Games</a>
|
||||
<a href="#android">Android</a>
|
||||
<a href="#transfer">Transfer</a>
|
||||
<a href="#apps">Apps</a>
|
||||
<a href="#display">Display</a>
|
||||
<a href="#power">Power</a>
|
||||
<a href="#home" class="on" title="Headset view, status and screenshots (1)">Home<kbd>1</kbd></a>
|
||||
<a href="#games" title="Steam games and sideloaded titles (2)">Games<kbd>2</kbd></a>
|
||||
<a href="#android" title="Android apps and their display (3)">Android<kbd>3</kbd></a>
|
||||
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)">Tools<kbd>4</kbd></a>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<span class="chip" id="conn"><span class="dot"></span><span>Connecting…</span></span>
|
||||
<span class="chip" id="conn" role="status"><span class="dot"></span><span>Connecting…</span></span>
|
||||
<span class="chip" id="battChip" title="Battery">—</span>
|
||||
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
|
||||
@@ -319,6 +341,15 @@
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="banner" id="offline" role="alert" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
|
||||
<div class="grow"><div class="t" id="offMsg">Can't reach the Frame</div>
|
||||
<div class="s" id="offDetail">Retrying every few seconds. Everything fills in when it answers.</div></div>
|
||||
<button class="small" id="offSetup" hidden>Set Up Connection…</button>
|
||||
<button class="action small" id="offRetry">Retry now</button>
|
||||
</div>
|
||||
|
||||
<div class="page on" data-page="home">
|
||||
<div class="grid-top" id="view">
|
||||
<section class="panel">
|
||||
<div class="toolbar">
|
||||
@@ -401,12 +432,21 @@
|
||||
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
||||
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page" data-page="games">
|
||||
<section id="library">
|
||||
<div class="shelf-head"><h2>Library</h2><span class="count" id="gameCount"></span></div>
|
||||
<div class="shelf-head"><h2>Installed</h2><span class="count" id="gameCount"></span></div>
|
||||
<div class="shelf" id="games"><div class="sub">Loading…</div></div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="sideloaded">
|
||||
<div class="shelf-head"><h2>Sideloaded titles</h2><span class="count" id="titleCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="titleAdd" title="Pick a game's .zip or .exe to add to the Steam library">+ Add a game…</button></div>
|
||||
<div class="list" id="titleList"><div class="sub">Loading…</div></div>
|
||||
<div class="hint">Your own Linux or Windows builds, added to the Steam library as Devkit Games. Drop a game's
|
||||
<code>.zip</code>, folder or <code>.exe</code> anywhere in this window.</div>
|
||||
</section>
|
||||
|
||||
<section id="getgames">
|
||||
<div class="shelf-head"><h2>Get games</h2><span class="count" id="gmCount"></span><span class="spacer"></span>
|
||||
@@ -429,7 +469,9 @@
|
||||
compatibility categories.</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page" data-page="android">
|
||||
<section id="android">
|
||||
<div class="shelf-head"><h2>Android apps</h2><span class="count" id="andCount"></span><span class="spacer"></span>
|
||||
<button class="action small" id="repNew" title="Report how any APK worked on the Frame">+ Report an APK</button></div>
|
||||
@@ -442,62 +484,13 @@
|
||||
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>
|
||||
<div class="panel">
|
||||
<div class="shelf-head"><h2>Recent reports</h2><span class="count" id="repCount"></span></div>
|
||||
<div class="list" id="repList"><div class="sub">Loading…</div></div>
|
||||
<div class="hint" id="repHint">Reports change the verdicts in the catalogue. Any APK can be
|
||||
reported, including ones not on F-Droid.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
|
||||
<div class="cat-tools">
|
||||
<input type="text" id="catQ" placeholder="Search F-Droid apps rated for the Frame…">
|
||||
<select id="catCat" aria-label="Category"><option value="">All categories</option></select>
|
||||
</div>
|
||||
<div class="vchips" id="catChips"></div>
|
||||
<div class="cat-grid" id="catGrid"><div class="sub">Loading the catalogue…</div></div>
|
||||
<div class="row" style="margin-top:12px"><button class="small" id="catMore" hidden>Show more</button></div>
|
||||
<div class="hint">Verdicts come from checks of each APK against what Lepton supports (Android 11,
|
||||
64-bit ARM, no clipboard or Play Services) and from reports made on a Frame. See docs/apks.md.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid-3">
|
||||
<section class="panel" id="transfer">
|
||||
<div class="shelf-head"><h2>Send to Frame</h2></div>
|
||||
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
|
||||
<b>Drop files here</b>
|
||||
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app;
|
||||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
|
||||
<input type="file" id="fileInput" multiple hidden>
|
||||
</div>
|
||||
<div class="progress" id="prog"><i></i></div>
|
||||
<div class="shelf-head" style="margin-top:16px"><h2>Sideloaded titles</h2><span class="count" id="titleCount"></span></div>
|
||||
<div class="list" id="titleList"><div class="sub">Loading…</div></div>
|
||||
<textarea id="clipText" style="margin-top:16px" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button class="action small" id="clipSend">Send text</button>
|
||||
<button class="small" id="clipMac">Send this computer's clipboard</button>
|
||||
</div>
|
||||
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="apps">
|
||||
<div class="shelf-head"><h2>Linux apps</h2><span class="count">Flatpak</span></div>
|
||||
<div class="list" id="flatpaks"><div class="sub">Loading…</div></div>
|
||||
<div class="row" style="margin-top:14px; flex-wrap:nowrap">
|
||||
<input type="text" id="fpId" placeholder="Flathub ID, e.g. org.mozilla.firefox">
|
||||
<button class="action" id="fpInstall">Install</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top:8px" id="fpQuick"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel disp" id="display">
|
||||
<section class="panel disp" id="display">
|
||||
<div class="shelf-head"><h2>Android display</h2><span class="count">Lepton</span><span class="spacer"></span>
|
||||
<button class="small" id="dispReload" title="Read the current values again">Refresh</button></div>
|
||||
<select id="dispSel" aria-label="Lepton instance"><option>Loading…</option></select>
|
||||
<div class="hint" id="dispEmpty" hidden>Open an Android app in the headset, then Refresh, to change its
|
||||
resolution, UI scale and text size here.</div>
|
||||
<div id="dispCtl">
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="k">Resolution</div><div class="v" id="dispSize">—</div><div class="d" id="dispSizeD"> </div></div>
|
||||
<div class="stat"><div class="k">UI scale</div><div class="v" id="dispDens">—</div><div class="d" id="dispDensD"> </div></div>
|
||||
@@ -523,7 +516,63 @@
|
||||
<div class="row" style="margin-top:14px"><button class="small danger" id="dispReset" title="wm size reset, wm density reset, and clear font_scale">Reset all</button></div>
|
||||
<div class="hint">Changes apply at once, and the app may redraw or restart its screen. They may not survive the app
|
||||
relaunching (not yet verified). Sharp gives Android more pixels in the same panel, which should make text crisper.</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="panel">
|
||||
<div class="shelf-head"><h2>Recent reports</h2><span class="count" id="repCount"></span></div>
|
||||
<div class="list" id="repList"><div class="sub">Loading…</div></div>
|
||||
<div class="row" style="margin-top:10px"><button class="small" id="repMore" hidden>Show all</button></div>
|
||||
<div class="hint" id="repHint">Reports change the verdicts in the catalogue. Any APK can be
|
||||
reported, including ones not on F-Droid.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
|
||||
<div class="cat-tools">
|
||||
<input type="text" id="catQ" placeholder="Search F-Droid apps rated for the Frame…">
|
||||
<select id="catCat" aria-label="Category"><option value="">All categories</option></select>
|
||||
</div>
|
||||
<div class="vchips" id="catChips"></div>
|
||||
<div class="cat-grid" id="catGrid"><div class="sub">Loading the catalogue…</div></div>
|
||||
<div class="row" style="margin-top:12px"><button class="small" id="catMore" hidden>Show more</button></div>
|
||||
<div class="hint">Verdicts come from checks of each APK against what Lepton supports (Android 11,
|
||||
64-bit ARM, no clipboard or Play Services) and from reports made on a Frame. See docs/apks.md.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<div class="grid-3">
|
||||
<section class="panel" id="transfer">
|
||||
<div class="shelf-head"><h2>Send to Frame</h2></div>
|
||||
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
|
||||
<b>Drop files here, or click to choose</b>
|
||||
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app;
|
||||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
|
||||
You can also drop files anywhere in this window.
|
||||
<input type="file" id="fileInput" multiple hidden>
|
||||
</div>
|
||||
<div class="progress" id="prog"><i></i></div>
|
||||
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
|
||||
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button class="action small" id="clipSend">Send text</button>
|
||||
<button class="small" id="clipMac">Send this computer's clipboard</button>
|
||||
</div>
|
||||
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="apps">
|
||||
<div class="shelf-head"><h2>Linux apps</h2><span class="count">Flatpak</span></div>
|
||||
<div class="list" id="flatpaks"><div class="sub">Loading…</div></div>
|
||||
<div class="row" style="margin-top:14px; flex-wrap:nowrap">
|
||||
<input type="text" id="fpId" placeholder="Flathub ID, e.g. org.mozilla.firefox">
|
||||
<button class="action" id="fpInstall">Install</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top:8px" id="fpQuick"></div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="panel" id="power">
|
||||
<div class="shelf-head"><h2>Remote & power</h2></div>
|
||||
@@ -544,11 +593,16 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
|
||||
<span class="sub">Files go to <code>~/Downloads</code>; <code>.apk</code> files install as Android apps;
|
||||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
|
||||
|
||||
<div class="drawer" id="drawer"><div class="log" id="log"></div></div>
|
||||
<div class="bottombar" id="bottombar" role="button" aria-label="Show activity">
|
||||
<span class="label">ACTIVITY</span>
|
||||
<span class="jobs" id="jobs" hidden><span class="spinner"></span><span id="jobsText"></span></span>
|
||||
<span class="last" id="lastLog">Ready</span>
|
||||
<span class="sub" id="drawerHint">Show ▴</span>
|
||||
</div>
|
||||
@@ -665,11 +719,23 @@ function applyHostWording(h) {
|
||||
async function api(path, body) {
|
||||
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
|
||||
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
|
||||
const r = await fetch(path, opts);
|
||||
let r;
|
||||
try { r = await fetch(path, opts); }
|
||||
catch { throw new Error("Frame Control's local server isn't running. Restart the app (Frame → Restart Server)."); }
|
||||
const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
|
||||
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
||||
if (!r.ok) {
|
||||
const err = new Error(data.error || `HTTP ${r.status}`);
|
||||
err.offline = !!data.offline;
|
||||
if (err.offline) setOnline(false, err.message);
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
// What a panel shows when its data couldn't be read: a quiet placeholder while
|
||||
// the Frame is unreachable (the banner explains), the real error otherwise.
|
||||
function failed(el, e) {
|
||||
el.innerHTML = e.offline ? `<div class="wait">Waiting for the Frame</div>` : `<div class="sub">${esc(e.message)}</div>`;
|
||||
}
|
||||
async function act(label, fn, btn) {
|
||||
if (btn) btn.disabled = true;
|
||||
log(label + "…");
|
||||
@@ -687,17 +753,54 @@ function setConn(ok, text) {
|
||||
$("conn").innerHTML = `<span class="dot ${ok ? "on" : "off"}"></span><span>${esc(text)}</span>`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
$("refreshAll").disabled = true;
|
||||
try {
|
||||
state = await api("/api/status");
|
||||
render(state);
|
||||
setConn(true, state.hostname);
|
||||
} catch (e) {
|
||||
// Whether the Frame answers. Any request that couldn't reach it flips this off;
|
||||
// the next successful status check flips it back and reloads what failed.
|
||||
let online = null;
|
||||
function setOnline(ok, why) {
|
||||
const was = online;
|
||||
online = ok;
|
||||
$("offline").hidden = ok;
|
||||
if (!ok) {
|
||||
$("offMsg").textContent = why || "Can't reach the Frame";
|
||||
setConn(false, "Offline");
|
||||
$("battChip").textContent = "—";
|
||||
log("Status failed: " + e.message, "e");
|
||||
} finally { $("refreshAll").disabled = false; }
|
||||
$("battChip").hidden = true;
|
||||
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
|
||||
} else if (was === false) {
|
||||
log("Connected to the Frame again", "ok");
|
||||
reloadAll();
|
||||
}
|
||||
}
|
||||
function reloadAll() { loadTitles(); loadOwned(); loadAndroid(); loadDisplays(); loadShots(); }
|
||||
$("offRetry").onclick = () => refresh();
|
||||
if (window.frameApp && window.frameApp.setUpConnection) {
|
||||
$("offSetup").hidden = false;
|
||||
$("offSetup").onclick = () => window.frameApp.setUpConnection();
|
||||
}
|
||||
$("conn").onclick = () => { if (online === false) refresh(); };
|
||||
|
||||
let refreshing = null, pollTimer = null;
|
||||
function schedule() { // every 30 s while connected, every 8 s while not
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = setTimeout(() => { if (document.hidden) schedule(); else refresh(); }, online === false ? 8000 : 30000);
|
||||
}
|
||||
function refresh() {
|
||||
if (refreshing) return refreshing;
|
||||
$("refreshAll").disabled = true; $("offRetry").disabled = true;
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
state = await api("/api/status");
|
||||
render(state);
|
||||
setConn(true, state.hostname);
|
||||
setOnline(true);
|
||||
} catch (e) {
|
||||
if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
|
||||
if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
|
||||
} finally {
|
||||
$("refreshAll").disabled = false; $("offRetry").disabled = false;
|
||||
refreshing = null; schedule();
|
||||
}
|
||||
})();
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
// ---- battery ----
|
||||
@@ -706,7 +809,9 @@ function battery(b, power) {
|
||||
const full = b?.status === "Full" || (!!power && pct >= 100);
|
||||
const charging = !full && b?.status === "Charging";
|
||||
// A weak charger can lose to the load: plugged in but still draining.
|
||||
const draining = !full && !charging && (!power || b?.status === "Discharging" || b?.watts < 0);
|
||||
// Topped up on a charger it reports Discharging at ~0 W: that's idle, not draining.
|
||||
const idle = !!power && b?.watts != null && Math.abs(b.watts) < 0.5;
|
||||
const draining = !full && !charging && !idle && (!power || b?.status === "Discharging" || b?.watts < 0);
|
||||
const low = !charging && !full && pct != null && pct <= 15;
|
||||
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf";
|
||||
$("battFill").setAttribute("width", pct == null ? 0 : (55 * Math.min(100, Math.max(0, pct)) / 100).toFixed(1));
|
||||
@@ -738,6 +843,7 @@ function battery(b, power) {
|
||||
$("battChip").innerHTML = pct == null ? "—" :
|
||||
`<svg width="22" height="12" viewBox="0 0 22 12" aria-hidden="true"><rect x=".75" y=".75" width="18" height="10.5" rx="2" fill="none" stroke="#8f98a0" stroke-width="1.5"/><rect x="19.8" y="3.5" width="1.8" height="5" rx=".6" fill="#8f98a0"/><rect x="2.5" y="2.5" width="${(14.5 * chipPct / 100).toFixed(1)}" height="7" rx="1" fill="${color}"/></svg>${bolt}<span>${pct}%</span>`;
|
||||
$("battChip").title = label;
|
||||
$("battChip").hidden = pct == null;
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
@@ -795,7 +901,8 @@ function render(s) {
|
||||
</div>`).join("") : `<div class="sub">No Flatpak apps.</div>`;
|
||||
const have = new Set(s.flatpaks.map(f => f.id));
|
||||
$("fpQuick").innerHTML = QUICK.filter(([, id]) => !have.has(id))
|
||||
.map(([n, id]) => `<button class="small" data-quick="${esc(id)}">+ ${esc(n)}</button>`).join("");
|
||||
.map(([n, id]) => installing.has(id) ? `<button class="small" disabled>Installing ${esc(n)}…</button>`
|
||||
: `<button class="small" data-quick="${esc(id)}">+ ${esc(n)}</button>`).join("");
|
||||
}
|
||||
|
||||
// ---- view ----
|
||||
@@ -855,7 +962,7 @@ $("canvas").addEventListener("pointerdown", e => {
|
||||
$("canvas").addEventListener("pointerup", up, { once: true });
|
||||
});
|
||||
document.addEventListener("keydown", e => {
|
||||
if ($("canvas").hidden || /INPUT|TEXTAREA/.test(document.activeElement.tagName) || e.metaKey || e.ctrlKey) return;
|
||||
if (page !== "home" || $("canvas").hidden || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || e.metaKey || e.ctrlKey) return;
|
||||
if (e.key === "+" || e.key === "=") setZoom(zoom * 1.5);
|
||||
else if (e.key === "-") setZoom(zoom / 1.5);
|
||||
else if (e.key === "0") setZoom(1);
|
||||
@@ -1073,17 +1180,48 @@ document.body.addEventListener("click", async (e) => {
|
||||
else if (b.dataset.uninstall) {
|
||||
if (confirm(`Remove ${b.dataset.name} from the Frame?`))
|
||||
act(`Remove ${b.dataset.name}`, () => api("/api/flatpak", { action: "uninstall", id: b.dataset.uninstall }), b).then(refresh);
|
||||
} else if (b.dataset.quick) installFlatpak(b.dataset.quick, b);
|
||||
} else if (b.dataset.quick) installFlatpak(b.dataset.quick);
|
||||
else if (b.dataset.open) {
|
||||
if (b.dataset.confirm && !confirm(b.dataset.confirm)) return;
|
||||
act(b.textContent.trim(), () => api("/api/open", { what: b.dataset.open }), b);
|
||||
}
|
||||
});
|
||||
function installFlatpak(id, btn) {
|
||||
toast(`Installing ${id}. This can take a few minutes…`);
|
||||
return act(`Install ${id}`, () => api("/api/flatpak", { action: "install", id }), btn).then(refresh);
|
||||
// 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();
|
||||
let jobsRunning = 0;
|
||||
function showJobs() {
|
||||
$("jobs").hidden = !jobsRunning;
|
||||
$("jobsText").textContent = `${jobsRunning} install${jobsRunning === 1 ? "" : "s"} running`;
|
||||
}
|
||||
$("fpInstall").onclick = () => { const id = $("fpId").value.trim(); if (id) installFlatpak(id, $("fpInstall")); };
|
||||
async function runJob(label, key, start) {
|
||||
installing.add(key); jobsRunning++; showJobs();
|
||||
log(label + "…");
|
||||
try {
|
||||
const { job } = await start();
|
||||
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.done) continue;
|
||||
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(); }
|
||||
}
|
||||
function installFlatpak(id) {
|
||||
if (installing.has(id)) return;
|
||||
toast(`Installing ${id}. This can take a few minutes…`);
|
||||
const done = runJob(`Install ${id}`, id, () => api("/api/flatpak", { action: "install", id })).then(refresh);
|
||||
if (state) render(state); // show it as installing
|
||||
return done;
|
||||
}
|
||||
$("fpInstall").onclick = () => { const id = $("fpId").value.trim(); if (id) { installFlatpak(id); $("fpId").value = ""; } };
|
||||
$("fpId").addEventListener("keydown", e => { if (e.key === "Enter") $("fpInstall").click(); });
|
||||
|
||||
function setVolume(pct, muted) {
|
||||
@@ -1123,10 +1261,26 @@ const drop = $("drop");
|
||||
drop.onclick = () => $("fileInput").click();
|
||||
drop.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("fileInput").click(); } };
|
||||
$("fileInput").onchange = () => sendFiles([...$("fileInput").files]);
|
||||
["dragenter", "dragover"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.add("over"); }));
|
||||
["dragleave", "drop"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.remove("over"); }));
|
||||
// Folders only show up as directory entries, and only during the drop event itself.
|
||||
drop.addEventListener("drop", e => {
|
||||
// Files can be dropped anywhere in the window; an overlay says what happens.
|
||||
// Dialogs keep their own drops (none take files today), so the overlay stays out of them.
|
||||
let dragDepth = 0;
|
||||
const carriesFiles = e => [...(e.dataTransfer?.types || [])].includes("Files");
|
||||
window.addEventListener("dragenter", e => {
|
||||
if (!carriesFiles(e) || document.querySelector("dialog[open]")) return;
|
||||
e.preventDefault();
|
||||
if (dragDepth++ === 0) { $("dropzone").hidden = false; drop.classList.add("over"); }
|
||||
});
|
||||
window.addEventListener("dragover", e => { if (carriesFiles(e)) e.preventDefault(); });
|
||||
window.addEventListener("dragleave", e => {
|
||||
if (!carriesFiles(e) || dragDepth === 0) return;
|
||||
if (--dragDepth === 0) { $("dropzone").hidden = true; drop.classList.remove("over"); }
|
||||
});
|
||||
window.addEventListener("drop", e => {
|
||||
if (!carriesFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth = 0; $("dropzone").hidden = true; drop.classList.remove("over");
|
||||
if (document.querySelector("dialog[open]")) return;
|
||||
// Folders only show up as directory entries, and only during the drop event itself.
|
||||
const items = [...(e.dataTransfer.items || [])].filter(it => it.kind === "file");
|
||||
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
|
||||
sendFiles([...e.dataTransfer.files], dirs);
|
||||
@@ -1284,7 +1438,7 @@ async function loadTitles() {
|
||||
let list, err;
|
||||
try { list = (await api("/api/titles")).titles; } catch (e) { err = e; }
|
||||
if (seq !== titlesSeq) return;
|
||||
if (err) { $("titleList").innerHTML = `<div class="sub">${esc(err.message)}</div>`; return; }
|
||||
if (err) return failed($("titleList"), err);
|
||||
$("titleCount").textContent = list.length ? `${list.length}` : "";
|
||||
$("titleList").innerHTML = list.length ? list.map(t => `
|
||||
<div class="item">
|
||||
@@ -1292,7 +1446,7 @@ async function loadTitles() {
|
||||
<div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div>
|
||||
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">▶</button>
|
||||
<button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
|
||||
</div>`).join("") : `<div class="sub">None yet. Drop a game's .zip, folder or .exe above.</div>`;
|
||||
</div>`).join("") : `<div class="sub">None yet.</div>`;
|
||||
}
|
||||
$("titleList").onclick = async e => {
|
||||
const b = e.target.closest("[data-title]"); if (!b) return;
|
||||
@@ -1304,6 +1458,7 @@ $("titleList").onclick = async e => {
|
||||
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
|
||||
};
|
||||
loadTitles();
|
||||
$("titleAdd").onclick = () => $("fileInput").click();
|
||||
|
||||
|
||||
// ---- Steam games: install owned games, find and buy on the store ----
|
||||
@@ -1321,8 +1476,8 @@ async function loadOwned() {
|
||||
gm.owned = r.games; gm.country = r.country || gm.country; gm.dl = r.download;
|
||||
gm.byId = new Map(gm.owned.map(g => [g.id, g]));
|
||||
} catch (e) {
|
||||
if (!gm.owned) $("gmGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`;
|
||||
log("Steam library: " + e.message, "e");
|
||||
if (!gm.owned) failed($("gmGrid"), e);
|
||||
if (!e.offline) log("Steam library: " + e.message, "e");
|
||||
// Keep trying while a download was in progress; one failed poll shouldn't freeze it.
|
||||
clearTimeout(gm.poll);
|
||||
if (gm.dl) gm.poll = setTimeout(loadOwned, 15000);
|
||||
@@ -1458,7 +1613,7 @@ let androidApps = [];
|
||||
async function loadAndroid() {
|
||||
try {
|
||||
androidApps = (await api("/api/android")).apps;
|
||||
} catch (e) { $("andApps").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||||
} catch (e) { return failed($("andApps"), e); }
|
||||
$("andCount").textContent = androidApps.length ? `${androidApps.length} installed` : "";
|
||||
$("andApps").innerHTML = androidApps.length ? androidApps.map(a => {
|
||||
const c = cat.apps && cat.apps.find(x => x.p === a.package);
|
||||
@@ -1521,6 +1676,7 @@ function moreCatalog() {
|
||||
<div class="why">${esc(a.why[0] || "")}${a.why.length > 1 ? `<span title="${esc(a.why.slice(1).join("\n"))}"> · more</span>` : ""}</div>
|
||||
<div class="row">
|
||||
${installed.has(a.p) ? `<span class="tag">Installed</span>`
|
||||
: installing.has(a.p) ? `<span class="tag">Installing…</span>`
|
||||
: a.r === "no" ? "" : `<button class="action small" data-and="install" data-pkg="${esc(a.p)}" data-name="${esc(a.n)}">Install</button>`}
|
||||
<button class="small" data-and="rate" data-pkg="${esc(a.p)}" data-ver="${esc(a.v)}" data-name="${esc(a.n)}" data-src="F-Droid" title="Report how it worked">Report</button>
|
||||
<a class="small" href="https://f-droid.org/packages/${esc(a.p)}/" target="_blank" rel="noopener">F-Droid</a>
|
||||
@@ -1543,8 +1699,11 @@ document.body.addEventListener("click", async e => {
|
||||
const b = e.target.closest("[data-and]"); if (!b) return;
|
||||
const pkg = b.dataset.pkg, action = b.dataset.and;
|
||||
if (action === "install") {
|
||||
if (installing.has(pkg)) return;
|
||||
toast(`Installing ${b.dataset.name}. The first time takes a minute…`);
|
||||
await act(`Install ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
||||
const done = runJob(`Install ${b.dataset.name}`, pkg, () => api("/api/android", { action, package: pkg }));
|
||||
b.outerHTML = `<span class="tag">Installing…</span>`;
|
||||
await done;
|
||||
} else if (action === "remove") {
|
||||
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
|
||||
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
||||
@@ -1562,7 +1721,7 @@ document.body.addEventListener("click", async e => {
|
||||
loadCatalog();
|
||||
|
||||
// ---- Android display (wm size / wm density / font_scale per Lepton instance) ----
|
||||
const disp = { list: [], port: null };
|
||||
const disp = { list: [], port: null, error: null, seq: 0 };
|
||||
const DPI = [120, 640];
|
||||
const dims = s => { const m = /^(\d+)x(\d+)$/.exec(s || ""); return m ? [+m[1], +m[2]] : null; };
|
||||
const clampDpi = d => Math.max(DPI[0], Math.min(DPI[1], Math.round(d)));
|
||||
@@ -1584,16 +1743,27 @@ function dispCalc(i) {
|
||||
}
|
||||
async function loadDisplays() {
|
||||
$("dispReload").disabled = true;
|
||||
const seq = ++disp.seq; // an older, slower request mustn't overwrite a newer answer
|
||||
let list = [], error = null;
|
||||
try {
|
||||
disp.list = (await api("/api/android/displays")).instances;
|
||||
list = (await api("/api/android/displays")).instances;
|
||||
} catch (e) {
|
||||
disp.list = [];
|
||||
log("Android display: " + e.message, "e");
|
||||
error = e;
|
||||
if (!e.offline) log("Android display: " + e.message, "e");
|
||||
} finally { $("dispReload").disabled = false; }
|
||||
if (seq !== disp.seq) return;
|
||||
disp.list = list; disp.error = error;
|
||||
if (!disp.list.some(i => i.port === disp.port)) disp.port = disp.list.length ? disp.list[0].port : null;
|
||||
const sel = $("dispSel");
|
||||
sel.innerHTML = disp.list.length ? disp.list.map(i => `<option value="${i.port}">${esc(dispLabel(i))}</option>`).join("")
|
||||
: `<option value="">No Lepton instance is running</option>`;
|
||||
: `<option value="">${disp.error ? (disp.error.offline ? "Waiting for the Frame" : "Couldn't read Android's display")
|
||||
: "No Android app is open"}</option>`;
|
||||
sel.disabled = !disp.list.length;
|
||||
// Open-an-app advice only fits a successful, empty answer; otherwise say what went wrong.
|
||||
$("dispEmpty").textContent = !disp.error ? "Open an Android app in the headset, then Refresh, to change its resolution, UI scale and text size here."
|
||||
: disp.error.offline ? "This fills in when the Frame answers." : disp.error.message;
|
||||
$("dispEmpty").hidden = !!disp.list.length;
|
||||
$("dispCtl").hidden = !disp.list.length;
|
||||
if (disp.port) sel.value = String(disp.port);
|
||||
renderDisplay();
|
||||
}
|
||||
@@ -1661,6 +1831,9 @@ loadDisplays();
|
||||
const RLABEL = { works: "Works", issues: "Problems", broken: "Doesn't work", runs: "Runs (test)",
|
||||
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
|
||||
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
|
||||
const REPORTS_SHOWN = 5;
|
||||
let repsAll = false;
|
||||
$("repMore").onclick = () => { repsAll = true; loadReports(); };
|
||||
async function loadReports() {
|
||||
let reps, shared;
|
||||
try { ({ reports: reps, shared } = await api("/api/android/reports")); }
|
||||
@@ -1669,7 +1842,9 @@ async function loadReports() {
|
||||
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
|
||||
: "Reports are saved on this computer and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
|
||||
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
||||
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
|
||||
$("repMore").hidden = reps.length <= REPORTS_SHOWN || repsAll;
|
||||
$("repMore").textContent = `Show all ${reps.length}`;
|
||||
$("repList").innerHTML = reps.length ? (repsAll ? reps : reps.slice(0, REPORTS_SHOWN)).map(r => {
|
||||
const k = r.rating || r.result;
|
||||
return `<div class="item rep-item"><div class="grow">
|
||||
<div class="t">${esc(r.name)} <span class="sub">v${esc(String(r.version || "?").replace(/^v/i, ""))}</span></div>
|
||||
@@ -1749,8 +1924,7 @@ async function loadShots() {
|
||||
try {
|
||||
shots.list = (await api("/api/shots")).shots;
|
||||
} catch (e) {
|
||||
$("shotGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`;
|
||||
return;
|
||||
return failed($("shotGrid"), e);
|
||||
} finally { $("shotsRefresh").disabled = false; }
|
||||
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
||||
const unsaved = shots.list.filter(s => !s.saved).length;
|
||||
@@ -1815,12 +1989,29 @@ $("shotsRefresh").onclick = loadShots;
|
||||
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
||||
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
|
||||
|
||||
// ---- nav highlight follows scroll ----
|
||||
const spy = new IntersectionObserver(entries => {
|
||||
const top = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
|
||||
if (top) document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + top.target.id));
|
||||
}, { rootMargin: "-80px 0px -55% 0px" });
|
||||
["view", "shots", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id)));
|
||||
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
|
||||
const PAGES = ["home", "games", "android", "tools"];
|
||||
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
|
||||
display: "android", transfer: "tools", apps: "tools", power: "tools" };
|
||||
let page = "home";
|
||||
function showPage() {
|
||||
const id = location.hash.slice(1);
|
||||
const next = PAGES.includes(id) ? id : SECTION_PAGE[id] || "home";
|
||||
if (next !== page && page === "home" && live) toggleLive(false); // don't stream video nobody is watching
|
||||
page = next;
|
||||
document.querySelectorAll(".page").forEach(p => p.classList.toggle("on", p.dataset.page === page));
|
||||
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
|
||||
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
|
||||
const section = !PAGES.includes(id) && id && $(id);
|
||||
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
||||
}
|
||||
window.addEventListener("hashchange", showPage);
|
||||
showPage();
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;
|
||||
const n = PAGES[+e.key - 1];
|
||||
if (n) location.hash = n;
|
||||
});
|
||||
|
||||
// ---- install links from websites (frame-control://install, docs/web-install.md) ----
|
||||
// The app passes each link here. The server checks it and reads the manifest;
|
||||
@@ -1928,7 +2119,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
|
||||
|
||||
setView("headset");
|
||||
refresh().then(loadShots); // after status, so app names resolve
|
||||
setInterval(() => { if (!document.hidden) refresh(); }, 30000);
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+107
-11
@@ -85,6 +85,89 @@ class Failure(Exception):
|
||||
self.status = status
|
||||
|
||||
|
||||
# What ssh prints when it never reached the Frame, and what to tell the user
|
||||
# instead. Only ssh's own wording is matched, so a command that ran on the Frame
|
||||
# and failed keeps its real error.
|
||||
UNREACHABLE = [
|
||||
(re.compile(r"Could not resolve hostname"),
|
||||
"Can't find the Frame on the network. Check it's on and connected, or run Set Up Connection."),
|
||||
(re.compile(r"port \d+: (Operation timed out|Connection timed out|Host is down|No route to host|"
|
||||
r"Network is unreachable)"),
|
||||
"The Frame isn't answering. It may be asleep, switched off, or on another network."),
|
||||
(re.compile(r"[Tt]imed out talking to "),
|
||||
"The Frame took too long to answer. It may be asleep or busy; try again."),
|
||||
(re.compile(r"port \d+: Connection refused"),
|
||||
"The Frame refused the connection. Check Developer Mode is still on."),
|
||||
(re.compile(r"Permission denied \(publickey"),
|
||||
"The Frame didn't accept this computer's SSH key. Run Set Up Connection again."),
|
||||
(re.compile(r"Host key verification failed"),
|
||||
"The Frame's SSH identity changed (after a reinstall, or a different device). Run Set Up Connection again."),
|
||||
(re.compile(r"kex_exchange_identification|Connection closed by .* port \d+|Connection reset by .* port \d+"),
|
||||
"The connection to the Frame dropped. Try again."),
|
||||
]
|
||||
|
||||
|
||||
def unreachable(message):
|
||||
"""The plain-language reason the Frame couldn't be reached, or None if it was."""
|
||||
for pattern, friendly in UNREACHABLE:
|
||||
if pattern.search(message):
|
||||
return friendly
|
||||
return None
|
||||
|
||||
|
||||
def error_body(message):
|
||||
"""A JSON error body and status; SSH connection failures become one clear offline message."""
|
||||
friendly = unreachable(message)
|
||||
if friendly:
|
||||
return {"error": friendly, "offline": True, "detail": message}, 503
|
||||
return {"error": message}, None
|
||||
|
||||
|
||||
# ---- Background jobs: installs that can outlast a request ------------------
|
||||
#
|
||||
# Flatpak and Android installs can take many minutes. The request starts the
|
||||
# work and returns a job id at once; the page polls /api/job for the outcome.
|
||||
|
||||
JOB_TTL = 3600
|
||||
_jobs_lock = threading.Lock()
|
||||
_jobs = {} # id -> {"label", "done", "error", "message", "result", "time"}
|
||||
|
||||
|
||||
def start_job(label, work):
|
||||
"""Run work() in the background. It returns a dict with a "message"."""
|
||||
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}
|
||||
|
||||
def run():
|
||||
fields = {}
|
||||
try:
|
||||
result = 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)}
|
||||
except Exception as e:
|
||||
fields = {"error": f"{type(e).__name__}: {e}"}
|
||||
finally:
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(fields, done=True, time=time.time())
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
return {"message": f"{label}…", "job": job}
|
||||
|
||||
|
||||
def job_status(query):
|
||||
with _jobs_lock:
|
||||
job = _jobs.get((parse_qs(query).get("id") or [""])[0])
|
||||
snapshot = job and {k: v for k, v in job.items() if k != "time"}
|
||||
if not snapshot:
|
||||
raise Failure("no such job (the app may have restarted)", 404)
|
||||
return snapshot
|
||||
|
||||
|
||||
_master_lock = threading.Lock()
|
||||
_master = None
|
||||
|
||||
@@ -408,11 +491,13 @@ def flatpak(body):
|
||||
if not FLATPAK_ID.match(app):
|
||||
raise Failure("bad Flatpak app ID", 400)
|
||||
if action == "install":
|
||||
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
|
||||
ssh("flatpak remote-add --user --if-not-exists flathub "
|
||||
"https://dl.flathub.org/repo/flathub.flatpakrepo && "
|
||||
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=900)
|
||||
return {"message": f"Installed {app}"}
|
||||
def work():
|
||||
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
|
||||
ssh("flatpak remote-add --user --if-not-exists flathub "
|
||||
"https://dl.flathub.org/repo/flathub.flatpakrepo && "
|
||||
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=1800)
|
||||
return {"message": f"Installed {app}"}
|
||||
return start_job(f"Install {app}", work)
|
||||
if action == "uninstall":
|
||||
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
|
||||
return {"message": strip_ansi(out).strip() or f"Removed {app}"}
|
||||
@@ -449,8 +534,13 @@ def android(body):
|
||||
ensure_master()
|
||||
try:
|
||||
if action == "install":
|
||||
m = frame_catalog.install(pkg)
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.", "app": m}
|
||||
frame_catalog.app(pkg) # an unknown package fails now, not in the background
|
||||
|
||||
def work():
|
||||
m = frame_catalog.install(pkg)
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
|
||||
"app": m}
|
||||
return start_job(f"Install {pkg}", work)
|
||||
if action in ("launch", "stop"):
|
||||
m = getattr(frame_android, action)(pkg)
|
||||
return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"}
|
||||
@@ -1156,6 +1246,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def send_json(self, obj, status=200):
|
||||
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
||||
|
||||
def send_error_json(self, message, status):
|
||||
body, offline_status = error_body(message)
|
||||
self.send_json(body, offline_status or status)
|
||||
|
||||
def do_GET(self):
|
||||
if not self.local_request():
|
||||
return
|
||||
@@ -1175,6 +1269,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"titles": frame_titles.list_titles()})
|
||||
elif path == "/api/titles/job":
|
||||
self.send_json(title_job(url.query))
|
||||
elif path == "/api/job":
|
||||
self.send_json(job_status(url.query))
|
||||
elif path == "/api/android/displays":
|
||||
self.send_json(android_displays())
|
||||
elif path == "/api/android/reports":
|
||||
@@ -1204,9 +1300,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
except Failure as e:
|
||||
self.send_json({"error": str(e)}, e.status)
|
||||
self.send_error_json(str(e), e.status)
|
||||
except frame_android.FrameError as e:
|
||||
self.send_json({"error": str(e)}, 502)
|
||||
self.send_error_json(str(e), 502)
|
||||
except Exception as e:
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
@@ -1230,11 +1326,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
self.send_json(handler(body))
|
||||
except Failure as e:
|
||||
self.send_json({"error": str(e)}, e.status)
|
||||
self.send_error_json(str(e), e.status)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.send_json({"error": f"bad request: {e}"}, 400)
|
||||
except frame_android.FrameError as e:
|
||||
self.send_json({"error": str(e)}, 502)
|
||||
self.send_error_json(str(e), 502)
|
||||
except Exception as e:
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
|
||||
Reference in new issue
Block a user