mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
Merge main into apk-alternatives: install other versions as background jobs
Main now runs Android installs as background jobs and maps SSH failures to one offline message. Alternative-version installs go through the same job, the alternatives dialog waits on it with runJob, and send_error_json keeps the apk blocker that opens the dialog. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
c814cb95d0
136 files changed
+11160
-234
No files matched your search
+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]
|
||||
|
||||
|
||||
+18
-9
@@ -35,7 +35,13 @@ PY = 'python3 ~/' + UTILS + '/'
|
||||
# devkit-steam is the trampoline file that switches SteamOS to a sideloaded client
|
||||
# (select_steam.sh); a folder there breaks Valve's devkit tools.
|
||||
RESERVED_IDS = ('steam', 'steamdeckard', 'steamvr', 'steamvrdeckard', 'devkit-steam')
|
||||
ID_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$')
|
||||
# Any title folder on the Frame we'll list, launch or remove: safe in a shell and a path.
|
||||
ID_RE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$')
|
||||
# What a new install may use. Steam's create-shortcut refused "fc-smoke-exe" with
|
||||
# "missing/invalid arguments" and took the same program as "FCSmokeProbe" (headset
|
||||
# smoke test, 2026-09-27, build 20260922.6101926); Valve's client only allows
|
||||
# GAMEID_ALLOWED_PATTERN, ^[A-Za-z_][A-Za-z0-9_.]+$ (devkit_client/gui2). No dots here.
|
||||
NEW_ID_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]{1,63}$')
|
||||
DIR_RE = re.compile(r'^/[A-Za-z0-9_./-]+$')
|
||||
|
||||
# Zip limits: well above any real game, well below a zip bomb.
|
||||
@@ -124,13 +130,15 @@ def _pe(f, head):
|
||||
|
||||
|
||||
def title_id(name):
|
||||
"""The Devkit Game id Steam shows as the title's name: [A-Za-z0-9_-], at most 64."""
|
||||
s = re.sub(r'[^A-Za-z0-9_-]+', '_', str(name or '').strip())
|
||||
s = re.sub(r'_+', '_', s).strip('_-')[:64].strip('_-')
|
||||
"""The Devkit Game id Steam shows as the title's name, in the form Steam accepts
|
||||
(NEW_ID_RE): letters, digits and _, not starting with a digit, 2 to 64 long."""
|
||||
s = re.sub(r'[^A-Za-z0-9]+', '_', str(name or '').strip()).strip('_')[:64].strip('_')
|
||||
if not s:
|
||||
raise FrameError('the title needs a name with some letters or digits')
|
||||
if s.lower() in RESERVED_IDS:
|
||||
s += '-game'
|
||||
if s[0].isdigit():
|
||||
s = '_' + s[:63]
|
||||
if len(s) < 2 or s.lower() in RESERVED_IDS:
|
||||
s += '_game'
|
||||
return s
|
||||
|
||||
|
||||
@@ -653,7 +661,7 @@ def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
|
||||
|
||||
def _install(plan, step):
|
||||
gid = plan['id']
|
||||
if not ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
||||
if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
|
||||
raise FrameError(f'bad title id {gid!r}')
|
||||
step("Syncing Valve's devkit tools to the Frame", 0.02)
|
||||
ensure_utils()
|
||||
@@ -683,8 +691,9 @@ def _install(plan, step):
|
||||
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
|
||||
registered = True # the files stay: Steam registers them once it's running
|
||||
if 'error' in reply:
|
||||
raise FrameError(f"Uploaded, but Steam didn't register it: {reply['error']}. "
|
||||
"With Steam running on the Frame, install it again.")
|
||||
err = str(reply['error']).strip().rstrip('.')
|
||||
hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else ''
|
||||
raise FrameError(f"Uploaded, but Steam didn't register it: {err}.{hint}")
|
||||
step('Done', 1.0)
|
||||
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
|
||||
return meta
|
||||
|
||||
+462
-147
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Frame Control</title>
|
||||
<style>
|
||||
/* Motiva Sans is Steam's UI font; Valve's CDN serves it with CORS open. */
|
||||
@@ -27,8 +27,8 @@
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; } /* class display rules would otherwise win */
|
||||
html { scroll-behavior: smooth; scroll-padding-top: 76px; }
|
||||
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: 46px;
|
||||
html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); }
|
||||
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom));
|
||||
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
|
||||
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
|
||||
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
|
||||
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px;
|
||||
height: 64px; padding: 0 28px; background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
|
||||
height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
|
||||
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
|
||||
.brand svg { width: 30px; height: 30px; }
|
||||
@@ -57,10 +57,41 @@
|
||||
.dot.on { background: var(--green); box-shadow: 0 0 6px rgba(89,191,64,.8); }
|
||||
.dot.off { background: var(--bad); }
|
||||
|
||||
main { max-width: 1480px; margin: 0 auto; padding: 22px 28px 30px; display: flex; flex-direction: column; gap: 26px; }
|
||||
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); 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: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
|
||||
@media (max-width: 820px) { .brand b { display: none; } }
|
||||
nav svg { display: none; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body { -webkit-tap-highlight-color: transparent; }
|
||||
.mobile-only { display: none; }
|
||||
body.mobile .mobile-only { display: revert; }
|
||||
body.mobile .desk-only { 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%);
|
||||
@@ -90,7 +121,7 @@
|
||||
.seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; }
|
||||
.seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
|
||||
.seg button.on { background: var(--btn-hi); color: var(--bright); }
|
||||
input[type=text], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
input[type=text], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
border-radius: 3px; padding: 9px 11px; font: inherit; }
|
||||
textarea { resize: vertical; min-height: 76px; }
|
||||
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
|
||||
@@ -200,19 +231,21 @@
|
||||
.links div { margin: 5px 0; }
|
||||
|
||||
/* ---- bottom bar (Steam's DOWNLOADS strip) ---- */
|
||||
.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 { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: calc(42px + env(safe-area-inset-bottom)); display: flex; align-items: center; gap: 14px;
|
||||
padding: 0 calc(28px + env(safe-area-inset-right)) env(safe-area-inset-bottom) calc(28px + env(safe-area-inset-left)); 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;
|
||||
.drawer { position: fixed; left: 0; right: 0; bottom: calc(42px + env(safe-area-inset-bottom)); z-index: 19; max-height: 0; overflow: hidden;
|
||||
background: rgba(14,20,27,.98); transition: max-height .25s ease; box-shadow: 0 -10px 30px rgba(0,0,0,.5); }
|
||||
.drawer.open { max-height: 300px; }
|
||||
.log { font: 12px/1.55 ui-monospace, "SF Mono", Menlo, monospace; color: #b8c4d2; max-height: 300px; overflow: auto;
|
||||
white-space: pre-wrap; padding: 12px 28px; }
|
||||
white-space: pre-wrap; padding: 12px calc(28px + env(safe-area-inset-right)) 12px calc(28px + env(safe-area-inset-left)); }
|
||||
.log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); }
|
||||
.log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); }
|
||||
.toast { position: fixed; bottom: 58px; right: 28px; z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
|
||||
.toast { position: fixed; bottom: calc(58px + env(safe-area-inset-bottom)); right: calc(28px + env(safe-area-inset-right)); z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
|
||||
background: #3d4450; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.5); border-left: 3px solid var(--blue);
|
||||
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
|
||||
.toast.show { opacity: 1; transform: none; }
|
||||
@@ -221,10 +254,10 @@
|
||||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||||
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||
.rep-item .s { white-space: normal; }
|
||||
#repDlg, #titleDlg, #wiDlg, #apkAltDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
#repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #apkAltDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#repDlg h2, #titleDlg h2, #wiDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||||
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
@@ -270,8 +303,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,17 +313,59 @@
|
||||
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; }
|
||||
.disp .seg button { padding: 0 10px; }
|
||||
.disp input[type=number] { width: 72px; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; }
|
||||
/* Touch screens can't hover: keep the library's name and Play button showing. */
|
||||
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
|
||||
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
|
||||
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
|
||||
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
|
||||
html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
|
||||
/* No blur here: it would make the header the containing block of the fixed tab bar. */
|
||||
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
|
||||
backdrop-filter: none; background: rgb(23, 29, 37); }
|
||||
.brand svg { width: 26px; height: 26px; }
|
||||
header .chip { height: 30px; padding: 0 10px; }
|
||||
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
|
||||
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
|
||||
nav a { flex: 1; flex-direction: column; justify-content: center; gap: 3px; padding: 0; font-size: 10.5px;
|
||||
letter-spacing: .5px; border-bottom: 0; color: var(--muted); }
|
||||
nav a.on { border-bottom: 0; }
|
||||
nav svg { display: block; width: 23px; height: 23px; }
|
||||
.bottombar, .drawer { display: none; }
|
||||
body { padding-bottom: calc(70px + env(safe-area-inset-bottom)); }
|
||||
.toast { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); max-width: none; bottom: calc(70px + env(safe-area-inset-bottom)); }
|
||||
main, .page { gap: 16px; }
|
||||
main { padding: 12px calc(12px + env(safe-area-inset-right)) 20px calc(12px + env(safe-area-inset-left)); }
|
||||
.panel { padding: 14px; }
|
||||
.grid-top, .grid-3, .and-grid, .and-col { gap: 14px; }
|
||||
.toolbar { gap: 8px; }
|
||||
.toolbar .spacer { display: none; }
|
||||
.shelf-head { flex-wrap: wrap; row-gap: 8px; }
|
||||
.shelf { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; }
|
||||
.cat-grid { grid-template-columns: 1fr; }
|
||||
.shot-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.and-btns { padding-left: 0; }
|
||||
.banner { flex-wrap: wrap; }
|
||||
.banner .s { white-space: normal; }
|
||||
button { height: 38px; }
|
||||
button.small { height: 32px; }
|
||||
.actions button { height: 46px; }
|
||||
.cat-tools select { max-width: none; flex: 1 1 100%; }
|
||||
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
|
||||
body.typing nav { display: none; }
|
||||
}
|
||||
</style>
|
||||
</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 +375,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)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="4"/><circle cx="8.5" cy="12" r="1.6"/><circle cx="15.5" cy="12" r="1.6"/></svg>Home<kbd>1</kbd></a>
|
||||
<a href="#games" title="Steam games and sideloaded titles (2)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8h12a4 4 0 0 1 4 4v1a4 4 0 0 1-7 2.6h-6A4 4 0 0 1 2 13v-1a4 4 0 0 1 4-4z"/><path d="M7 11v3M5.5 12.5h3"/><circle cx="16" cy="11.5" r=".6"/><circle cx="18" cy="13.5" r=".6"/></svg>Games<kbd>2</kbd></a>
|
||||
<a href="#android" title="Android apps and their display (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M8 9a4 4 0 0 1 8 0M8 5l1.5 2M16 5l-1.5 2M9.5 13h.01M14.5 13h.01"/></svg>Android<kbd>3</kbd></a>
|
||||
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>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 +389,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 +480,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 +517,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 +532,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 +564,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><span class="desk-only">Drop files here, or click to choose</span><span class="mobile-only">Tap to choose files</span></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.
|
||||
<span class="desk-only">You can also drop files anywhere in this window.</span>
|
||||
<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>
|
||||
@@ -536,19 +633,26 @@
|
||||
<button data-open="reboot" data-confirm="Restart the Frame?"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>Restart</button>
|
||||
<button data-open="poweroff" data-confirm="Shut the Frame down?" class="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v9"/><path d="M6.3 7.3a8 8 0 1 0 11.4 0"/></svg>Shut down</button>
|
||||
</div>
|
||||
<div class="hint">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
||||
<div class="links">
|
||||
<div class="hint desk-only">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
||||
<div class="hint mobile-only">Sleep, Restart and Shut down ask for the Developer Mode password. SSH, SFTP, Steam Link and remote desktop open in the app that handles them.</div>
|
||||
<div class="row mobile-only" style="margin-top:14px"><button class="small" id="changeHeadset">Change headset…</button></div>
|
||||
<div class="links desk-only">
|
||||
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
|
||||
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
|
||||
<div><a href="https://framedropvr.com" target="_blank">FrameDrop</a>: sideloader (Windows only for now)</div>
|
||||
</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>
|
||||
@@ -617,10 +721,23 @@
|
||||
<button type="button" class="small" id="wiCancel">Cancel</button>
|
||||
<button type="button" class="action small" id="wiGo" disabled>Install</button></div>
|
||||
</dialog>
|
||||
<dialog id="pwDlg" aria-labelledby="pwTitle">
|
||||
<form method="dialog" id="pwForm">
|
||||
<h2 id="pwTitle">Restart the Frame?</h2>
|
||||
<div class="sub">SteamOS asks for the Developer Mode password for this. It's used once and not saved.</div>
|
||||
<input type="password" id="pwInput" autocomplete="current-password" placeholder="Developer Mode password" style="margin-top:12px">
|
||||
<div class="row rep-actions"><span class="sub" id="pwMsg"></span><span class="spacer"></span>
|
||||
<button type="button" class="small" id="pwCancel">Cancel</button>
|
||||
<button type="submit" class="action small" id="pwGo">Restart</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
// Every /api/ request carries this header. The desktop server takes "1"; the
|
||||
// iPhone app's server, on the Frame, takes a fresh key it puts in the page URL.
|
||||
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
|
||||
const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_stream.Moonlight"],
|
||||
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
|
||||
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
||||
@@ -667,19 +784,52 @@ $("bottombar").onclick = () => {
|
||||
const HOST = { computer: "computer" };
|
||||
function applyHostWording(h) {
|
||||
Object.assign(HOST, h);
|
||||
$("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
||||
$("shotsSaveNew").textContent = `Save new to ${h.computer}`;
|
||||
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
|
||||
document.body.classList.toggle("mobile", !!h.mobile);
|
||||
$("shotsFolder").hidden = !!h.mobile;
|
||||
if (!h.mobile) $("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
||||
$("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`;
|
||||
$("clipMac").textContent = `Send ${h.computer} clipboard`;
|
||||
if (h.mobile) $("offSetup").textContent = "Change headset…";
|
||||
}
|
||||
// Images to the phone (the app offers Save Image to Photos, Files and so on).
|
||||
function blobBase64(blob) {
|
||||
return new Promise((ok, bad) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => ok(String(r.result).split(",")[1]);
|
||||
r.onerror = () => bad(new Error("couldn't read the image"));
|
||||
r.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
async function saveToDevice(items) {
|
||||
const images = await Promise.all(items.map(async i => ({ name: i.name, data: await blobBase64(i.blob) })));
|
||||
return window.frameApp.saveImages(images);
|
||||
}
|
||||
const savesToDevice = () => !!(window.frameApp && window.frameApp.saveImages);
|
||||
|
||||
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);
|
||||
const opts = body === undefined ? { headers: {"X-Frame-UI": UI_KEY} } : {
|
||||
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": UI_KEY}, body: JSON.stringify(body) };
|
||||
let r;
|
||||
try { r = await fetch(path, opts); }
|
||||
catch {
|
||||
throw new Error(window.frameApp?.platform === "ios" ? "Lost the connection to the Frame. The app reconnects on its own."
|
||||
: "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 + "…");
|
||||
@@ -697,17 +847,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 ----
|
||||
@@ -716,7 +903,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));
|
||||
@@ -748,6 +937,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) {
|
||||
@@ -805,7 +995,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 ----
|
||||
@@ -865,7 +1056,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);
|
||||
@@ -908,7 +1099,7 @@ async function capture() {
|
||||
let url = null;
|
||||
try {
|
||||
const r = await fetch(view === "headset" ? "/api/screenshot?view=headset" : "/api/screenshot",
|
||||
{ headers: {"X-Frame-UI": "1"} });
|
||||
{ headers: {"X-Frame-UI": UI_KEY} });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||
const source = r.headers.get("X-Capture-Source") || "gamescope";
|
||||
url = URL.createObjectURL(await r.blob());
|
||||
@@ -1007,7 +1198,7 @@ async function startVideo() {
|
||||
},
|
||||
error: e => log("Video decoder: " + e.message, "e"),
|
||||
});
|
||||
const r = await fetch(`/api/stream?${STREAM_QUERY}`, { headers: {"X-Frame-UI": "1"}, signal: ctl.signal });
|
||||
const r = await fetch(`/api/stream?${STREAM_QUERY}`, { headers: {"X-Frame-UI": UI_KEY}, signal: ctl.signal });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||
const reader = r.body.getReader();
|
||||
let buf = new Uint8Array(0), scan = 0, auStart = -1;
|
||||
@@ -1062,6 +1253,7 @@ $("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) :
|
||||
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
|
||||
}, "image/png");
|
||||
function download(blob, name) {
|
||||
if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }]));
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = name;
|
||||
@@ -1083,17 +1275,75 @@ 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) {
|
||||
const what = b.dataset.open, label = b.textContent.trim();
|
||||
// On the phone, power needs the password here, and the rest opens other apps.
|
||||
if (HOST.mobile && POWER[what]) return askPower(what);
|
||||
if (b.dataset.confirm && !confirm(b.dataset.confirm)) return;
|
||||
act(b.textContent.trim(), () => api("/api/open", { what: b.dataset.open }), b);
|
||||
if (HOST.mobile && window.frameApp?.open) return act(label, () => window.frameApp.open(what), b);
|
||||
act(label, () => api("/api/open", { what }), 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(); }
|
||||
}
|
||||
const POWER = { suspend: ["Put the Frame to sleep?", "Sleep"], reboot: ["Restart the Frame?", "Restart"],
|
||||
poweroff: ["Shut the Frame down?", "Shut down"] };
|
||||
function askPower(what) {
|
||||
const [title, verb] = POWER[what], dlg = $("pwDlg");
|
||||
$("pwTitle").textContent = title; $("pwGo").textContent = verb;
|
||||
$("pwInput").value = ""; $("pwMsg").textContent = "";
|
||||
$("pwCancel").onclick = () => dlg.close();
|
||||
$("pwForm").onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const password = $("pwInput").value;
|
||||
if (!password) return ($("pwMsg").textContent = "Enter the password");
|
||||
$("pwGo").disabled = true; $("pwMsg").textContent = "";
|
||||
try {
|
||||
const res = await api("/api/open", { what, password });
|
||||
dlg.close(); log(res.message, "ok"); toast(res.message);
|
||||
} catch (err) { $("pwMsg").textContent = err.message; }
|
||||
finally { $("pwGo").disabled = false; $("pwInput").value = ""; }
|
||||
};
|
||||
dlg.showModal();
|
||||
$("pwInput").focus();
|
||||
}
|
||||
$("changeHeadset").onclick = () => window.frameApp?.setUpConnection && window.frameApp.setUpConnection();
|
||||
|
||||
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) {
|
||||
@@ -1133,10 +1383,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);
|
||||
@@ -1145,7 +1411,7 @@ function upload(file, mode) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/upload");
|
||||
xhr.setRequestHeader("X-Frame-UI", "1");
|
||||
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
|
||||
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
|
||||
xhr.setRequestHeader("X-Mode", mode);
|
||||
const bar = $("prog").firstElementChild;
|
||||
@@ -1191,10 +1457,12 @@ function showApkAlternatives(reason, result) {
|
||||
$("apkAltVersions").onclick = async e => {
|
||||
const b = e.target.closest("[data-version]"); if (!b) return;
|
||||
const v = result.versions[+b.dataset.version];
|
||||
const res = await act(`Install ${result.package} ${v.version}`, () => api("/api/android", {
|
||||
if (installing.has(result.package)) return;
|
||||
b.disabled = true; b.textContent = "Installing…";
|
||||
const res = await runJob(`Install ${result.package} ${v.version}`, result.package, () => api("/api/android", {
|
||||
action: "install", package: result.package, url: v.url
|
||||
}), b);
|
||||
if (res) { $("apkAltDlg").close(); loadAndroid(); }
|
||||
}));
|
||||
if (res) { $("apkAltDlg").close(); loadAndroid(); } else { b.disabled = false; b.textContent = "Install"; }
|
||||
};
|
||||
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
|
||||
}
|
||||
@@ -1221,9 +1489,11 @@ async function sendFiles(files, dirs = new Set()) {
|
||||
// ---- sideloaded titles (a Linux or Windows build as a Steam Devkit Game) ----
|
||||
const RESERVED_IDS = ["steam", "steamdeckard", "steamvr", "steamvrdeckard", "devkit-steam"];
|
||||
function titleId(name) { // mirrors frame_titles.title_id: the name Steam shows
|
||||
const trim = x => x.replace(/^[_-]+|[_-]+$/g, "");
|
||||
let s = trim(trim(String(name).trim().replace(/[^A-Za-z0-9_-]+/g, "_").replace(/_+/g, "_")).slice(0, 64));
|
||||
if (RESERVED_IDS.includes(s.toLowerCase())) s += "-game";
|
||||
const trim = x => x.replace(/^_+|_+$/g, "");
|
||||
let s = trim(trim(String(name).trim().replace(/[^A-Za-z0-9]+/g, "_")).slice(0, 64));
|
||||
if (!s) return s;
|
||||
if (/^[0-9]/.test(s)) s = "_" + s.slice(0, 63);
|
||||
if (s.length < 2 || RESERVED_IDS.includes(s.toLowerCase())) s += "_game";
|
||||
return s;
|
||||
}
|
||||
const ARCH = { arm64: "ARM64", x86_64: "x86-64", x86: "32-bit x86" };
|
||||
@@ -1334,7 +1604,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">
|
||||
@@ -1342,7 +1612,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;
|
||||
@@ -1354,6 +1624,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 ----
|
||||
@@ -1371,8 +1642,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);
|
||||
@@ -1508,7 +1779,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);
|
||||
@@ -1571,6 +1842,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>
|
||||
@@ -1593,8 +1865,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);
|
||||
@@ -1612,7 +1887,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)));
|
||||
@@ -1634,16 +1909,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();
|
||||
}
|
||||
@@ -1711,6 +1997,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")); }
|
||||
@@ -1719,7 +2008,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>
|
||||
@@ -1791,7 +2082,7 @@ function shotApp(appid) {
|
||||
}
|
||||
async function shotBlob(id, thumb) {
|
||||
const r = await fetch(`/api/shots/image?id=${encodeURIComponent(id)}${thumb ? "&thumb=1" : ""}`,
|
||||
{ headers: {"X-Frame-UI": "1"} });
|
||||
{ headers: {"X-Frame-UI": UI_KEY} });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||
return r.blob();
|
||||
}
|
||||
@@ -1800,19 +2091,18 @@ 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;
|
||||
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
|
||||
$("shotsSaveNew").disabled = !unsaved;
|
||||
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved && !HOST.mobile ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
|
||||
$("shotsSaveNew").disabled = HOST.mobile ? !shots.list.length : !unsaved;
|
||||
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
|
||||
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
|
||||
<div class="row"><div class="grow">
|
||||
<div class="t">${esc(shotApp(s.appid))}</div>
|
||||
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
|
||||
${s.saved ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
||||
${s.saved && !HOST.mobile ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
||||
</div></div>`).join("")
|
||||
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
||||
// Thumbnails one at a time over the shared SSH connection.
|
||||
@@ -1852,6 +2142,10 @@ async function openShot(s) {
|
||||
}
|
||||
async function saveShots(list, btn) {
|
||||
if (!list.length) return;
|
||||
if (HOST.mobile && savesToDevice()) {
|
||||
return act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
|
||||
async () => saveToDevice(await Promise.all(list.map(async s => ({ blob: await shotBlob(s.id, false), name: s.file })))), btn);
|
||||
}
|
||||
const res = await act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
|
||||
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
|
||||
if (res) loadShots();
|
||||
@@ -1866,12 +2160,33 @@ $("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);
|
||||
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
|
||||
const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=checkbox], [type=radio], [type=file], [type=button])');
|
||||
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
|
||||
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
|
||||
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;
|
||||
@@ -1979,7 +2294,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>
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Stand-in for ssh when Frame Control's server runs on the Frame itself
|
||||
# (FRAME_LOCAL=1, started by the iPhone app). The server and its helpers call
|
||||
# `ssh [options] frame COMMAND`, and rsync calls it as its transport; here that
|
||||
# means: run COMMAND in the home directory, as ssh would, with the same stdin.
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-[bcDEeFIiJLlmOoPpQRSWwB]) shift 2 ;; # options that take a value
|
||||
-?*) shift ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
[ $# -gt 0 ] && shift # the host alias
|
||||
cd "$HOME" || exit 255
|
||||
[ $# -eq 0 ] && exit 0 # -N: nothing to run
|
||||
# Stopping ssh ends everything the command started (sshd hangs up the session).
|
||||
# To do the same, run COMMAND as its own process group and pass on a TERM, HUP
|
||||
# or INT to all of it, so e.g. a live-video ffmpeg can't outlive its stream.
|
||||
set -m # job control (bash allows it without a terminal): own process group, and stdin kept
|
||||
"${SHELL:-/bin/sh}" -c "$*" &
|
||||
child=$!
|
||||
trap 'kill -TERM -- "-$child" 2>/dev/null' TERM HUP INT
|
||||
wait "$child"
|
||||
status=$?
|
||||
while kill -0 "$child" 2>/dev/null; do # a trapped signal ends `wait` early
|
||||
wait "$child"
|
||||
status=$?
|
||||
done
|
||||
exit "$status"
|
||||
+178
-20
@@ -7,6 +7,9 @@ up by scripts/connect.sh or ui/frame_connect.py.
|
||||
|
||||
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
|
||||
Env: FRAME_ALIAS (default frame)
|
||||
FRAME_LOCAL=1 run on the Frame itself (the iPhone app starts it there over SSH)
|
||||
FRAME_UI_KEY required X-Frame-UI value (the iPhone app passes a fresh one)
|
||||
FRAME_DEVICE what to call the device the page runs on (e.g. iPhone)
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
@@ -44,12 +47,20 @@ import frame_webinstall # noqa: E402
|
||||
frame_host.trust_bundled_cas()
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
# On the Frame itself, every `ssh frame COMMAND` the server and its helpers run
|
||||
# goes to local-bin/ssh, which runs COMMAND here instead, so one code path serves
|
||||
# both. Nothing listens beyond 127.0.0.1; the phone reaches it through SSH.
|
||||
LOCAL = os.environ.get("FRAME_LOCAL") == "1"
|
||||
if LOCAL:
|
||||
os.environ["PATH"] = f"{HERE / 'local-bin'}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||
UI_KEY = os.environ.get("FRAME_UI_KEY") or "1"
|
||||
DEVICE = os.environ.get("FRAME_DEVICE") or "phone"
|
||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
|
||||
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
|
||||
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||
# supports it (not on Windows: there every command connects on its own).
|
||||
CONTROL = frame_host.control_path()
|
||||
CONTROL = None if LOCAL else frame_host.control_path()
|
||||
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
|
||||
# Commands use the master when it's up and connect directly when it isn't.
|
||||
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
|
||||
@@ -87,6 +98,89 @@ class Failure(Exception):
|
||||
self.apk = apk
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
@@ -410,19 +504,43 @@ 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}"}
|
||||
raise Failure("action must be install or uninstall", 400)
|
||||
|
||||
|
||||
def power(what, password):
|
||||
"""Sleep, restart or shut down from the Frame itself: sudo takes the Developer Mode password on stdin."""
|
||||
if not isinstance(password, str) or not password or "\n" in password:
|
||||
raise Failure("enter the Developer Mode password", 400)
|
||||
try:
|
||||
r = subprocess.run(["sudo", "-S", "-k", "-p", "", "systemctl", what], input=password + "\n",
|
||||
capture_output=True, text=True, timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"systemctl {what} didn't answer")
|
||||
if r.returncode != 0:
|
||||
err = r.stderr.strip()
|
||||
raise Failure("that password wasn't accepted" if "incorrect password" in err or "Sorry" in err
|
||||
else err or f"systemctl {what} failed", 400)
|
||||
return {"message": {"suspend": "Going to sleep", "reboot": "Restarting", "poweroff": "Shutting down"}[what]}
|
||||
|
||||
|
||||
def open_thing(body):
|
||||
what = body.get("what")
|
||||
if LOCAL:
|
||||
# Terminals, Steam Link and remote desktop open on the phone (its app does
|
||||
# that); what's left here is power, with the password the page asked for.
|
||||
if what in ("reboot", "poweroff", "suspend"):
|
||||
return power(what, body.get("password"))
|
||||
raise Failure("open that from the app", 400)
|
||||
try:
|
||||
if what == "terminal":
|
||||
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
|
||||
@@ -461,9 +579,15 @@ def android(body):
|
||||
ensure_master()
|
||||
try:
|
||||
if action == "install":
|
||||
m = (frame_apk_versions.install(pkg, body["url"]) if body.get("url")
|
||||
else frame_catalog.install(pkg))
|
||||
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.", "app": m}
|
||||
url = body.get("url")
|
||||
if not url:
|
||||
frame_catalog.app(pkg) # an unknown package fails now, not in the background
|
||||
|
||||
def work():
|
||||
m = frame_apk_versions.install(pkg, url) if url else 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']}"}
|
||||
@@ -752,6 +876,30 @@ class AdbTunnel:
|
||||
return False
|
||||
|
||||
|
||||
class PodmanShell:
|
||||
"""AdbTunnel's stand-in on the Frame itself: there's no adb there, but each
|
||||
instance is a podman container, so run Android's shell inside it."""
|
||||
|
||||
def __init__(self, ports, containers):
|
||||
self.containers = containers
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def shell(self, port, command, timeout=20):
|
||||
ctr = self.containers.get(port)
|
||||
if not ctr:
|
||||
raise Failure(f"port {port} isn't a Lepton container this app can reach")
|
||||
return ssh(f"podman exec {shlex.quote(ctr)} /system/bin/sh -c {shlex.quote(command)}", timeout=timeout)
|
||||
|
||||
|
||||
def android_shell(ports, containers):
|
||||
return PodmanShell(ports, containers) if LOCAL else AdbTunnel(ports)
|
||||
|
||||
|
||||
DISPLAY_READ = "echo @@pkgs; pm list packages -3; echo @@size; wm size; echo @@density; wm density; " \
|
||||
"echo @@font; settings get system font_scale"
|
||||
|
||||
@@ -824,7 +972,7 @@ def android_displays():
|
||||
if not ports:
|
||||
return {"instances": []}
|
||||
instances = []
|
||||
with AdbTunnel(ports) as t:
|
||||
with android_shell(ports, containers) as t:
|
||||
for p in ports:
|
||||
item = {"port": p, "container": containers.get(p)}
|
||||
try:
|
||||
@@ -878,10 +1026,10 @@ def android_display(body):
|
||||
|
||||
if not cmds:
|
||||
raise Failure("nothing to change: give density, size or fontScale", 400)
|
||||
ports, _, _ = lepton_ports()
|
||||
ports, containers, _ = lepton_ports()
|
||||
if port not in ports:
|
||||
raise Failure(f"no Lepton instance is listening on Frame port {port}", 404)
|
||||
with AdbTunnel([port]) as t:
|
||||
with android_shell([port], containers) as t:
|
||||
for c in cmds:
|
||||
out = t.shell(port, c)
|
||||
# wm prints usage or an exception on failure but may still exit 0.
|
||||
@@ -1148,8 +1296,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# All of /api/*, not just POST: an <img> on any website could otherwise
|
||||
# trigger a headset capture and display it.
|
||||
api = urlparse(self.path).path.startswith("/api/")
|
||||
if (self.command == "POST" or api) and self.headers.get("X-Frame-UI") != "1":
|
||||
self.send_json({"error": "missing X-Frame-UI header"}, 403)
|
||||
if (self.command == "POST" or api) and not secrets.compare_digest(self.headers.get("X-Frame-UI") or "", UI_KEY):
|
||||
self.send_json({"error": "missing or wrong X-Frame-UI header"}, 403)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -1169,6 +1317,12 @@ 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, apk=None):
|
||||
body, offline_status = error_body(message)
|
||||
if apk is not None:
|
||||
body["apk"] = apk
|
||||
self.send_json(body, offline_status or status)
|
||||
|
||||
def do_GET(self):
|
||||
if not self.local_request():
|
||||
return
|
||||
@@ -1178,7 +1332,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/api/host":
|
||||
self.send_json({"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||
elif path == "/api/apk-versions":
|
||||
self.send_json(apk_versions(url.query))
|
||||
@@ -1190,6 +1345,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":
|
||||
@@ -1219,9 +1376,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
except Failure as e:
|
||||
self.send_json({"error": str(e), **({"apk": e.apk} if e.apk is not None else {})}, e.status)
|
||||
self.send_error_json(str(e), e.status, e.apk)
|
||||
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)
|
||||
|
||||
@@ -1245,11 +1402,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), **({"apk": e.apk} if e.apk is not None else {})}, e.status)
|
||||
self.send_error_json(str(e), e.status, e.apk)
|
||||
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)
|
||||
|
||||
@@ -1386,7 +1543,8 @@ def main():
|
||||
sys.stdin.buffer.read()
|
||||
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||
threading.Thread(target=watch_stdin, daemon=True).start()
|
||||
print(f"Frame Control on http://127.0.0.1:{args.port} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
|
||||
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
Reference in new issue
Block a user