iPhone and iPad app: the same features, served from the Frame

An iPhone can't run Python or ssh, but the Frame can. The app (ios/, SwiftUI)
connects with its own SSH key (Citadel), copies the server and helpers to
~/.cache/frame-control/<version> on the Frame once per version, starts
ui/server.py there with FRAME_LOCAL=1 on the Frame's 127.0.0.1, and shows the
page through an SSH tunnel. The server exits when the phone disconnects.

Server: FRAME_LOCAL=1 puts ui/local-bin on PATH, whose ssh stand-in runs each
`ssh frame COMMAND` locally (and serves as rsync's transport), so desktop and
phone share one code path. Android display goes through podman exec there, as
the Frame has no adb. FRAME_UI_KEY replaces the fixed X-Frame-UI value with a
per-session key. Power actions take the Developer Mode password via sudo -S.
--port 0 now prints the port it took.

Page: a bottom tab bar and safe areas on phones, Play buttons visible on touch
screens, saving through the share sheet, SSH/SFTP/Steam Link/remote desktop
opening in their iOS apps, and a password dialog for power.

App: pairing with the Developer Mode password once (never stored) or with a
key the user adds; host key pinned on first use; plain-language connection
errors with quiet retries; frame-control://install links; alerts and confirms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-27 11:18:15 +10:00
1 parent 0f770dc88a
commit 13187e8f6a
33 files changed
+2459 -34

No files matched your search

+135 -26
View File
@@ -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. */
@@ -61,7 +61,16 @@
.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; } header { gap: 16px; padding: 0 18px; } nav a { padding: 0 10px; } }
@media (max-width: 820px) { .brand b, #battChip { display: none; } }
@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; }
/* 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; } }
/* ---- pages: one per tab; the header nav switches between them ---- */
.page { display: flex; flex-direction: column; gap: 26px; }
@@ -114,7 +123,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); }
@@ -247,10 +256,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 { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
#repDlg, #titleDlg, #wiDlg, #pwDlg { 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 { background: rgba(0,0,0,.55); }
#repDlg h2, #titleDlg h2, #wiDlg 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 { background: rgba(0,0,0,.55); }
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg 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;
@@ -313,6 +322,41 @@
.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; }
/* ---- phones: tabs move to a bottom bar, as in iOS apps; the page clears the notch and home indicator ---- */
@media (max-width: 640px) {
/* 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) 14px 0; 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-bottom: env(safe-area-inset-bottom);
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: 14px; right: 14px; max-width: none; bottom: calc(70px + env(safe-area-inset-bottom)); }
main, .page { gap: 16px; }
main { padding: 12px 12px 20px; }
.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%; }
}
</style>
</head>
<body>
@@ -327,10 +371,10 @@
<b>FRAME CONTROL</b>
</a>
<nav id="nav">
<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>
<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" role="status"><span class="dot"></span><span>Connecting…</span></span>
@@ -547,10 +591,10 @@
<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>
<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.
You can also drop files anywhere in this window.
<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>
@@ -585,8 +629,10 @@
<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>
@@ -661,10 +707,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";
@@ -711,14 +770,32 @@ $("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 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("Frame Control's local server isn't running. Restart the app (Frame → Restart Server)."); }
@@ -1005,7 +1082,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());
@@ -1104,7 +1181,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;
@@ -1159,6 +1236,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;
@@ -1182,8 +1260,12 @@ document.body.addEventListener("click", async (e) => {
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);
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);
}
});
// Installs run as server jobs: start one, then poll until it's done. The
@@ -1214,6 +1296,29 @@ async function runJob(label, key, start) {
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…`);
@@ -1289,7 +1394,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;
@@ -1915,7 +2020,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();
}
@@ -1928,14 +2033,14 @@ async function loadShots() {
} 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.
@@ -1975,6 +2080,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();
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# 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
exec "${SHELL:-/bin/sh}" -c "$*"
+66 -7
View File
@@ -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
@@ -43,12 +46,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"]
@@ -504,8 +515,30 @@ def flatpak(body):
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])}"}
@@ -829,6 +862,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"
@@ -901,7 +958,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:
@@ -955,10 +1012,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.
@@ -1225,7 +1282,7 @@ 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":
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 X-Frame-UI header"}, 403)
return False
return True
@@ -1259,7 +1316,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/android":
ensure_master()
@@ -1459,7 +1517,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: