From 83d74548cd486000b4b35a9818820f15e4159117 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:08:36 +1000 Subject: [PATCH 001/113] Screenshots: Copy button, right-click menu, and new shots appear on their own Each screenshot card and the viewer get a Copy button that puts the image on the clipboard (natively in the desktop app, as PNG in a browser). Right-click a screenshot to open, copy, save, show it in Finder, or copy its path or name; right-click the viewer to copy or save. The shelf re-lists the Frame's screenshots every 8 s while the window is visible and connected, redraws only when something changed, and keeps thumbnails it already has. Switching headsets clears the list and ignores answers still on their way. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/main.js | 10 ++- app/preload.js | 5 +- tests/test_server.py | 6 ++ ui/frame_host.py | 13 +++ ui/index.html | 199 +++++++++++++++++++++++++++++++++++++------ ui/server.py | 6 ++ 6 files changed, 208 insertions(+), 31 deletions(-) diff --git a/app/main.js b/app/main.js index 4497136..3c07d2b 100644 --- a/app/main.js +++ b/app/main.js @@ -1,7 +1,7 @@ // Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on // a free loopback port and shows it in a native window. The server does all the // work over the `frame` SSH alias; this file only hosts it. -const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron"); +const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron"); const { execFile, spawn } = require("child_process"); const { promisify } = require("util"); const fs = require("fs"); @@ -264,6 +264,14 @@ function fromUi(e) { } ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : ""); +// A PNG or JPEG (a screenshot) onto the clipboard as an image. +ipcMain.handle("clipboard:writeImage", (e, bytes) => { + if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false; + const img = nativeImage.createFromBuffer(Buffer.from(bytes)); + if (img.isEmpty()) throw new Error("not an image"); + clipboard.writeImage(img); + return true; +}); ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); }); ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); }); ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null); diff --git a/app/preload.js b/app/preload.js index 8ab5b8f..fb86f63 100644 --- a/app/preload.js +++ b/app/preload.js @@ -2,8 +2,8 @@ // to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells // the page where a dropped file or folder lives, so a folder can be sideloaded // as a title without zipping it (the local server reads it from there). -// It can open Set Up Connection when the headset can't be reached, and keeps the -// Frame menu's list of headsets up to date. +// It can put a screenshot on the clipboard as an image, open Set Up Connection when +// the headset can't be reached, and keeps the Frame menu's list of headsets up to date. // 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. // And it passes update state both ways: see app/updater.js. @@ -12,6 +12,7 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron"); contextBridge.exposeInMainWorld("frameApp", { notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request), readClipboard: () => ipcRenderer.invoke("clipboard:read"), + writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes), setUpConnection: () => ipcRenderer.invoke("connection:setup"), // The Frame menu's headset switcher: the page tells it the headsets, and hears picks. devicesChanged: (list) => ipcRenderer.send("devices:changed", list), diff --git a/tests/test_server.py b/tests/test_server.py index 9805806..99dc85a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -111,6 +111,8 @@ class ServerGuards(unittest.TestCase): ("/api/volume", {"level": 1.5}), ("/api/clipboard", {"text": ""}), ("/api/open", {"what": "anything-else"}), + ("/api/open", {"what": "shot", "id": "1/250820/../../.ssh/id_ed25519"}), + ("/api/open", {"what": "shot"}), ("/api/shots/save", {"ids": []}), ("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}), ("/api/shots/save", {"ids": [1]}), @@ -121,6 +123,10 @@ class ServerGuards(unittest.TestCase): status, payload = self.post(path, body) self.assertEqual(status, 400, f"{path} {body} -> {payload}") + def test_showing_a_shot_needs_it_saved_here(self): + status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"}) + self.assertEqual(status, 404, payload) + def test_screenshot_ids_checked_before_ssh(self): for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"): status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"}) diff --git a/ui/frame_host.py b/ui/frame_host.py index e5c1914..beb5f8c 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -153,6 +153,19 @@ def open_path(path): stderr=subprocess.DEVNULL, **DETACHED) +def reveal_path(path): + """Show a file selected in its folder (Linux file managers vary, so there the folder opens).""" + path = Path(path) + if MAC: + cmd = ["open", "-R", str(path)] + elif WINDOWS: + cmd = ["explorer", f"/select,{path}"] + else: + return open_path(path.parent) + subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, **DETACHED) + + open_url = open_path # the same openers hand URLs to the default browser diff --git a/ui/index.html b/ui/index.html index 0d7a686..1c19901 100644 --- a/ui/index.html +++ b/ui/index.html @@ -258,7 +258,12 @@ .shot-card .row { flex-wrap: nowrap; } .shot-card .grow { flex: 1; min-width: 0; } .shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .shot-card .s { color: var(--muted); font-size: 12px; } + .shot-card .s { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .ctx-menu { position: fixed; z-index: 1000; min-width: 200px; padding: 4px; border-radius: 4px; background: #232c38; + box-shadow: 0 10px 28px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.08); } + .ctx-menu button { display: block; width: 100%; height: 30px; padding: 0 10px; text-align: left; background: none; } + .ctx-menu button:hover, .ctx-menu button:focus-visible { background: var(--blue); color: #fff; outline: none; } + .ctx-menu hr { border: 0; border-top: 1px solid rgba(255,255,255,.1); margin: 4px 2px; } /* ---- library shelf (portrait capsules, like Steam's library home) ---- */ .shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; } @@ -701,6 +706,7 @@ + @@ -867,7 +873,7 @@
Loading…
-
Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to ~/Pictures/SteamFrame.
+
Screenshots you take in the headset with Steam's screenshot shortcut. New ones appear on their own. Click one to open it in the viewer, Copy puts it on the clipboard, and Save copies it to ~/Pictures/SteamFrame. Right-click for more.
@@ -1869,7 +1875,7 @@ async function capture() { if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`; ctrlShow(); $("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString(); - $("saveBtn").disabled = false; + $("saveBtn").disabled = $("copyBtn").disabled = false; return true; } catch (e) { log("Capture failed: " + e.message, "e"); @@ -1951,7 +1957,7 @@ async function startVideo() { $("viewer").classList.remove("busy"); c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true; $("srcBadge").hidden = false; $("srcBadge").textContent = `${video.label} · video`; - $("stamp").hidden = false; $("saveBtn").disabled = false; + $("stamp").hidden = false; $("saveBtn").disabled = $("copyBtn").disabled = false; } frames++; const now = performance.now(); @@ -2020,6 +2026,35 @@ $("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) : if (!b) return toast("Couldn't encode the image", true); download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`); }, "image/png"); +const copyViewer = () => act("Copy image", () => copyImage(lastShot ? lastShot.blob + : new Promise((ok, bad) => $("canvas").toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png")))); +$("copyBtn").onclick = copyViewer; +// Right-click the viewer to copy or save what it shows. +$("canvas").oncontextmenu = e => { + if ($("saveBtn").disabled) return; + e.preventDefault(); + showMenu(e, [["Copy image", copyViewer], ["Save image", () => $("saveBtn").click()]]); +}; +// An image onto the clipboard. The app does it natively (JPEG too); a browser +// takes PNG only, and the blob is handed over as a promise so the click still counts. +async function copyImage(blob) { + if (window.frameApp?.writeImage) { + await window.frameApp.writeImage(new Uint8Array(await (await blob).arrayBuffer())); + return { message: "Copied the image" }; + } + if (!navigator.clipboard?.write || !window.ClipboardItem) throw new Error("This browser can't copy images here"); + await navigator.clipboard.write([new ClipboardItem({ "image/png": Promise.resolve(blob).then(pngBlob) })]); + return { message: "Copied the image" }; +} +async function pngBlob(blob) { + if (blob.type === "image/png") return blob; + const bmp = await createImageBitmap(blob); + const c = document.createElement("canvas"); + c.width = bmp.width; c.height = bmp.height; + c.getContext("2d").drawImage(bmp, 0, 0); + bmp.close(); + return new Promise((ok, bad) => c.toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png")); +} function download(blob, name) { if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }])); const a = document.createElement("a"); @@ -3497,8 +3532,11 @@ loadReports(); api("/api/host").then(applyHostWording).catch(() => {}); // ---- Steam screenshots from the headset ---- -const shots = { list: [], urls: [] }; +// thumbs: id -> promise of an object URL, kept across reloads so a refresh +// that finds a new shot fetches only that one's thumbnail. +const shots = { list: [], sig: null, thumbs: new Map(), loading: null, gen: 0 }; const STEAMVR_APPID = "250820"; +const SHOTS_POLL_MS = 8000; function shotApp(appid) { if (appid === STEAMVR_APPID) return "SteamVR"; const g = state?.games?.find(x => x.appid === appid); @@ -3510,35 +3548,71 @@ async function shotBlob(id, thumb) { if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`); return r.blob(); } -async function loadShots() { - $("shotsRefresh").disabled = true; +function shotThumb(id) { + if (!shots.thumbs.has(id)) { + const p = shotBlob(id, true).then(b => URL.createObjectURL(b)); + p.catch(() => shots.thumbs.delete(id)); // try again next time + shots.thumbs.set(id, p); + } + return shots.thumbs.get(id); +} +// quiet: a background check. It keeps what's shown if the Frame can't be read, +// and redraws only when the shots (or whether they're saved here) changed. +function loadShots(quiet) { + if (!shots.loading) { + const p = readShots(quiet === true).finally(() => { if (shots.loading === p) shots.loading = null; }); + shots.loading = p; + } + return shots.loading; +} +// Forget the shots of a headset we've switched away from, and ignore its answers still on their way. +function resetShots() { + shots.gen++; shots.loading = null; shots.list = []; shots.sig = null; + for (const p of shots.thumbs.values()) p.then(URL.revokeObjectURL, () => {}); + shots.thumbs = new Map(); +} +async function readShots(quiet) { + const gen = shots.gen; + if (!quiet) $("shotsRefresh").disabled = true; + let got; try { - shots.list = (await api("/api/shots")).shots; + got = await api("/api/shots"); } catch (e) { - return failed($("shotGrid"), e); + if (!quiet && gen === shots.gen) { shots.sig = null; failed($("shotGrid"), e); } + return; } finally { $("shotsRefresh").disabled = false; } - shots.urls.forEach(URL.revokeObjectURL); shots.urls = []; + if (gen !== shots.gen) return; + ({ shots: shots.list, folder: shots.folder } = got); + const sig = JSON.stringify(shots.list.map(s => [s.id, s.saved, shotApp(s.appid)])); + if (sig !== shots.sig) { shots.sig = sig; drawShots(); } + // Thumbnails one at a time over the shared SSH connection; one that failed is tried again next time. + for (const img of document.querySelectorAll("#shotGrid img[data-shot]:not([src])")) { + const s = shots.list[+img.dataset.shot]; + try { img.src = await shotThumb(s.id); } catch (e) { img.alt = "Preview failed"; } + if (!img.isConnected || gen !== shots.gen) return; // redrawn, or another headset, meanwhile + } +} +// "Sep 28, 10:37 PM": short enough to sit beside the card's buttons (the full date is its tooltip). +function shotTime(t) { + return new Date(t * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); +} +function drawShots() { + const ids = new Set(shots.list.map(s => s.id)); + for (const [id, p] of shots.thumbs) { + if (!ids.has(id)) { shots.thumbs.delete(id); p.then(URL.revokeObjectURL, () => {}); } + } const unsaved = shots.list.filter(s => !s.saved).length; $("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) => `
- Screenshot from ${esc(shotApp(s.appid))} + $("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `
+ Screenshot from ${esc(shotApp(s.appid))}
${esc(shotApp(s.appid))}
-
${esc(new Date(s.time * 1000).toLocaleString())}
+
${esc(shotTime(s.time))}
+ ${s.saved && !HOST.mobile ? `On ${HOST.computer}` : ``}
`).join("") : `
No screenshots on the Frame yet.
`; - // Thumbnails one at a time over the shared SSH connection. - for (const img of document.querySelectorAll("#shotGrid img[data-shot]")) { - const s = shots.list[+img.dataset.shot]; - try { - const url = URL.createObjectURL(await shotBlob(s.id, true)); - shots.urls.push(url); - img.src = url; - } catch (e) { img.alt = "Preview failed"; } - if (!img.isConnected) return; // the list was reloaded meanwhile - } } async function openShot(s) { if (live) toggleLive(false); @@ -3556,7 +3630,7 @@ async function openShot(s) { draw(); $("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`; $("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString(); - $("saveBtn").disabled = false; + $("saveBtn").disabled = $("copyBtn").disabled = false; $("view").scrollIntoView({ behavior: "smooth" }); } catch (e) { toast("Couldn't open the screenshot: " + e.message, true); @@ -3575,15 +3649,84 @@ async function saveShots(list, btn) { () => api("/api/shots/save", { ids: list.map(s => s.id) }), btn); if (res) loadShots(); } +const copyShot = (s, btn) => act("Copy screenshot", () => copyImage(shotBlob(s.id, false)), btn); $("shotGrid").onclick = e => { const img = e.target.closest("img[data-shot]"); if (img) return openShot(shots.list[+img.dataset.shot]); + const c = e.target.closest("[data-shot-copy]"); + if (c) return copyShot(shots.list[+c.dataset.shotCopy], c); const b = e.target.closest("[data-shot-save]"); if (b) saveShots([shots.list[+b.dataset.shotSave]], b); }; -$("shotsRefresh").onclick = loadShots; +// A small right-click menu. items: [label, fn] pairs, null for a divider. +function closeMenu() { document.querySelector(".ctx-menu")?.remove(); } +function showMenu(e, items) { + closeMenu(); + const menu = document.createElement("div"); + menu.className = "ctx-menu"; + menu.setAttribute("role", "menu"); + for (const it of items) { + if (!it) { menu.append(document.createElement("hr")); continue; } + const b = document.createElement("button"); + b.textContent = it[0]; + b.setAttribute("role", "menuitem"); + b.onclick = () => { closeMenu(); it[1](); }; + menu.append(b); + } + (document.fullscreenElement || document.body).append(menu); // in front of a fullscreen viewer + const r = menu.getBoundingClientRect(); + menu.style.left = Math.max(4, Math.min(e.clientX, innerWidth - r.width - 4)) + "px"; + menu.style.top = Math.max(4, Math.min(e.clientY, innerHeight - r.height - 4)) + "px"; + menu.querySelector("button").focus(); +} +document.addEventListener("pointerdown", e => { if (!e.target.closest(".ctx-menu")) closeMenu(); }, true); +document.addEventListener("keydown", e => { + const menu = document.querySelector(".ctx-menu"); + if (!menu) return; + if (e.key === "Escape") return closeMenu(); + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); // arrows move through the menu, not the page + const items = [...menu.querySelectorAll("button")]; + const at = items.indexOf(document.activeElement), step = e.key === "ArrowDown" ? 1 : -1; + items[at < 0 ? (step > 0 ? 0 : items.length - 1) : (at + step + items.length) % items.length].focus(); +}); +addEventListener("blur", closeMenu); +document.addEventListener("fullscreenchange", closeMenu); +addEventListener("scroll", closeMenu, true); +// Right-click a screenshot for everything it can do. +$("shotGrid").oncontextmenu = e => { + const card = e.target.closest("[data-card]"); + if (!card) return; + e.preventDefault(); + const s = shots.list[+card.dataset.card]; + const items = [ + ["Open in viewer", () => openShot(s)], + ["Copy image", () => copyShot(s)], + ]; + if (!s.saved || HOST.mobile) items.push(null, [`Save to ${HOST.computer}`, () => saveShots([s])]); + else { + const sep = shots.folder.includes("\\") ? "\\" : "/"; + items.push(null); + items.push([`Show in ${!HOST.fileManager || HOST.fileManager === "your file manager" ? "folder" : HOST.fileManager}`, + () => act("Show screenshot", () => api("/api/open", { what: "shot", id: s.id }))]); + items.push(["Copy file path", () => act("Copy file path", async () => { + await navigator.clipboard.writeText(shots.folder + sep + s.file); + return { message: "Copied the file path" }; + })]); + } + items.push(null, ["Copy file name", () => act("Copy file name", async () => { + await navigator.clipboard.writeText(s.file); + return { message: "Copied " + s.file }; + })]); + showMenu(e, items); +}; +$("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); +// Watch for new shots: a cheap listing over the shared SSH connection while the +// window is visible and the Frame is reachable, and again on coming back to it. +setInterval(() => { if (!document.hidden && online) loadShots(true); }, SHOTS_POLL_MS); +document.addEventListener("visibilitychange", () => { if (!document.hidden && online) loadShots(true); }); // ---- Panel switcher: our UI over SteamVR's panel API ---- let panelSeq = 0; @@ -4177,7 +4320,7 @@ if (window.frameApp && window.frameApp.onInstallLink) { } setView("headset"); -refresh().then(loadShots); // after status, so app names resolve +refresh().then(() => loadShots()); // after status, so app names resolve document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); }); ", page)) + for src in re.findall(r'", html) + parts += [(ROOT / "ui" / src).read_text(encoding="utf-8") for src in re.findall(r'" + across = "

x

" + same_line = "" + const_and_function = "" + for html in (dup, across, same_line, const_and_function): + rc, err = self.module_check(self.page_scripts(html)) + self.assertNotEqual(rc, 0, html) + self.assertIn("has already been declared", err, html) + nested = ("") + self.assertEqual(self.module_check(self.page_scripts(nested)), (0, "")) def test_no_top_level_name_is_declared_twice(self): - import re - for name in ("index.html", "assistant.html", "mac-view.html"): - page = (ROOT / "ui" / name).read_text(encoding="utf-8") - scripts = "\n".join(re.findall(r"", page)) - for src in re.findall(r' ", "blob:https://evil.example/1", + "javascript:alert(1)", "file:///etc/passwd", "ftp://example.com/", "https://u:p@evil.example/", + "chrome://settings", "not a url"]) { + assert.strictEqual(frameNav(wc, bad, false), true, bad); + } + // The window itself: Meta's sites only. + assert.strictEqual(frameNav(wc, "about:blank", true), true); assert.strictEqual(frameNav(wc, "https://evil.example/", true), true); + assert.strictEqual(frameNav(wc, "https://client-api.arkoselabs.com/", true), true); assert.strictEqual(frameNav(wc, "https://auth.meta.com/login/", true), false); - assert.deepStrictEqual(f.opened, []); + assert.deepStrictEqual(f.opened, []); // stopped, never opened outside f.windows[0].destroy(); }); diff --git a/docs/meta-library.md b/docs/meta-library.md index cf1855f..7b9309b 100644 --- a/docs/meta-library.md +++ b/docs/meta-library.md @@ -45,9 +45,14 @@ closes the window. The window: or check (camera, notifications…) refused; - only goes to `https://` pages on `oculus.com`, `meta.com` and `facebook.com` (and their subdomains). A link anywhere else opens in your system browser; a - redirect anywhere else is stopped. Frames inside the page are held to the - same list (plus `about:blank`), and one going elsewhere is just stopped. - Pop-ups on those sites open in the same window; + redirect anywhere else is stopped. Pop-ups on those sites open in the same + window. Frames inside the page may load any `https://` page (Meta's login + can embed captcha and CDN frames from other sites, such as Arkose Labs, + hCaptcha, reCAPTCHA or fbcdn) or an empty frame (`about:blank`, + `about:srcdoc`); a frame going to any other scheme (`http:`, `data:`, + `blob:`, `javascript:`, `file:`…) is just stopped. Frames can't reach the + token: the cookie is read by Frame Control's main process, and the window + has no IPC; - sends a cookie value on only if it looks like a user token (`OC` or `FRL`, mixed case, no `%`, `|` or `:`), and each value once. If the server refuses it, the window stays open; close it to see why; @@ -306,7 +311,8 @@ and cookies (`app/test/meta-signin.test.js`): the window's options (its own partition, sandbox, context isolation, no Node, no preload) and Meta's login address, no developer tools; permission requests and checks refused; the navigation allow-list (links elsewhere open in the system browser, redirects -and frames going elsewhere are stopped, pop-ups never become windows); a +elsewhere are stopped, pop-ups never become windows); frames allowed any +HTTPS page but no other scheme, the window itself only Meta's sites; a page's beforeunload not keeping the window open; the cookie read only from `persist:meta-signin` and only for `oculus.com`, even when the default session and another partition hold one; signed-out values ignored; a value @@ -331,8 +337,7 @@ working token. Meta account was used, and nothing was sent to Meta): whether Meta's login still goes only through `oculus.com`, `meta.com` and `facebook.com`, whether it uses pop-ups (they open in the same window here, which would break a -pop-up that reports back to its opener), whether it needs frames from other -sites such as a captcha (those are stopped), whether `oc_ac_at` is still set on +pop-up that reports back to its opener), whether `oc_ac_at` is still set on `oculus.com` after login, and whether Meta accepts the window (Frame Control removes Electron's name from the user agent as a precaution). A real Meta token and Meta's real answers (none was available; nothing was sent to Meta), real downloads and their redirects, From 9d92396d49e6a2a5b75a1778066c987257d635a1 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:54:38 +1100 Subject: [PATCH 099/113] My Meta library: Sign in with Meta cancels by in-flight attempt and saved owner - frame_meta keeps only in-flight sign-in attempts (removed when their connect finishes) instead of a bounded list of cancelled ids, so other cancels can't push one out, and records which attempt saved the account. Cancelling an attempt being checked stops its save; cancelling the attempt that just saved deletes that token; a later sign-in's or a pasted token's account is never touched; unknown attempts change nothing. - will-redirect applies the per-frame rule: frames may be redirected to any https: page, the window only to Meta's sites. Co-Authored-By: Claude Opus 5.5 --- app/meta-signin.js | 8 +++- app/test/meta-signin.test.js | 29 ++++++++++++ docs/meta-library.md | 22 ++++++--- tests/test_meta.py | 90 +++++++++++++++++++++++++++--------- ui/frame_meta.py | 73 ++++++++++++++++++----------- 5 files changed, 166 insertions(+), 56 deletions(-) diff --git a/app/meta-signin.js b/app/meta-signin.js index 90c55ee..c49bd17 100644 --- a/app/meta-signin.js +++ b/app/meta-signin.js @@ -164,7 +164,13 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare if (e.isMainFrame ? allowedUrl(e.url) : allowedSubframeUrl(e.url)) return; e.preventDefault(); }); - wc.on("will-redirect", (e, target) => { if (!allowedUrl(target)) e.preventDefault(); }); + // Redirects fire for frames too: the same rule per frame (details on the event in + // current Electron, positional arguments in older ones; unknown counts as the window). + wc.on("will-redirect", (e, target, _inPlace, isMainFrame) => { + const to = e && e.url !== undefined ? e.url : target; + const main = e && e.isMainFrame !== undefined ? e.isMainFrame : isMainFrame !== false; + if (!(main ? allowedUrl(to) : allowedSubframeUrl(to))) e.preventDefault(); + }); // A page's beforeunload never keeps the window open. wc.on("will-prevent-unload", (e) => e.preventDefault()); wc.setWindowOpenHandler(({ url: target }) => { diff --git a/app/test/meta-signin.test.js b/app/test/meta-signin.test.js index 8b27b26..f2f481a 100644 --- a/app/test/meta-signin.test.js +++ b/app/test/meta-signin.test.js @@ -312,6 +312,35 @@ test("the window stays on Meta's sites; frames inside it may load any HTTPS page f.windows[0].destroy(); }); +function redirect(wc, url, isMainFrame, style) { + let prevented = false; + const e = { preventDefault: () => { prevented = true; } }; + if (style === "details") wc.emit("will-redirect", Object.assign(e, { url, isMainFrame })); + else wc.emit("will-redirect", e, url, false, isMainFrame); + return prevented; +} + +test("redirects follow the same rule per frame", async () => { + const f = fakes(); + f.flow.signIn(); + const wc = f.windows[0].webContents; + for (const style of ["details", "positional"]) { + // A captcha frame redirected within HTTPS loads; a frame redirected to data: or http: doesn't. + assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", false, style), false, style); + assert.strictEqual(redirect(wc, "https://newassets.hcaptcha.com/x", false, style), false, style); + assert.strictEqual(redirect(wc, "data:text/html,x", false, style), true, style); + assert.strictEqual(redirect(wc, "http://www.facebook.com/x", false, style), true, style); + assert.strictEqual(redirect(wc, "javascript:alert(1)", false, style), true, style); + // The window: Meta's sites only. + assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", true, style), true, style); + assert.strictEqual(redirect(wc, "https://auth.meta.com/next", true, style), false, style); + } + // Without frame information it is treated as the window. + assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/", undefined, "positional"), true); + assert.deepStrictEqual(f.opened, []); + f.windows[0].destroy(); +}); + test("a page's beforeunload can't keep the window open", async () => { const f = fakes(); const done = f.flow.signIn(); diff --git a/docs/meta-library.md b/docs/meta-library.md index 7b9309b..6458de5 100644 --- a/docs/meta-library.md +++ b/docs/meta-library.md @@ -50,7 +50,8 @@ closes the window. The window: can embed captcha and CDN frames from other sites, such as Arkose Labs, hCaptcha, reCAPTCHA or fbcdn) or an empty frame (`about:blank`, `about:srcdoc`); a frame going to any other scheme (`http:`, `data:`, - `blob:`, `javascript:`, `file:`…) is just stopped. Frames can't reach the + `blob:`, `javascript:`, `file:`…) is just stopped. Redirects follow the same + rule for the frame they happen in. Frames can't reach the token: the cookie is read by Frame Control's main process, and the window has no IPC; - sends a cookie value on only if it looks like a user token (`OC` or `FRL`, @@ -60,8 +61,11 @@ closes the window. The window: - when it is closed, times out or is signed out of, stops reading cookies first, then is destroyed (a page's "leave this page?" can't keep it open), and, if it had sent a token, tells the server to drop that sign-in: each - sign-in has a random attempt id, and a connect from a cancelled attempt is - never saved, even if Meta is still answering when the cancel arrives. + sign-in has a random attempt id. The server lists an attempt only while its + connect is being checked: cancelled then, it isn't saved; cancelled just + after its token was saved, that token is deleted (as a disconnect). A cancel + never removes an account a later sign-in or a pasted token saved, and one + for an unknown attempt changes nothing. The page never sees the token: the desktop app hands it straight to the local server's connect path and tells the page only the answer (connected, and your @@ -140,7 +144,8 @@ test return a background job (`/api/job`). Sign in with Meta uses the same `connect` action: the desktop app's main process (`app/meta-signin.js`) posts it with the cookie's value and an `"attempt"` id (8 to 64 letters, digits or `-`) once the page's acknowledgement box is ticked. A connect whose attempt -was cancelled answers 409 and saves nothing. +was cancelled while it was checked answers 409 and saves nothing; +`cancel-signin` for an attempt whose token is the saved account deletes it. ## What an install does @@ -322,9 +327,12 @@ logged, and a server error that echoed the token scrubbed; closing the window or signing out ending the wait; closing, timing out or signing out during a connect cancelling that attempt (with its own id) after cookie intake stops; sign-out destroying the window before clearing only that partition. The -server side (`tests/test_meta.py`): a cancelled attempt refused before -anything is sent to Meta, and also when cancelled while Meta is answering; -attempt ids checked; other attempts and pasted tokens unaffected. +frame redirects held to the frame rule, the window's to Meta's sites. The +server side (`tests/test_meta.py`): an attempt cancelled while Meta is +answering not saved, even after 300 other cancels; a cancel just after the +save deleting that token; a cancel never removing a later sign-in's or a +pasted token's account; unknown attempts changing nothing; no attempt left +listed after a connect, refused or not; attempt ids checked. **Differences from BSManager:** BSManager uses its app's default session (and clears only `.oculus.com` cookies afterwards); Frame Control uses a session of diff --git a/tests/test_meta.py b/tests/test_meta.py index f794fef..3a2cf07 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -201,31 +201,20 @@ class Tokens(unittest.TestCase): class SignInAttempts(unittest.TestCase): - """Sign in with Meta (app/meta-signin.js): an attempt the app gave up on is never saved.""" + """Sign in with Meta (app/meta-signin.js): a token from an attempt the app gave up on is + never kept, whether the cancel comes while it is checked or just after it was saved.""" def tearDown(self): meta.disconnect() - def test_a_cancelled_attempt_is_refused_before_anything_is_sent(self): - fake = FakeMeta(self) - meta.cancel_signin('attempt-before-0001') - with self.assertRaises(meta.SignInCancelled): - meta.connect(TOKEN, attempt='attempt-before-0001') - self.assertEqual(fake.requests, []) - self.assertFalse(meta.account_path().exists()) - # Another attempt, and a pasted token (no attempt), still connect. - self.assertTrue(meta.connect(TOKEN, attempt='attempt-other-0002')['connected']) - meta.disconnect() - self.assertTrue(meta.connect(TOKEN)['connected']) - - def test_cancelling_while_meta_answers_wins(self): - fake = FakeMeta(self) + def held_connect(self, fake, attempt): + """Start connect(TOKEN, attempt) and wait until Meta is answering "who am I".""" fake.hold = threading.Event() errors = [] def connect(): try: - meta.connect(TOKEN, attempt='attempt-during-0003') + meta.connect(TOKEN, attempt=attempt) except meta.MetaError as e: errors.append(e) thread = threading.Thread(target=connect) @@ -234,6 +223,12 @@ class SignInAttempts(unittest.TestCase): if fake.requests: break threading.Event().wait(0.01) + return thread, errors + + def test_cancelling_while_meta_answers_wins(self): + fake = FakeMeta(self) + thread, errors = self.held_connect(fake, 'attempt-during-0003') + self.assertIn('attempt-during-0003', meta._attempts) meta.cancel_signin('attempt-during-0003') # the window closed while Meta was answering fake.hold.set() thread.join(10) @@ -241,6 +236,53 @@ class SignInAttempts(unittest.TestCase): self.assertIsInstance(errors[0], meta.SignInCancelled) self.assertFalse(meta.account_path().exists()) self.assertFalse(meta.status()['connected']) + self.assertEqual(meta._attempts, {}) + + def test_other_cancels_cant_push_out_one_in_flight(self): + fake = FakeMeta(self) + thread, errors = self.held_connect(fake, 'attempt-held-0004') + meta.cancel_signin('attempt-held-0004') + for i in range(300): + meta.cancel_signin(f'attempt-noise-{i:04d}') + fake.hold.set() + thread.join(10) + self.assertIsInstance(errors[0], meta.SignInCancelled) + self.assertFalse(meta.account_path().exists()) + self.assertEqual(meta._attempts, {}) # unknown ids left nothing behind + + def test_a_cancel_just_after_the_save_removes_that_token(self): + FakeMeta(self) + self.assertTrue(meta.connect(TOKEN, attempt='attempt-saved-0005')['connected']) + self.assertFalse(meta.cancel_signin('attempt-saved-0005')['connected']) + self.assertFalse(meta.account_path().exists()) + + def test_a_cancel_never_removes_a_later_account(self): + FakeMeta(self) + meta.connect(TOKEN, attempt='attempt-first-0006') + meta.connect(TOKEN, attempt='attempt-second-0007') # a later sign-in owns the account now + meta.cancel_signin('attempt-first-0006') + self.assertTrue(meta.status()['connected']) + meta.connect(TOKEN) # a pasted token + meta.cancel_signin('attempt-second-0007') + self.assertTrue(meta.status()['connected']) + meta.disconnect() + meta.connect(TOKEN, attempt='attempt-third-0008') + meta.disconnect() + meta.connect(TOKEN) + meta.cancel_signin('attempt-third-0008') # its account was disconnected already + self.assertTrue(meta.status()['connected']) + + def test_unknown_attempts_change_nothing(self): + FakeMeta(self) + meta.cancel_signin('attempt-unknown-0009') + self.assertTrue(meta.connect(TOKEN, attempt='attempt-unknown-0009')['connected']) + self.assertEqual(meta._attempts, {}) + + def test_a_rejected_token_leaves_no_attempt_behind(self): + FakeMeta(self, token=OTHER_TOKEN) + with self.assertRaises(meta.TokenRejected): + meta.connect(TOKEN, attempt='attempt-rejected-0010') + self.assertEqual(meta._attempts, {}) def test_attempt_ids_are_checked(self): FakeMeta(self) @@ -1158,7 +1200,7 @@ class Server(unittest.TestCase): def test_sign_in_attempts_through_the_server(self): s = self.server - fake = FakeMeta(self) + FakeMeta(self) self.addCleanup(meta.disconnect) for body in ({'action': 'cancel-signin'}, {'action': 'cancel-signin', 'attempt': 'no'}, {'action': 'connect', 'token': TOKEN, 'acknowledged': True, 'attempt': 5}, @@ -1166,15 +1208,19 @@ class Server(unittest.TestCase): with self.assertRaises(s.Failure) as e: s.meta(body) self.assertEqual(e.exception.status, 400, body) + self.assertTrue(s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True, + 'attempt': 'server-attempt-0001'})['connected']) result = s.meta({'action': 'cancel-signin', 'attempt': 'server-attempt-0001'}) self.assertFalse(result['connected']) - with self.assertRaises(s.Failure) as e: - s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True, 'attempt': 'server-attempt-0001'}) + self.assertNotIn(TOKEN, json.dumps(result)) + # A cancel caught while Meta answers is a 409, without the token. + with patch.object(meta, 'whoami', side_effect=lambda t: meta.cancel_signin('server-attempt-0002') or + {'id': '1', 'name': 'player_one'}): + with self.assertRaises(s.Failure) as e: + s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True, 'attempt': 'server-attempt-0002'}) self.assertEqual(e.exception.status, 409) self.assertNotIn(TOKEN, str(e.exception)) - self.assertEqual(fake.requests, []) - self.assertTrue(s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True, - 'attempt': 'server-attempt-0002'})['connected']) + self.assertFalse(meta.status()['connected']) if __name__ == '__main__': diff --git a/ui/frame_meta.py b/ui/frame_meta.py index 7778098..894e8ae 100644 --- a/ui/frame_meta.py +++ b/ui/frame_meta.py @@ -26,7 +26,6 @@ Python stdlib only. CLI: python3 ui/frame_meta.py connect --token-stdin | status | disconnect | list [--refresh] | download APP_ID install APP_ID [--test] [--allow-downgrade] | test PACKAGE [--wait SECONDS] [--no-report] """ -import collections import concurrent.futures import hashlib import http.client @@ -325,11 +324,15 @@ def status(): 'connectedAt': (account or {}).get('connected')} -# Sign in with Meta (app/meta-signin.js) names each of its attempts; one it gave up on is -# remembered here, so its connect is refused whether the cancel arrives before or after it. +# Sign in with Meta (app/meta-signin.js) names each of its attempts. While a connect from an +# attempt is being checked it is listed in _attempts (only then, so this never grows); a +# cancel marks it, and it is then not saved. _saved_by is the attempt whose token is the +# saved account (None for a pasted token), set together with the save, so a cancel that +# arrives just after the save still removes that token, and never a later one. SIGNIN_ATTEMPT = re.compile(r'[A-Za-z0-9-]{8,64}') SIGNIN_CANCELLED = 'Sign in with Meta was cancelled, so that sign-in was not saved.' -_cancelled = collections.deque(maxlen=256) +_attempts = {} # in-flight attempt id -> {'cancelled': bool, 'running': connects under way} +_saved_by = [None] def _check_attempt(attempt): @@ -339,13 +342,16 @@ def _check_attempt(attempt): def cancel_signin(attempt): - """Refuse to save any connect made under this Sign in with Meta attempt, now or later.""" - _check_attempt(attempt) + """Never keep a token from this Sign in with Meta attempt: one still being checked isn't + saved, and one already saved is deleted. Unknown attempts change nothing.""" if attempt is None: raise MetaError('Invalid sign-in attempt id.') + _check_attempt(attempt) with _lock: - if attempt not in _cancelled: - _cancelled.append(attempt) + if attempt in _attempts: + _attempts[attempt]['cancelled'] = True + elif _saved_by[0] == attempt: + _forget_locked() return status() @@ -356,31 +362,46 @@ def connect(token, attempt=None): _check_attempt(attempt) token = check_token(token) with _lock: - if attempt is not None and attempt in _cancelled: - raise SignInCancelled(SIGNIN_CANCELLED) generation = _state['generation'] - user = whoami(token) - with _lock: - if attempt is not None and attempt in _cancelled: - raise SignInCancelled(SIGNIN_CANCELLED) - if _state['generation'] != generation: - raise MetaError('The Meta account was disconnected (or another token connected) while this one ' - 'was being checked, so it was not saved.') - _write_account({'token': token, 'user': user, 'connected': time.strftime('%Y-%m-%dT%H:%M:%S')}) - _state['generation'] += 1 - _library.update(generation=None, at=0, apps=None) + if attempt is not None: + entry = _attempts.setdefault(attempt, {'cancelled': False, 'running': 0}) + entry['running'] += 1 + try: + user = whoami(token) + with _lock: + if attempt is not None and _attempts[attempt]['cancelled']: + raise SignInCancelled(SIGNIN_CANCELLED) + if _state['generation'] != generation: + raise MetaError('The Meta account was disconnected (or another token connected) while this one ' + 'was being checked, so it was not saved.') + _write_account({'token': token, 'user': user, 'connected': time.strftime('%Y-%m-%dT%H:%M:%S')}) + _saved_by[0] = attempt + _state['generation'] += 1 + _library.update(generation=None, at=0, apps=None) + finally: + if attempt is not None: + with _lock: + entry = _attempts[attempt] + entry['running'] -= 1 + if not entry['running']: + del _attempts[attempt] return status() +def _forget_locked(): + _state['generation'] += 1 + _saved_by[0] = None + _library.update(generation=None, at=0, apps=None) + try: + os.remove(str(account_path())) + except FileNotFoundError: + pass + + def disconnect(): """Forget the token: delete the file and the library in memory. Downloads under way stop.""" with _lock: - _state['generation'] += 1 - _library.update(generation=None, at=0, apps=None) - try: - os.remove(str(account_path())) - except FileNotFoundError: - pass + _forget_locked() return status() From 9c33711e6f9b9f0b366f42cc1e7334dac87edee0 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:00:36 +1100 Subject: [PATCH 100/113] My Meta library: Sign in with Meta cancels only after its connect settles When the sign-in window is closed, times out or is signed out of while a connect is under way, the page is answered at once, but cancel-signin is posted only after that connect settles (saved, refused or failed), so the server always sees the cancel after the connect and deletes the token if that connect saved it. Nothing sent means no cancel. Sign-out, Disconnect's first step, waits for any such cancel (also from a window closed earlier) before the page posts its disconnect. Co-Authored-By: Claude Opus 5.5 --- app/meta-signin.js | 22 +++++--- app/test/meta-signin.test.js | 99 +++++++++++++++++++++++++++++------- docs/meta-library.md | 15 ++++-- 3 files changed, 108 insertions(+), 28 deletions(-) diff --git a/app/meta-signin.js b/app/meta-signin.js index c49bd17..a4cf0ee 100644 --- a/app/meta-signin.js +++ b/app/meta-signin.js @@ -65,6 +65,7 @@ function scrub(text, token) { // cancel(attempt) -> Promise (the server's cancel-signin: never save that attempt) } function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent = () => null, timeout = TIMEOUT }) { let active = null; // { win, promise, finish } + const pending = new Set(); // cancels still to reach the server, from flows already finished const openOutside = (target) => { if (/^https?:\/\//i.test(String(target))) shell.openExternal(String(target)); @@ -102,9 +103,12 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare if (timer.unref) timer.unref(); // Stops cookie intake first, then the window (destroy: a page's beforeunload can't - // keep it open), then, unless it connected, tells the server not to save a connect - // that may still be under way. Resolves once that is done. + // keep it open), and answers the page. Unless it connected, and if it sent a token, it + // then has the server drop this attempt: only once the connect under way has settled, + // so the server always sees the cancel after the connect (and deletes the token if + // that connect saved it). The returned promise settles when that is done. let finished = null; + let inflight = null; // the last connect sent function finish(result) { if (done) return finished || Promise.resolve(); // destroy() below re-enters through "closed" done = true; @@ -112,9 +116,11 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare clearTimeout(timer); if (active && active.win === win) active = null; if (!win.isDestroyed()) win.destroy(); - const sent = tried.size > 0; // a connect is sent synchronously after its value is added - finished = (result.ok || !sent ? Promise.resolve() : Promise.resolve().then(() => cancel(attemptId))) - .catch(() => {}).then(() => resolve(result)); + resolve(result); + if (result.ok || !inflight) return (finished = Promise.resolve()); // nothing sent: nothing to cancel + finished = Promise.resolve(inflight).catch(() => {}).then(() => cancel(attemptId)).catch(() => {}); + pending.add(finished); + finished.then(() => pending.delete(finished)); return finished; } @@ -134,7 +140,8 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare tried.add(key); let r; try { - r = await connect(c.value, attemptId); + inflight = connect(c.value, attemptId); + r = await inflight; } catch { lastError = "Frame Control's local server didn't answer."; continue; @@ -201,6 +208,9 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare // Clear only once nothing can write to the session again. if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again."); } + // Any connect still under way settles and is cancelled before the caller (Disconnect) + // deletes the account, so it can't be saved afterwards. + await Promise.all([...pending]); const ses = session.fromPartition(PARTITION); await ses.clearStorageData(); await ses.clearCache(); diff --git a/app/test/meta-signin.test.js b/app/test/meta-signin.test.js index f2f481a..2213f62 100644 --- a/app/test/meta-signin.test.js +++ b/app/test/meta-signin.test.js @@ -355,35 +355,97 @@ test("a page's beforeunload can't keep the window open", async () => { assert.ok(w.isDestroyed()); }); -test("closing the window while a connect is under way has the server drop that attempt", async () => { +test("closing during a connect: the cancel goes to the server only after that connect settles", async () => { + for (const outcome of ["saved", "refused", "failed"]) { + const f = fakes(); + let release, fail; + f.setAnswer(() => new Promise((r, j) => { release = r; fail = j; })); + const done = f.flow.signIn(); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN }); + await settle(); + assert.deepStrictEqual(f.calls, [TOKEN]); + f.windows[0].close(); // the user gives up while the server and Meta are answering + const r = await done; // the page hears at once + assert.strictEqual(r.ok, false); + await settle(); + assert.deepStrictEqual(f.cancels, [], outcome); // not before the connect has settled + if (outcome === "saved") release({ status: 200, data: { connected: true } }); + else if (outcome === "refused") release({ status: 400, data: { error: "no" } }); + else fail(new Error("socket hang up")); + await settle(); + // The same attempt id the connect carried, after it settled and after cookie intake stopped. + assert.deepStrictEqual(f.cancels, [f.attempts[0]], outcome); + assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/); + assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"], outcome); + assert.deepStrictEqual(f.calls, [TOKEN]); + } +}); + +test("closing before any connect needs no cancel", async () => { + const f = fakes(); + const done = f.flow.signIn(); + f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: TOKEN }); // nothing usable + await settle(); + f.windows[0].close(); + assert.strictEqual((await done).ok, false); + await f.flow.signOut(); + await settle(); + assert.deepStrictEqual(f.calls, []); + assert.deepStrictEqual(f.cancels, []); +}); + +test("a timeout during a connect cancels after it settles", async () => { + const f = fakes({ timeout: 50 }); + let release; + f.setAnswer(() => new Promise((r) => { release = r; })); // Meta is slow + const done = f.flow.signIn(); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN }); + const r = await done; + assert.match(r.error, /timed out/); + assert.ok(f.windows[0].isDestroyed()); + assert.deepStrictEqual(f.cancels, []); + release({ status: 400, data: { error: "no" } }); + await settle(); + assert.deepStrictEqual(f.cancels, [f.attempts[0]]); +}); + +test("disconnect during a connect waits for it, cancels it, then clears, before the page deletes the account", async () => { const f = fakes(); let release; f.setAnswer(() => new Promise((r) => { release = r; })); const done = f.flow.signIn(); f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN }); await settle(); - assert.deepStrictEqual(f.calls, [TOKEN]); - f.windows[0].close(); // the user gives up while Meta is answering - const r = await done; - assert.strictEqual(r.ok, false); - // The same attempt id the connect carried, cancelled after cookie intake stopped. - assert.strictEqual(f.cancels.length, 1); - assert.strictEqual(f.cancels[0], f.attempts[0]); - assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/); - assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"]); - release({ status: 200, data: { connected: true } }); // the late answer changes nothing here + f.log.length = 0; + let signedOut = false; + const out = f.flow.signOut().then(() => { signedOut = true; }); // the page's Disconnect, step one + assert.strictEqual((await done).ok, false); await settle(); - assert.deepStrictEqual(f.calls, [TOKEN]); + assert.strictEqual(signedOut, false); // still waiting for the connect + assert.deepStrictEqual(f.cancels, []); + release({ status: 200, data: { connected: true } }); // it saved, just now + await out; + // Only now would the page post its disconnect: the cancel (which deletes what that + // connect saved) has reached the server, and the session is cleared. + assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]); + assert.deepStrictEqual(f.cancels, [f.attempts[0]]); }); -test("a timeout drops the attempt too", async () => { - const f = fakes({ timeout: 50 }); - f.setAnswer(() => new Promise(() => {})); // Meta never answers +test("a later disconnect still waits for a closed window's connect", async () => { + const f = fakes(); + let release; + f.setAnswer(() => new Promise((r) => { release = r; })); const done = f.flow.signIn(); f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN }); - const r = await done; - assert.match(r.error, /timed out/); - assert.ok(f.windows[0].isDestroyed()); + await settle(); + f.windows[0].close(); + await done; + let signedOut = false; + const out = f.flow.signOut().then(() => { signedOut = true; }); + await settle(); + assert.strictEqual(signedOut, false); + release({ status: 400, data: { error: "no" } }); + await out; assert.deepStrictEqual(f.cancels, [f.attempts[0]]); }); @@ -425,6 +487,7 @@ test("each sign-in has its own attempt id", async () => { await settle(); f.windows[1].close(); await b; + await settle(); assert.strictEqual(f.attempts.length, 2); assert.notStrictEqual(f.attempts[0], f.attempts[1]); assert.deepStrictEqual(f.cancels, f.attempts); diff --git a/docs/meta-library.md b/docs/meta-library.md index 6458de5..30fa2f0 100644 --- a/docs/meta-library.md +++ b/docs/meta-library.md @@ -61,7 +61,10 @@ closes the window. The window: - when it is closed, times out or is signed out of, stops reading cookies first, then is destroyed (a page's "leave this page?" can't keep it open), and, if it had sent a token, tells the server to drop that sign-in: each - sign-in has a random attempt id. The server lists an attempt only while its + sign-in has a random attempt id. The cancel is sent only once the connect + under way has finished (saved, refused or failed), so the server always + gets it after the connect; with nothing sent, there is nothing to cancel. + The server lists an attempt only while its connect is being checked: cancelled then, it isn't saved; cancelled just after its token was saved, that token is deleted (as a disconnect). A cancel never removes an account a later sign-in or a pasted token saved, and one @@ -253,8 +256,9 @@ turn that on. - Disconnect deletes the file and the library Frame Control holds in memory, and stops a download under way. A connect still being checked when you disconnect is not saved. In the desktop app, before the token is deleted, it - also stops any sign-in under way (cancelling its attempt), destroys the - sign-in window, and only then clears the `persist:meta-signin` session + also stops any sign-in under way, destroys the sign-in window, waits for a + connect still under way to finish and cancels its attempt (deleting its + token if it was just saved), and only then clears the `persist:meta-signin` session (cookies, storage, cache); no other session is touched. - With Sign in with Meta, a second copy of your Meta sign-in lives in that session's storage, in Electron's per-user data folder (`Partitions/meta-signin`), @@ -325,7 +329,10 @@ sent once, a refused one keeping the window open; the window closing after a connect, with the page getting the answer and never the token; nothing logged, and a server error that echoed the token scrubbed; closing the window or signing out ending the wait; closing, timing out or signing out during a -connect cancelling that attempt (with its own id) after cookie intake stops; +connect cancelling that attempt (with its own id) after cookie intake stops +and only after that connect settles (saved, refused or failed), and no cancel +when nothing was sent; sign-out (Disconnect's first step) waiting for a +connect under way, even from a window closed earlier, before it finishes; sign-out destroying the window before clearing only that partition. The frame redirects held to the frame rule, the window's to Meta's sites. The server side (`tests/test_meta.py`): an attempt cancelled while Meta is From c7125bc3de80dc6b2416e34570c1ee3441bddf33 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:10:59 +1100 Subject: [PATCH 101/113] My Meta library: Sign in with Meta closes two sign-out races - cancel_signin marks an in-flight attempt and, independently, deletes the account if that attempt saved it, so a cancel landing after the save but before the connect's cleanup still removes the token. - Sign-out runs one at a time (a second call joins it) and refuses new sign-ins while it runs; it goes round until no window, connect or cancel is left. The page keeps Sign in with Meta off from pressing Disconnect until the server has deleted the token. Co-Authored-By: Claude Opus 5.5 --- app/meta-signin.js | 46 ++++++++++++++++++--------- app/test/meta-signin.test.js | 60 ++++++++++++++++++++++++++++++++++++ docs/meta-library.md | 12 ++++++-- tests/test_meta.py | 38 +++++++++++++++++++++++ ui/frame_meta.py | 4 ++- ui/index.html | 24 +++++++++------ 6 files changed, 156 insertions(+), 28 deletions(-) diff --git a/app/meta-signin.js b/app/meta-signin.js index a4cf0ee..b4205ae 100644 --- a/app/meta-signin.js +++ b/app/meta-signin.js @@ -66,12 +66,15 @@ function scrub(text, token) { function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent = () => null, timeout = TIMEOUT }) { let active = null; // { win, promise, finish } const pending = new Set(); // cancels still to reach the server, from flows already finished + let signingOut = null; // the sign-out under way; no sign-in starts while it runs + const SIGNING_OUT = "Frame Control is signing out of Meta; try again in a moment."; const openOutside = (target) => { if (/^https?:\/\//i.test(String(target))) shell.openExternal(String(target)); }; function signIn() { + if (signingOut) return Promise.resolve({ ok: false, error: SIGNING_OUT }); if (active) { active.win.focus(); return active.promise; } const ses = session.fromPartition(PARTITION); ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)); @@ -201,24 +204,37 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare // Signs out of Meta in Frame Control: stops any sign-in under way (and has the server // refuse to save it), destroys the window, then clears everything // the sign-in session stored (cookies, storage, cache), so the next sign-in starts fresh. - async function signOut() { - if (active) { - const { win, finish } = active; - await finish({ ok: false, cancelled: true, error: "Signed out of Meta in Frame Control." }); - // Clear only once nothing can write to the session again. - if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again."); + // One at a time (a second call joins the first), and no sign-in can start meanwhile. + // It goes round until no window is open and no connect or cancel is left, so nothing + // started before it can set a token after it (or after the Disconnect that follows). + function signOut() { + if (!signingOut) { + signingOut = (async () => { + for (;;) { + if (active) { + const { win, finish } = active; + await finish({ ok: false, cancelled: true, error: "Signed out of Meta in Frame Control." }); + // Clear only once nothing can write to the session again. + if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again."); + } else if (pending.size) { + // A connect under way settles and is cancelled before the caller (Disconnect) + // deletes the account, so it can't be saved afterwards. + await Promise.all([...pending]); + } else { + break; + } + } + const ses = session.fromPartition(PARTITION); + await ses.clearStorageData(); + await ses.clearCache(); + if (ses.clearAuthCache) await ses.clearAuthCache(); + return true; + })().finally(() => { signingOut = null; }); } - // Any connect still under way settles and is cancelled before the caller (Disconnect) - // deletes the account, so it can't be saved afterwards. - await Promise.all([...pending]); - const ses = session.fromPartition(PARTITION); - await ses.clearStorageData(); - await ses.clearCache(); - if (ses.clearAuthCache) await ses.clearAuthCache(); - return true; + return signingOut; } - return { signIn, signOut, isOpen: () => !!active }; + return { signIn, signOut, isOpen: () => !!active, isSigningOut: () => !!signingOut }; } module.exports = { createMetaSignIn, allowedUrl, allowedSubframeUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE, ALLOWED }; diff --git a/app/test/meta-signin.test.js b/app/test/meta-signin.test.js index 2213f62..10a522c 100644 --- a/app/test/meta-signin.test.js +++ b/app/test/meta-signin.test.js @@ -492,3 +492,63 @@ test("each sign-in has its own attempt id", async () => { assert.notStrictEqual(f.attempts[0], f.attempts[1]); assert.deepStrictEqual(f.cancels, f.attempts); }); + +test("no sign-in can start while a sign-out waits on an earlier connect", async () => { + const f = fakes(); + let release; + f.setAnswer(() => new Promise((r) => { release = r; })); + const first = f.flow.signIn(); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN }); + await settle(); + const out = f.flow.signOut(); // Disconnect pressed while that connect is under way + assert.strictEqual((await first).ok, false); + assert.ok(f.flow.isSigningOut()); + // The page answered, so the user tries again at once: refused, no window, nothing sent. + const second = await f.flow.signIn(); + assert.deepStrictEqual(second, { ok: false, error: "Frame Control is signing out of Meta; try again in a moment." }); + assert.strictEqual(f.windows.length, 1); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER }); + await settle(); + assert.deepStrictEqual(f.calls, [TOKEN]); + // A second sign-out joins the first. + assert.strictEqual(f.flow.signOut(), out); + release({ status: 200, data: { connected: true } }); + await out; + assert.deepStrictEqual(f.cancels, [f.attempts[0]]); + assert.strictEqual(f.meta().cleared.filter(([k]) => k === "storage").length, 1); + assert.ok(!f.flow.isSigningOut()); + // Once it is done, signing in works again. + f.setAnswer(() => ({ status: 200, data: { connected: true } })); + const third = f.flow.signIn(); + assert.strictEqual(f.windows.length, 2); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER }); + assert.strictEqual((await third).ok, true); +}); + +test("sign-out goes round until no window and no connect or cancel is left", async () => { + const f = fakes(); + let releaseA; + f.setAnswer(() => new Promise((r) => { releaseA = r; })); + const a = f.flow.signIn(); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN }); + await settle(); + f.windows[0].close(); // window A closed; its cancel waits on its connect + await a; + // Window B is open, with its own connect under way, when sign-out starts. + let releaseB; + f.setAnswer(() => new Promise((r) => { releaseB = r; })); + const b = f.flow.signIn(); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER }); + await settle(); + let finished = false; + const out = f.flow.signOut().then(() => { finished = true; }); + await b; + releaseB({ status: 400, data: { error: "no" } }); + await settle(); + assert.strictEqual(finished, false); // A's connect is still under way + releaseA({ status: 200, data: { connected: true } }); + await out; + assert.deepStrictEqual([...f.cancels].sort(), [...f.attempts].sort()); + assert.strictEqual(f.attempts.length, 2); + assert.strictEqual(f.log[f.log.length - 1], "clear"); +}); diff --git a/docs/meta-library.md b/docs/meta-library.md index 30fa2f0..cc3f543 100644 --- a/docs/meta-library.md +++ b/docs/meta-library.md @@ -66,7 +66,8 @@ closes the window. The window: gets it after the connect; with nothing sent, there is nothing to cancel. The server lists an attempt only while its connect is being checked: cancelled then, it isn't saved; cancelled just - after its token was saved, that token is deleted (as a disconnect). A cancel + after its token was saved (even before the connect has finished tidying + up), that token is deleted (as a disconnect). A cancel never removes an account a later sign-in or a pasted token saved, and one for an unknown attempt changes nothing. @@ -259,7 +260,9 @@ turn that on. also stops any sign-in under way, destroys the sign-in window, waits for a connect still under way to finish and cancels its attempt (deleting its token if it was just saved), and only then clears the `persist:meta-signin` session - (cookies, storage, cache); no other session is touched. + (cookies, storage, cache); no other session is touched. Sign in with Meta + stays off from the moment you press Disconnect until the token is deleted, + and the app refuses a sign-in while it is signing out. - With Sign in with Meta, a second copy of your Meta sign-in lives in that session's storage, in Electron's per-user data folder (`Partitions/meta-signin`), until you disconnect or sign out of Meta in Frame Control. @@ -333,7 +336,10 @@ connect cancelling that attempt (with its own id) after cookie intake stops and only after that connect settles (saved, refused or failed), and no cancel when nothing was sent; sign-out (Disconnect's first step) waiting for a connect under way, even from a window closed earlier, before it finishes; -sign-out destroying the window before clearing only that partition. The +sign-out destroying the window before clearing only that partition; no +sign-in starting while a sign-out runs (it is refused: "try again in a +moment"), a second sign-out joining the first, and a sign-out waiting for +every window and connect started before it. The frame redirects held to the frame rule, the window's to Meta's sites. The server side (`tests/test_meta.py`): an attempt cancelled while Meta is answering not saved, even after 300 other cancels; a cancel just after the diff --git a/tests/test_meta.py b/tests/test_meta.py index 3a2cf07..9caf106 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -256,6 +256,44 @@ class SignInAttempts(unittest.TestCase): self.assertFalse(meta.cancel_signin('attempt-saved-0005')['connected']) self.assertFalse(meta.account_path().exists()) + def test_a_cancel_between_the_save_and_the_cleanup_removes_that_token(self): + # The connect has saved and let go of the lock, but hasn't yet dropped its attempt + # from the in-flight list when the cancel arrives. + FakeMeta(self) + real_status = meta.status + seen = {} + + def cancel_in_the_gap(): + # status() runs after the save; put the attempt back as still listed, as it is + # in the moment before connect's cleanup, and cancel there. + if 'done' not in seen and meta._saved_by[0] == 'attempt-gap-0011': + seen['done'] = True + with meta._lock: + meta._attempts['attempt-gap-0011'] = {'cancelled': False, 'running': 1} + meta.cancel_signin('attempt-gap-0011') + seen['listed'] = 'attempt-gap-0011' in meta._attempts + with meta._lock: + del meta._attempts['attempt-gap-0011'] + return real_status() + with patch.object(meta, 'status', side_effect=cancel_in_the_gap): + meta.connect(TOKEN, attempt='attempt-gap-0011') + self.assertTrue(seen.get('listed')) + self.assertFalse(meta.account_path().exists()) + self.assertFalse(meta.status()['connected']) + + def test_a_cancel_while_still_listed_marks_and_never_touches_another_account(self): + FakeMeta(self) + meta.connect(TOKEN) # a pasted token owns the account + with meta._lock: + meta._attempts['attempt-listed-0012'] = {'cancelled': False, 'running': 1} + try: + meta.cancel_signin('attempt-listed-0012') + self.assertTrue(meta._attempts['attempt-listed-0012']['cancelled']) + finally: + with meta._lock: + meta._attempts.pop('attempt-listed-0012', None) + self.assertTrue(meta.status()['connected']) + def test_a_cancel_never_removes_a_later_account(self): FakeMeta(self) meta.connect(TOKEN, attempt='attempt-first-0006') diff --git a/ui/frame_meta.py b/ui/frame_meta.py index 894e8ae..fecd174 100644 --- a/ui/frame_meta.py +++ b/ui/frame_meta.py @@ -348,9 +348,11 @@ def cancel_signin(attempt): raise MetaError('Invalid sign-in attempt id.') _check_attempt(attempt) with _lock: + # Both, independently: a connect that has just saved may still be listed until + # its cleanup runs, and its token must go all the same. if attempt in _attempts: _attempts[attempt]['cancelled'] = True - elif _saved_by[0] == attempt: + if _saved_by[0] == attempt: _forget_locked() return status() diff --git a/ui/index.html b/ui/index.html index 2afb885..35d1968 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3492,11 +3492,13 @@ async function loadMeta(refresh = false) { // Sign in with Meta: the desktop app only (app/meta-signin.js); a plain browser pastes the token. const metaSignInOk = !!(window.frameApp && window.frameApp.meta && window.frameApp.meta.signIn); $("metaSignInRow").hidden = !metaSignInOk; +let metaDisconnecting = false; // from pressing Disconnect until the server has deleted the token $("metaAck").onchange = e => { - $("metaToken").disabled = $("metaConnect").disabled = $("metaSignIn").disabled = !e.target.checked; + $("metaToken").disabled = $("metaConnect").disabled = !e.target.checked; + $("metaSignIn").disabled = !e.target.checked || metaDisconnecting; }; $("metaSignIn").onclick = async () => { - if (!$("metaAck").checked) return; + if (!$("metaAck").checked || metaDisconnecting) return; $("metaSignIn").disabled = true; $("metaState").textContent = "Sign in to Meta in the window that opened…"; try { @@ -3504,7 +3506,7 @@ $("metaSignIn").onclick = async () => { if (r && r.ok) { toast(r.message || "Connected"); loadMeta(); } else { await loadMeta(); $("metaState").textContent = (r && r.error) || "Sign-in didn't finish."; } } catch (e) { $("metaState").textContent = e.message; } - finally { $("metaSignIn").disabled = !$("metaAck").checked; } + finally { $("metaSignIn").disabled = !$("metaAck").checked || metaDisconnecting; } }; $("metaSignOut").onclick = async () => { try { await window.frameApp.meta.signOut(); toast("Signed out of Meta in Frame Control"); } @@ -3524,12 +3526,16 @@ $("metaConnect").onclick = async () => { $("metaDisconnect").onclick = async () => { if (!confirm("Disconnect your Meta account? Its token is deleted from this computer. Games already installed stay." + (metaSignInOk ? "\n\nThis also signs out of Meta in Frame Control (its Sign in with Meta window), so the next sign-in starts fresh." : ""))) return; - // Sign-out first: it stops the sign-in window taking a new token before the account is forgotten. - if (metaSignInOk) { - try { await window.frameApp.meta.signOut(); } catch (e) { toast(`Couldn't sign out of Meta in Frame Control: ${e.message}`, true); } - } - try { const d = await api("/api/meta", { action: "disconnect" }); toast(d.message || "Disconnected"); } - catch (e) { toast(e.message, true); } + // No sign-in from here until the token is deleted. Sign-out first: it stops the sign-in + // window (and any connect it started) before the account is forgotten. + metaDisconnecting = true; $("metaSignIn").disabled = true; + try { + if (metaSignInOk) { + try { await window.frameApp.meta.signOut(); } catch (e) { toast(`Couldn't sign out of Meta in Frame Control: ${e.message}`, true); } + } + try { const d = await api("/api/meta", { action: "disconnect" }); toast(d.message || "Disconnected"); } + catch (e) { toast(e.message, true); } + } finally { metaDisconnecting = false; } $("metaAck").checked = false; $("metaToken").disabled = $("metaConnect").disabled = $("metaSignIn").disabled = true; metaLib.results.clear(); loadMeta(); }; From c060df4b5066234233990125b299f18a1054224d Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:17:39 +1100 Subject: [PATCH 102/113] My Meta library: one Disconnect at a time, guarded from the confirm until the delete returns Co-Authored-By: Claude Opus 5.5 --- ui/index.html | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/index.html b/ui/index.html index 35d1968..85db1ee 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3524,18 +3524,24 @@ $("metaConnect").onclick = async () => { finally { $("metaConnect").disabled = !$("metaAck").checked; } }; $("metaDisconnect").onclick = async () => { + // One Disconnect at a time, guarded from before the confirm until the delete returns. + if (metaDisconnecting) return; + metaDisconnecting = true; $("metaDisconnect").disabled = $("metaSignIn").disabled = true; if (!confirm("Disconnect your Meta account? Its token is deleted from this computer. Games already installed stay." - + (metaSignInOk ? "\n\nThis also signs out of Meta in Frame Control (its Sign in with Meta window), so the next sign-in starts fresh." : ""))) return; + + (metaSignInOk ? "\n\nThis also signs out of Meta in Frame Control (its Sign in with Meta window), so the next sign-in starts fresh." : ""))) { + metaDisconnecting = false; $("metaDisconnect").disabled = false; + $("metaSignIn").disabled = !$("metaAck").checked; + return; + } // No sign-in from here until the token is deleted. Sign-out first: it stops the sign-in // window (and any connect it started) before the account is forgotten. - metaDisconnecting = true; $("metaSignIn").disabled = true; try { if (metaSignInOk) { try { await window.frameApp.meta.signOut(); } catch (e) { toast(`Couldn't sign out of Meta in Frame Control: ${e.message}`, true); } } try { const d = await api("/api/meta", { action: "disconnect" }); toast(d.message || "Disconnected"); } catch (e) { toast(e.message, true); } - } finally { metaDisconnecting = false; } + } finally { metaDisconnecting = false; $("metaDisconnect").disabled = false; } $("metaAck").checked = false; $("metaToken").disabled = $("metaConnect").disabled = $("metaSignIn").disabled = true; metaLib.results.clear(); loadMeta(); }; From 5db963157159249f115527ad2287886d331209aa Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:41:19 +1100 Subject: [PATCH 103/113] Usage statistics: send the time zone, and show the notice again when it lists something new Opens, installs and updates carry the computer's time zone name (from Electron, which knows it on every OS), so we can see roughly which countries Frame Control is used in without ever seeing an address. The first-run notice now has a version. Copies that saw the older notice see it again, and keep sending their usual statistics, but leave the time zone out until they've seen the new one. Co-Authored-By: Claude Opus 5.5 --- app/main.js | 5 +++-- docs/privacy.md | 13 ++++++++---- tests/test_telemetry.py | 35 ++++++++++++++++++++++++++++++++ ui/frame_telemetry.py | 45 ++++++++++++++++++++++++++++++++--------- ui/index.html | 4 ++-- 5 files changed, 84 insertions(+), 18 deletions(-) diff --git a/app/main.js b/app/main.js index 7d77675..84e88d4 100644 --- a/app/main.js +++ b/app/main.js @@ -115,10 +115,11 @@ function ping(target) { } async function startServer() { - // The version and whether this is a built app go to ui/frame_telemetry.py, which - // sends nothing from a source checkout. + // The version, the time zone and whether this is a built app go to ui/frame_telemetry.py, + // which sends nothing from a source checkout. const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1", FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG, + FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "", ...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}), ...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) }; python = await findPython(env); diff --git a/docs/privacy.md b/docs/privacy.md index 8bf434d..b7c5e0f 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -33,7 +33,12 @@ computer, exactly as they were sent. yours. - Every event includes the app version, OS name (macOS, Windows or Linux), CPU architecture and Python version. -- `frame_connected` carries `headset_id`: the first 16 hex digits of a +- `app_installed`, `app_updated` and `app_opened` carry `timezone`: your + computer's time zone setting, such as `Europe/Berlin`, so we can see roughly + which countries Frame Control is used in without knowing your address. + Copies that showed the first-run notice before it mentioned time zones + (before 0.4.6) show it again, and don't send the time zone until it has been + seen.- `frame_connected` carries `headset_id`: the first 16 hex digits of a SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset, so the machine-id itself never leaves it. It is the same for one headset on every computer, which lets us count headsets rather than installs; it can't @@ -44,9 +49,9 @@ computer, exactly as they were sent. | Event | When | Properties besides the common ones | |---|---|---| -| `app_installed` | First run | | -| `app_updated` | First run of a new version | `from_version` | -| `app_opened` | At most once a day | | +| `app_installed` | First run | `timezone` | +| `app_updated` | First run of a new version | `from_version`, `timezone` | +| `app_opened` | At most once a day | `timezone` | | `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` | | `tab_viewed` | The first click on each tab in a session | `tab` | | `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below | diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 64bf976..42938e2 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -159,6 +159,34 @@ class Lifecycle(Base): e = self.queued()[-1] self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9")) + def test_opens_installs_and_updates_carry_the_time_zone(self): + with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}): + tm.app_started() + with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}): + tm.app_started() + self.assertEqual([(e["event"], e["properties"].get("timezone")) for e in self.queued()], + [("app_installed", "Europe/Berlin"), ("app_opened", "Europe/Berlin"), + ("app_updated", "Europe/Berlin")]) + + def test_time_zone_names_only(self): + for tz, want in (("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"), ("Etc/GMT+10", "Etc/GMT+10"), + ("UTC", "UTC"), ("../etc/passwd", None), ("/home/alex/tz", None), ("A/" + "b" * 40, None)): + with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": tz}): + self.assertEqual(tm.timezone(), want, tz) + + def test_an_older_notice_is_shown_again_and_holds_back_the_time_zone(self): + tm.SETTINGS.write_text(json.dumps({"id": "x", "notice_shown": True, "installed_sent": True, + "last_version": "9.9.9"})) # as 0.4.5 saved it + self.assertFalse(tm.state()["noticeShown"]) + with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}): + tm.app_started() + self.assertEqual([(e["event"], e["properties"].get("timezone")) for e in self.queued()], [("app_opened", None)]) + tm.update_settings({"noticeShown": True}) + self.assertTrue(tm.state()["noticeShown"]) + tm._save({**tm.settings(), "last_open_day": None}) # the next day + tm.app_started() + self.assertEqual(self.queued()[-1]["properties"]["timezone"], "Europe/Berlin") + def test_frame_build_once(self): tm.frame_seen("20260922.1", "3.8") tm.frame_seen("20260922.1", "3.8") @@ -230,6 +258,13 @@ class Sending(Base): self.assertEqual(self.queued(), []) self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"]) + def test_an_older_notice_still_sends(self): + got = self.serve() + tm._save({**tm._defaults(), "notice_shown": True, "notice_version": 1}) + tm.capture("app_opened") + self.assertEqual(tm.flush(), 1) + self.assertEqual(len(got), 1) + def test_a_failed_send_keeps_the_events(self): self.serve(status=500) tm.update_settings({"noticeShown": True}) diff --git a/ui/frame_telemetry.py b/ui/frame_telemetry.py index faa115d..8a8dd62 100644 --- a/ui/frame_telemetry.py +++ b/ui/frame_telemetry.py @@ -18,7 +18,9 @@ Report a problem dialog (frame_report.py) sends bug reports privately to the same project whatever is chosen here. Events are identified by a random id made on first run, not by the person or -computer, and sent without person profiles or GeoIP. Nothing is sent without a +computer, and sent without person profiles or GeoIP. Opens, installs and updates +carry the computer's time zone setting (e.g. Europe/Berlin) instead, to see +roughly where Frame Control is used. Nothing is sent without a project key (ui/telemetry.json or $FRAME_CONTROL_POSTHOG_KEY), from a source checkout unless $FRAME_CONTROL_TELEMETRY=1, or when $DO_NOT_TRACK=1 or $FRAME_CONTROL_TELEMETRY=0. @@ -65,6 +67,7 @@ EXPECTED_CATEGORIES = ('frame_unreachable', 'frame_not_set_up', 'frame_auth') DEFAULT_HOST = 'https://us.i.posthog.com' LEVELS = ('usage', 'compat', 'diagnostics') +NOTICE_VERSION = 2 # raise when the notice lists something new: it's shown again (2: time zone) # Events the page may send through /api/telemetry, and the properties each may carry. PAGE_EVENTS = {'tab_viewed': {'tab'}, 'update_offered': {'to_version'}, 'update_started': {'to_version'}, 'update_failed': {'to_version', 'error_category'}} @@ -105,7 +108,7 @@ def blocked(): def _defaults(): return {'id': str(uuid.uuid4()), 'usage': True, 'compat': False, 'diagnostics': False, - 'notice_shown': False, 'installed_sent': False, 'last_version': None, 'last_open_day': None, + 'notice_shown': False, 'notice_version': 0, 'installed_sent': False, 'last_version': None, 'last_open_day': None, 'frames_seen': [], 'compat_sent': []} @@ -147,12 +150,12 @@ def update_settings(changes): under way finishes first, so nothing leaves after this returns.""" with _send_lock, _lock: s = settings() - if 'noticeShown' in changes: - s['notice_shown'] = bool(changes['noticeShown']) or s['notice_shown'] + if changes.get('noticeShown'): + s['notice_shown'], s['notice_version'] = True, NOTICE_VERSION for level in LEVELS: if level in changes: s[level] = bool(changes[level]) - s['notice_shown'] = True + s['notice_shown'], s['notice_version'] = True, NOTICE_VERSION _save(s) _drop_unwanted(s) if changes.get('compat'): @@ -161,10 +164,16 @@ def update_settings(changes): return state() +def _notice_current(s): + """Whether the notice this person saw lists everything that's sent now. If not, the page + shows it again, and what it added waits until it has been seen.""" + return s['notice_shown'] and s['notice_version'] >= NOTICE_VERSION + + def state(): """What the page shows: the choices, why sending is blocked, and what was sent.""" s = settings() - return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': s['notice_shown'], + return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': _notice_current(s), 'diagnostics': s['diagnostics'], 'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)), 'sent': list(reversed(_read_lines(SENT)))[:50]} @@ -372,20 +381,36 @@ def page_event(body): return {'queued': capture(name, props)} +def timezone(): + """The computer's time zone name (e.g. Europe/Berlin), or None. The app passes it from + Electron, which knows it on every OS; from a checkout it's read from /etc/localtime.""" + tz = os.environ.get('FRAME_CONTROL_TIMEZONE') or os.environ.get('TZ', '').lstrip(':') + if not tz: + try: + tz = os.path.realpath('/etc/localtime').split('zoneinfo/', 1)[1] + except (OSError, IndexError): + return None + return tz if len(tz) <= 40 and re.fullmatch(r'[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+){0,2}', tz) else None + + def app_started(): - """Once per server start: first install, an update, and one open a day.""" + """Once per server start: first install, an update, and one open a day. They carry the time + zone, unless the notice this person saw came before it was sent; a first run's events wait + for the current notice anyway (flush).""" if blocked(): return with _lock: s = settings() version, today = app_version(), time.strftime('%Y-%m-%d') + tz = timezone() if _notice_current(s) or not s['notice_shown'] else None + where = {'timezone': tz} if tz else {} if not s['installed_sent']: - capture('app_installed') + capture('app_installed', where) s['installed_sent'] = True elif s['last_version'] and s['last_version'] != version: - capture('app_updated', {'from_version': s['last_version']}) + capture('app_updated', {'from_version': s['last_version'], **where}) if s['last_open_day'] != today: - capture('app_opened') + capture('app_opened', where) s['last_open_day'] = today s['last_version'] = version _save(s) diff --git a/ui/index.html b/ui/index.html index dd191fb..bad2773 100644 --- a/ui/index.html +++ b/ui/index.html @@ -683,7 +683,7 @@