mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 07:00:37 +02:00
Install links for websites: frame-control://install
A site can link to frame-control://install?manifest=URL (or ?url=URL) to install a title with Frame Control. Manifests use FrameDrop's format, so framedrop.install/v1 is accepted as well as frame-control.install/v1. - app/install-link.js parses links; main.js registers the scheme (plus electron-builder protocols for Info.plist and the .desktop file), takes links from open-url, second-instance argv and the first argv, and holds them until the page asks for them through preload's onInstallLink. - ui/frame_webinstall.py checks the URLs (HTTPS only; localhost over http only when the link itself is local; no userinfo; every address public, rechecked on redirects and pinned for the connection), reads the manifest, downloads with a size cap and sha256 check, and dispatch() sends .apk to frame_android and .zip/.exe to frame_titles when present. - server.py adds /api/webinstall/check, start, job and cancel behind the existing Host and X-Frame-UI guards; a start needs a one-time id from check. Downloads stop on cancel and on shutdown, and leftovers from a killed server are swept by PID. - index.html asks before anything downloads (name, source host, file, type, size, whether a sha256 was given) and shows progress. - docs/web-install.md, docs/install.html (landing page, unpublished). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1a0e54d8bd
commit
dd9c009206
13 files changed
+1541
-10
No files matched your search
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>Install with Frame Control</title>
|
||||
<!-- Landing page for install links (docs/web-install.md): install.html?manifest=URL
|
||||
or ?url=URL opens frame-control://install?… and offers the download if the
|
||||
app doesn't open. Static, no requests of its own. Not published yet. -->
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0d1117; color: #e6edf3;
|
||||
font: 15px/1.5 -apple-system, "Segoe UI", sans-serif; }
|
||||
main { max-width: 520px; padding: 32px; }
|
||||
h1 { font-size: 20px; margin: 0 0 8px; }
|
||||
p { color: #8b98a8; }
|
||||
code { color: #e6edf3; overflow-wrap: anywhere; }
|
||||
a.btn { display: inline-block; margin: 8px 12px 0 0; padding: 9px 16px; border-radius: 3px; text-decoration: none;
|
||||
background: #2d333b; color: #e6edf3; }
|
||||
a.btn.go { background: #1a9fff; color: #fff; font-weight: 600; }
|
||||
.err { color: #ff7b72; }
|
||||
[hidden] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Install with Frame Control</h1>
|
||||
<p id="what"></p>
|
||||
<p id="bad" class="err" hidden>This link doesn't name an https:// manifest or file, so there's nothing to install.</p>
|
||||
<div id="actions" hidden>
|
||||
<a class="btn go" id="open">Open in Frame Control</a>
|
||||
<a class="btn" href="https://github.com/saphid/steam-frame/releases/latest">Get Frame Control</a>
|
||||
</div>
|
||||
<p id="missing" hidden>Nothing happened? Frame Control isn't installed on this computer, or is older than the
|
||||
version that handles install links. Get it, open it once, then use the link again.</p>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const q = new URLSearchParams(location.search);
|
||||
const kind = q.has("manifest") ? "manifest" : q.has("url") ? "url" : null;
|
||||
const target = kind && q.get(kind);
|
||||
let ok = false;
|
||||
try {
|
||||
const u = new URL(target);
|
||||
const local = ["localhost", "127.0.0.1"].includes(u.hostname);
|
||||
ok = !u.username && !u.password && (u.protocol === "https:" || (u.protocol === "http:" && local));
|
||||
} catch {}
|
||||
if (!ok) { document.getElementById("bad").hidden = false; return; }
|
||||
const link = `frame-control://install?${kind}=${encodeURIComponent(target)}`;
|
||||
document.getElementById("what").textContent = `From ${new URL(target).hostname}. Frame Control shows what it will `
|
||||
+ "install and asks you before downloading anything.";
|
||||
document.getElementById("open").href = link;
|
||||
document.getElementById("actions").hidden = false;
|
||||
// If the app opens, this page loses focus or is hidden; if not, say how to get it.
|
||||
let left = false;
|
||||
window.addEventListener("blur", () => { left = true; });
|
||||
document.addEventListener("visibilitychange", () => { if (document.hidden) left = true; });
|
||||
setTimeout(() => { if (!left) document.getElementById("missing").hidden = false; }, 2000);
|
||||
location.href = link;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,146 @@
|
||||
# Install links for websites
|
||||
|
||||
A website can put an "Install with Frame Control" button next to its download.
|
||||
Clicking it opens Frame Control, which shows what the link wants to install and
|
||||
asks the user. Only after they click **Install** does it download the file and
|
||||
install it on the Frame.
|
||||
|
||||
What's verified: the link parsing, URL rules, manifest parsing, download,
|
||||
size cap and sha256 check, by `tests/test_webinstall.py` and
|
||||
`tests/test_server.py` (no network: a stub server on 127.0.0.1). Installing on
|
||||
the headset is the same code as dropping a file on Frame Control: `.apk` files go
|
||||
to the APK installer ([apks.md](apks.md)), `.zip` and `.exe` files to the
|
||||
Linux/Windows title installer. A link hasn't been clicked through to a headset
|
||||
install yet.
|
||||
|
||||
## The link
|
||||
|
||||
```
|
||||
frame-control://install?manifest=<URL-encoded manifest URL>
|
||||
frame-control://install?url=<URL-encoded file URL>
|
||||
```
|
||||
|
||||
Use `manifest` when you can: it carries the title's name and a sha256, which
|
||||
Frame Control checks before installing. `url` is for a file on its own; the
|
||||
dialog then names the title after the file.
|
||||
|
||||
The manifest is FrameDrop's format, so one manifest serves both apps. The
|
||||
schema may be `framedrop.install/v1` or `frame-control.install/v1`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "framedrop.install/v1",
|
||||
"name": "My Game",
|
||||
"files": [
|
||||
{ "url": "https://cdn.example.com/mygame-arm64.apk", "sha256": "optional-but-better" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | |
|
||||
|---|---|
|
||||
| `schema` | Required, one of the two above |
|
||||
| `name` | Shown in the confirm dialog (at most 120 characters). Defaults to the file name. APKs are still named in the Steam library by their own label |
|
||||
| `files` | Exactly one entry for now; more is refused with a message |
|
||||
| `files[0].url` | Required. The file to install |
|
||||
| `files[0].sha256` | Optional, 64 hex digits. The download must match or nothing is installed |
|
||||
| `files[0].size` | Optional (Frame Control extension), bytes. Shown up front; the download must match |
|
||||
| `files[0].exe` | Optional (Frame Control extension), for a `.zip` title: the program inside it to run |
|
||||
|
||||
What gets installed depends on the file name's extension:
|
||||
|
||||
| File | Installed as |
|
||||
|---|---|
|
||||
| `.apk` | An Android app in its own Lepton instance with a Steam shortcut ([apks.md](apks.md)) |
|
||||
| `.zip`, `.exe` | A Linux or Windows title. Versions of Frame Control without the title installer say "Linux/Windows titles need a newer Frame Control" |
|
||||
| anything else | Refused |
|
||||
|
||||
## Rules
|
||||
|
||||
Frame Control refuses a link, and downloads nothing, unless:
|
||||
|
||||
- Every URL (the manifest's, the file's and each redirect) is `https://`.
|
||||
`http://` works only for `localhost` or `127.0.0.1`, for testing, and only
|
||||
when the link itself points there: a public manifest can't send Frame
|
||||
Control to your own computer.
|
||||
- No URL has a user name or password in it (`https://user:pw@…`).
|
||||
- No host is, or resolves to, a private, loopback, link-local, CGNAT
|
||||
(100.64.0.0/10), multicast or otherwise non-public address. Every address
|
||||
the name has must be public, it's checked again on every redirect (at most
|
||||
5), and the download connects to the address that was checked.
|
||||
- The file URL ends in a file name with one of the extensions above
|
||||
(`https://example.com/games/` is refused).
|
||||
- The manifest is JSON of at most 256 KB, and the file at most 4 GiB
|
||||
(`MAX_MANIFEST` and `MAX_FILE` in `ui/frame_webinstall.py`).
|
||||
- The user confirms. The dialog shows the title's name, the site the link came
|
||||
from (and the file's host if different), the file name and type, the size if
|
||||
known, and whether a sha256 was given.
|
||||
|
||||
A web page can't install anything itself: it can only open the link. Frame
|
||||
Control's local server refuses requests from web pages, so the only way in is
|
||||
the operating system handing the link to the app, then the user's click.
|
||||
|
||||
## Button for your site
|
||||
|
||||
Paste this where the download is, with your manifest's URL in `MANIFEST`:
|
||||
|
||||
```html
|
||||
<a id="frame-control-install" href="#"
|
||||
style="display:inline-block;padding:10px 18px;border-radius:4px;background:#1a9fff;color:#fff;
|
||||
font:600 15px -apple-system,'Segoe UI',sans-serif;text-decoration:none">Install with Frame Control</a>
|
||||
<script>
|
||||
(() => {
|
||||
const MANIFEST = "https://example.com/mygame/frame-control.json";
|
||||
const GET_APP = "https://github.com/saphid/steam-frame/releases/latest";
|
||||
const button = document.getElementById("frame-control-install");
|
||||
button.href = "frame-control://install?manifest=" + encodeURIComponent(MANIFEST);
|
||||
button.addEventListener("click", () => {
|
||||
// If Frame Control opens, this page loses focus; if it doesn't, offer the download.
|
||||
let left = false;
|
||||
const away = () => { left = true; };
|
||||
window.addEventListener("blur", away, { once: true });
|
||||
setTimeout(() => {
|
||||
window.removeEventListener("blur", away);
|
||||
if (!left && confirm("Frame Control didn't open. Download it?")) location.href = GET_APP;
|
||||
}, 2000);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
For a single file, use `"frame-control://install?url=" + encodeURIComponent(FILE_URL)`.
|
||||
|
||||
`docs/install.html` is a landing page that does the same from a plain link:
|
||||
`install.html?manifest=<URL-encoded URL>` tries the app and shows a "Get Frame
|
||||
Control" link. It isn't published anywhere yet; host a copy to use it.
|
||||
|
||||
## Testing locally
|
||||
|
||||
Serve the manifest and file from your own computer:
|
||||
|
||||
```sh
|
||||
cd mygame && python3 -m http.server 8000
|
||||
open 'frame-control://install?manifest=http%3A%2F%2Flocalhost%3A8000%2Fmanifest.json' # xdg-open on Linux, start "" on Windows
|
||||
```
|
||||
|
||||
The manifest's file URL must then be `http://localhost:8000/…` or
|
||||
`http://127.0.0.1:8000/…` too.
|
||||
|
||||
## How it works
|
||||
|
||||
- `app/install-link.js` parses the link (only `frame-control://install` with
|
||||
exactly one `manifest` or `url`); `app/main.js` registers the scheme
|
||||
(`app.setAsDefaultProtocolClient`, and electron-builder's `protocols` for the
|
||||
macOS Info.plist and the Linux `.desktop` file). macOS delivers links through
|
||||
`open-url`, Windows and Linux as an argument to a second instance. Links
|
||||
wait in the main process until the page has loaded and asked for them
|
||||
(`frameApp.onInstallLink` in `app/preload.js`). `framedrop://` is left alone.
|
||||
- The page posts the link to `/api/webinstall/check`, which reads the manifest,
|
||||
applies the rules, asks the file's size with a HEAD request and returns a
|
||||
one-time id. Nothing is downloaded.
|
||||
- **Install** posts the id to `/api/webinstall/start`. The server downloads to
|
||||
a temporary folder (progress at `/api/webinstall/job`, cancellable with
|
||||
`/api/webinstall/cancel`), checks size and sha256, hands the file to
|
||||
`frame_webinstall.dispatch()` and deletes the folder.
|
||||
- The app registers the scheme each time it starts, so the last Frame Control
|
||||
started (e.g. a development checkout) handles the links.
|
||||
Reference in new issue
Block a user