mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +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,33 @@
|
||||
// Parses frame-control://install?manifest=URL and frame-control://install?url=URL
|
||||
// (see docs/web-install.md). Pure, so it runs under plain node for the tests.
|
||||
// This is only a first filter: ui/frame_webinstall.py applies the full URL rules
|
||||
// (HTTPS, no private addresses, redirects) before anything is fetched.
|
||||
const SCHEME = "frame-control";
|
||||
const MAX_LINK = 4096;
|
||||
const MAX_URL = 2048;
|
||||
|
||||
// {kind: "manifest" | "url", target} or null if raw isn't a usable install link.
|
||||
function parseInstallLink(raw) {
|
||||
if (typeof raw !== "string" || raw.length > MAX_LINK || !raw.toLowerCase().startsWith(`${SCHEME}:`)) return null;
|
||||
let link;
|
||||
try { link = new URL(raw); } catch { return null; }
|
||||
// frame-control://install?… puts "install" in the host; accept a trailing slash too.
|
||||
if (link.protocol !== `${SCHEME}:` || link.hostname !== "install" || !["", "/"].includes(link.pathname)) return null;
|
||||
const keys = [...new Set(link.searchParams.keys())];
|
||||
if (keys.length !== 1 || !["manifest", "url"].includes(keys[0])) return null;
|
||||
const values = link.searchParams.getAll(keys[0]);
|
||||
if (values.length !== 1) return null;
|
||||
const target = values[0];
|
||||
if (!target || target.length > MAX_URL) return null;
|
||||
let parsed;
|
||||
try { parsed = new URL(target); } catch { return null; }
|
||||
if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password) return null;
|
||||
return { kind: keys[0], target };
|
||||
}
|
||||
|
||||
// The link among command-line arguments (Windows and Linux pass it there).
|
||||
function linkFromArgv(argv) {
|
||||
return (argv || []).find((a) => typeof a === "string" && a.toLowerCase().startsWith(`${SCHEME}:`)) || null;
|
||||
}
|
||||
|
||||
module.exports = { SCHEME, parseInstallLink, linkFromArgv };
|
||||
+60
-5
@@ -9,6 +9,7 @@ const http = require("http");
|
||||
const net = require("net");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
@@ -233,11 +234,55 @@ async function firstRunCheck() {
|
||||
if (response === 0) setUpConnection();
|
||||
}
|
||||
|
||||
ipcMain.handle("clipboard:read", (e) => {
|
||||
if (!win || e.sender !== win.webContents || !url || new URL(e.senderFrame.url).origin !== new URL(url).origin) return "";
|
||||
return clipboard.readText();
|
||||
// IPC only from our own page in our own window.
|
||||
function fromUi(e) {
|
||||
return !!(win && e.sender === win.webContents && url && e.senderFrame
|
||||
&& new URL(e.senderFrame.url).origin === new URL(url).origin);
|
||||
}
|
||||
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
|
||||
// frame-control://install links from websites (docs/web-install.md). They can
|
||||
// arrive before the window or server exists (macOS open-url on a cold launch),
|
||||
// so they wait here until the page asks for them. The page checks the link with
|
||||
// the server and installs nothing until the user confirms in its dialog.
|
||||
const pendingLinks = [];
|
||||
let linkPage = null; // the webContents whose current page is listening
|
||||
|
||||
function openInstallLink(raw) {
|
||||
const req = parseInstallLink(raw);
|
||||
if (!req) {
|
||||
app.whenReady().then(() => dialog.showErrorBox("Frame Control can't use this link",
|
||||
"Install links look like frame-control://install?manifest=https://… or frame-control://install?url=https://…"));
|
||||
return;
|
||||
}
|
||||
pendingLinks.push(req);
|
||||
if (pendingLinks.length > 5) pendingLinks.shift(); // a page opening links in a loop
|
||||
deliverLinks();
|
||||
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
|
||||
}
|
||||
|
||||
function deliverLinks() {
|
||||
if (!win || !linkPage || linkPage !== win.webContents) return;
|
||||
while (pendingLinks.length) win.webContents.send("install-link", pendingLinks.shift());
|
||||
}
|
||||
|
||||
ipcMain.on("install-link:ready", (e) => {
|
||||
if (!fromUi(e)) return;
|
||||
linkPage = e.sender;
|
||||
deliverLinks();
|
||||
});
|
||||
|
||||
function registerScheme() {
|
||||
// A checkout runs as `electron .`, so the OS must be told the script too.
|
||||
// (macOS takes the scheme from Info.plist, which only the built app has.)
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) app.setAsDefaultProtocolClient(SCHEME, process.execPath, [path.resolve(process.argv[1])]);
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(SCHEME);
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||
@@ -257,7 +302,9 @@ function createWindow() {
|
||||
win.webContents.on("will-navigate", (e, target) => {
|
||||
if (!url || new URL(target).origin !== new URL(url).origin) e.preventDefault();
|
||||
});
|
||||
win.on("closed", () => { win = null; });
|
||||
// A reload or a new page must ask for links again before it gets any.
|
||||
win.webContents.on("did-start-loading", () => { linkPage = null; });
|
||||
win.on("closed", () => { win = null; linkPage = null; });
|
||||
load();
|
||||
}
|
||||
|
||||
@@ -323,10 +370,18 @@ function buildMenu() {
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
// macOS delivers install links here, even before the app is ready.
|
||||
app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); });
|
||||
// Windows and Linux start a second instance with the link as an argument.
|
||||
app.on("second-instance", (_e, argv) => {
|
||||
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
|
||||
const link = linkFromArgv(argv);
|
||||
if (link) openInstallLink(link);
|
||||
});
|
||||
const firstLink = IS_MAC ? null : linkFromArgv(process.argv);
|
||||
if (firstLink) openInstallLink(firstLink);
|
||||
app.whenReady().then(() => {
|
||||
registerScheme();
|
||||
buildMenu();
|
||||
createWindow();
|
||||
});
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
"build": {
|
||||
"appId": "com.saphid.frame-control",
|
||||
"productName": "Frame Control",
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Frame Control install link",
|
||||
"schemes": [
|
||||
"frame-control"
|
||||
]
|
||||
}
|
||||
],
|
||||
"directories": {
|
||||
"output": "dist",
|
||||
"buildResources": "build"
|
||||
@@ -28,6 +36,7 @@
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
// Lets the page read this computer's clipboard through Electron, so sending it
|
||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard.
|
||||
// It also receives frame-control://install links (docs/web-install.md): only
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
onInstallLink: (cb) => {
|
||||
ipcRenderer.removeAllListeners("install-link");
|
||||
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
|
||||
ipcRenderer.send("install-link:ready");
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user