Compare commits

...
1 Commits
Author SHA1 Message Date
SAPHIDandClaude Opus 5.5 1e1541e36e Offer the mailing list once on first launch
Alex wants new installs offered a way to hear about new features. The
existing contact email already records an "updates" opt-in privately in
PostHog (frame_contact.py, listed by `frame_report.py contacts updates`),
so this builds on it rather than adding a provider:

- The one-time prompt no longer waits for the Frame to connect: it shows
  on first launch, after the first-run privacy notice is closed (never
  both on screen). No thanks, or just seeing it, is remembered for good.
- It asks only for an email: "Want to hear about new features? Join the
  Frame Control mailing list. Optional, unsubscribe any time." with an
  empty field, Join and No thanks. Join sets the mailing list choice and
  keeps any follow-up permission already given for that address.
- Privacy & updates calls the choice "Join the mailing list"; the desktop
  app's Help menu gains Join the Mailing List… which opens it.
- docs/privacy.md, README and the website's privacy page say so. The
  website page also no longer claims the app has no analytics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:21:27 +00:00
8 changed files with 80 additions and 47 deletions

No files matched your search

+3 -2
View File
@@ -118,8 +118,9 @@ a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/ipho
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
From 0.4 it updates itself: when a new version is published, a banner offers
**Update and restart**. It sends anonymous usage statistics, which you can turn
off. Sharing compatibility results and error details is opt-in. See
[docs/privacy.md](docs/privacy.md).
off. Sharing compatibility results and error details is opt-in, and so is the
mailing list it offers once on first launch (just an email address, **No
thanks** to skip). See [docs/privacy.md](docs/privacy.md).
Google doesn't publish `adb` for arm64 Linux, so that build uses your
distribution's. If you already have `adb`, the app uses yours.
+3
View File
@@ -602,6 +602,9 @@ function buildMenu() {
if (win && url && linkPage === win.webContents) win.webContents.send("report:open");
else shell.openExternal("https://frame-control.pages.dev/feedback/"); // the page isn't up
} },
{ label: "Join the Mailing List…", click: () => {
if (win && url && linkPage === win.webContents) win.webContents.send("mailing:open");
} },
{ label: "Release Notes", click: () => shell.openExternal(updater.RELEASES) },
{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") },
],
+5
View File
@@ -40,6 +40,11 @@ contextBridge.exposeInMainWorld("frameApp", {
ipcRenderer.removeAllListeners("report:open");
ipcRenderer.on("report:open", () => cb());
},
// Help → Join the Mailing List… opens the page's mailing list setting (ui/frame_contact.py).
onJoinMailingList: (cb) => {
ipcRenderer.removeAllListeners("mailing:open");
ipcRenderer.on("mailing:open", () => cb());
},
// Sign in with Meta (app/meta-signin.js): Meta's login page in its own window. The
// answer is the server's (connected, display name), never the token.
meta: {
+16 -8
View File
@@ -218,21 +218,27 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
API key as `frame_compat_db.py sync`.
## Contact email (optional)
## Mailing list and contact email (optional)
Frame Control never needs an email address. If you'd like to leave one, there
are two separate choices, both off until you tick them:
| Choice | What it's for |
|---|---|
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
| **Join the mailing list: new features and updates** | Occasional emails about new features and updates |
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
You're asked once, in a bar at the top of the page, after the Frame has
connected for the first time, and never in the same visit as the first-run
privacy notice. **No thanks** hides it for good, and it isn't
shown again even if you ignore it. **Contact email** in **Privacy & updates**
is where you add, change or remove the address and either choice at any time.
The first time Frame Control opens, a bar at the top of the page asks
"Want to hear about new features? Join the Frame Control mailing list." with
an empty email field, **Join** and **No thanks**. It only asks about the
mailing list; follow-up questions are a separate choice in **Privacy &
updates**. If the first-run privacy notice is showing, it waits until that's
closed, so the two never appear together. It doesn't need the Frame to be set
up. **No thanks** hides it for good, and it isn't shown again even if you
ignore it. **Mailing list and contact email** in **Privacy & updates** (also
**Help → Join the Mailing List…** in the desktop app) is where you join later,
or change or remove the address and either choice, at any time. Unticking the
mailing list is unsubscribing.
**What's sent, and where.** The address and the two choices go privately to
Frame Control's PostHog project, the same place as problem reports, as a
@@ -267,7 +273,9 @@ sooner, ask the maintainer (for example in a problem report).
Nothing sends email yet: this only records who agreed to what. The
maintainer lists the addresses with
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
personal API key as `inbox`.
personal API key as `inbox`; `contacts updates` is the mailing list. If those
addresses are ever moved to an email service to send the newsletter, this
page will name it first.
## Turning it all off
+3 -2
View File
@@ -38,10 +38,11 @@
<div class="head">
<span class="kicker">Privacy</span>
<h1>Privacy</h1>
<p>Short version: the app collects nothing, and the website keeps only what you choose to send.</p>
<p>Short version: the app sends anonymous usage statistics you can turn off, your email only if you join the mailing list, and the website keeps only what you choose to send.</p>
</div>
<div class="faq notes">
<section><h2>The app</h2><p>Frame Control talks only to your headset (over SSH on your network), to GitHub for releases, and to Steam and F-Droid for game and app listings. It has no analytics and no accounts.</p></section>
<section><h2>The app</h2><p>Frame Control talks only to your headset (over SSH on your network), to GitHub for releases, and to Steam and F-Droid for game and app listings. It has no accounts. It sends anonymous usage statistics, which you can turn off in Privacy &amp; updates; <a href="https://github.com/saphid/frame-control/blob/main/docs/privacy.md">privacy.md</a> lists exactly what.</p></section>
<section><h2>The mailing list</h2><p>The first time the app opens, it offers to add you to the Frame Control mailing list for news about new features and updates. It's optional: the email field starts empty, <b>No thanks</b> skips it for good, and the app works the same either way. Only your email address is sent, privately to the maintainer, and only if you join. Unsubscribe or remove the address any time in Privacy &amp; updates.</p></section>
<section><h2>The feedback form</h2><p>What you type becomes a public GitHub issue on <a href="https://github.com/saphid/frame-control/issues">saphid/frame-control</a>. To stop abuse, the form keeps a one-way hash of your IP address for about an hour to count submissions. The address itself isn't stored or published.</p></section>
<section><h2>This website</h2><p>Hosted on Cloudflare Pages. No cookies, no analytics, no trackers. The download section asks GitHub for the latest version number. Donations go through Ko-fi, under Ko-fi's own privacy policy.</p></section>
</div>
+16 -5
View File
@@ -228,10 +228,9 @@ class Contact(Base):
# ---- the one-time prompt
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
tm.frame_seen("20260901.1", "3.8")
self.assertTrue(fc.state()["showPrompt"])
def test_the_prompt_is_offered_on_first_launch_then_stays_dismissed(self):
self.assertTrue(fc.state()["showPrompt"]) # a new install, before the Frame has connected
self.assertEqual(self.got, []) # offering it sends nothing
fc.prompt({"prompt": "dismissed"})
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
self.assertEqual(fc.load()["prompt"], "dismissed")
@@ -241,12 +240,24 @@ class Contact(Base):
fc.prompt({"prompt": "reset"})
def test_the_prompt_is_shown_once_and_saving_answers_it(self):
tm.frame_seen("20260901.1", "3.8")
fc.prompt({"prompt": "shown"})
self.assertFalse(fc.state()["showPrompt"])
fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
self.assertEqual(fc.load()["prompt"], "answered")
def test_joining_from_the_prompt_is_the_mailing_list_opt_in_only(self):
fc.save({"email": " me@example.com ", "updates": True, "fromPrompt": True})
self.assertEqual((fc.load()["prompt"], fc.state()["showPrompt"]), ("answered", False))
props = self.events()[-1]["properties"]
self.assertEqual((props["email"], props["updates"], props["followup"], props["action"]),
("me@example.com", True, False, "set"))
def test_a_failed_send_never_blocks_joining(self):
with self.offline():
s = fc.save({"email": "me@example.com", "updates": True, "fromPrompt": True})
self.assertTrue(s["updates"] and s["waiting"]) # kept here, sent later by the retrier
self.assertFalse(s["showPrompt"])
# ---- reports and the maintainer's list
def reports(self):
+5 -5
View File
@@ -2,7 +2,7 @@
Two separate opt-in choices, both off until ticked:
- updates: occasional notices about Frame Control releases and updates
- updates: the mailing list, occasional emails about new features and updates
- followup: the maintainer may ask follow-up questions, mainly about problem reports
The address and the choices are kept on this computer (frame_host.data_dir('contact')) and
@@ -97,12 +97,12 @@ def from_report(email):
def state():
"""What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet,
and the Frame has connected at least once (setup worked), so it never greets a new install."""
"""What the page shows. showPrompt: the one-time mailing list offer hasn't been shown or
answered yet. It's offered on first launch (the page shows it after the first-run privacy
notice is closed, never both at once), and works whether or not the Frame is set up."""
s = load()
set_up = bool(frame_telemetry.settings().get('frames_seen'))
return {'email': s['email'], 'updates': s['updates'], 'followup': s['followup'],
'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new' and set_up}
'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new'}
def _event(s):
+29 -25
View File
@@ -704,15 +704,12 @@
<button class="action small" id="noticeOk">OK</button>
</div>
<form class="notice" id="contactNotice" hidden>
<div class="grow"><b>Leave an email address?</b> Optional: Frame Control works the same either way, and
you can change or remove it any time in Privacy &amp; updates.
<div><input type="email" id="cpEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address" required></div>
<div class="contact-opts">
<label><input type="checkbox" id="cpUpdates"> Email me about Frame Control updates</label>
<label><input type="checkbox" id="cpFollowup"> The maintainer may contact me with follow-up questions</label></div></div>
<div class="grow"><b>Want to hear about new features?</b> Join the Frame Control mailing list.
Optional, unsubscribe any time in Privacy &amp; updates.
<div><input type="email" id="cpEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address" required></div></div>
<span class="sub" id="cpMsg" role="status"></span>
<button type="button" class="small" id="cpNo">No thanks</button>
<button type="submit" class="action small" id="cpSave">Save</button>
<button type="submit" class="action small" id="cpSave">Join</button>
</form>
<div class="banner" id="offline" role="alert" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
@@ -1261,17 +1258,17 @@
<span class="sub">Error messages and where in Frame Control they happened, with your home
folder, user name, addresses and keys removed.</span></label>
<div class="hint" id="tStatus"></div>
<h3 style="margin-top:22px">Contact email (optional)</h3>
<h3 style="margin-top:22px" id="mailingList">Mailing list and contact email (optional)</h3>
<form id="contactForm">
<input type="email" id="cEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address">
<div class="contact-opts">
<label><input type="checkbox" id="cUpdates"> Email me about Frame Control updates</label>
<label><input type="checkbox" id="cUpdates"> Join the mailing list: new features and updates</label>
<label><input type="checkbox" id="cFollowup"> The maintainer may contact me with follow-up questions</label></div>
<div class="row" style="margin-top:10px"><button type="submit" class="small action" id="cSave">Save</button>
<button type="button" class="small" id="cRemove">Remove my email</button></div>
</form>
<div class="hint" id="cStatus">Sent privately to the Frame Control maintainer, never shared or published.
Updates are occasional release notices; follow-up questions are mainly about problem reports you send.</div>
The mailing list is occasional news about new features and updates; follow-up questions are mainly about problem reports you send.</div>
<details id="tSentBox"><summary>Show what's been sent</summary><div class="sentlog" id="tSent"></div></details>
<div class="row" style="margin-top:14px"><button class="small action" data-report>Report a problem</button>
<button class="small" id="updateCheck" hidden>Check for updates</button>
@@ -4301,7 +4298,7 @@ $("macview").onclick = async e => {
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
const PAGES = ["home", "games", "android", "tools"];
const SECTION_PAGE = { view: "home", device: "home", shots: "home", vrTools: "home", comfort: "home", pad: "home", library: "games", sideloaded: "games", getgames: "games",
display: "android", androidLibrary: "android", appStore: "android", transfer: "tools", media: "tools", artworkSettings: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
display: "android", androidLibrary: "android", appStore: "android", transfer: "tools", media: "tools", artworkSettings: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", mailingList: "tools", panels: "tools" };
let page = "home";
function showPage() {
const id = location.hash.slice(1);
@@ -4553,7 +4550,6 @@ async function offerTest(m) {
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
const telemetry = { usage: false, compat: false, blocked: "not loaded" };
let privacyNoticeShown = false; // this visit: then the contact prompt waits for another one
function renderTelemetry(s) {
Object.assign(telemetry, s);
setRepHint();
@@ -4564,7 +4560,6 @@ function renderTelemetry(s) {
: "Nothing sent yet.";
const showNotice = !s.blocked && !s.noticeShown && s.usage;
$("privacyNotice").hidden = !showNotice;
if (showNotice) privacyNoticeShown = true;
if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {});
}
async function loadTelemetry() {
@@ -4578,24 +4573,25 @@ $("tUsage").onchange = e => setTelemetry({ usage: e.target.checked });
$("tCompat").onchange = e => setTelemetry({ compat: e.target.checked });
$("tDiag").onchange = e => setTelemetry({ diagnostics: e.target.checked });
$("tSentBox").ontoggle = () => { if ($("tSentBox").open) loadTelemetry(); };
$("noticeOk").onclick = () => { $("privacyNotice").hidden = true; };
$("noticeOk").onclick = () => { $("privacyNotice").hidden = true; checkContactPrompt(); };
$("noticeMore").onclick = async () => {
$("privacyNotice").hidden = true;
checkContactPrompt();
await setTelemetry({ compat: true, diagnostics: true });
toast("Thanks! Compatibility results and error details will be shared too. Change it any time in Privacy.");
};
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; };
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; checkContactPrompt(); };
const telemetryLoaded = loadTelemetry();
// ---- contact email: two separate opt-ins (ui/frame_contact.py, docs/privacy.md) ----
// ---- mailing list and contact email: two separate opt-ins (ui/frame_contact.py, docs/privacy.md) ----
const contact = { email: "", updates: false, followup: false };
function renderContact(s) {
Object.assign(contact, s);
$("cEmail").value = s.email; $("cUpdates").checked = s.updates; $("cFollowup").checked = s.followup;
$("cRemove").hidden = !s.email;
$("cStatus").textContent = s.waiting ? "Saved here; it's sent to the maintainer when Frame Control can reach PostHog."
: s.email ? `Saved. ${s.email} may get ${[s.updates && "update notices", s.followup && "follow-up questions"].filter(Boolean).join(" and ")}. Remove it any time.`
: "Sent privately to the Frame Control maintainer, never shared or published. Updates are occasional release notices; follow-up questions are mainly about problem reports you send.";
: s.email ? `Saved. ${s.email} may get ${[s.updates && "mailing list emails", s.followup && "follow-up questions"].filter(Boolean).join(" and ")}. Remove it any time.`
: "Sent privately to the Frame Control maintainer, never shared or published. The mailing list is occasional news about new features and updates; follow-up questions are mainly about problem reports you send.";
}
async function saveContact(change) {
const s = await api("/api/contact", change);
@@ -4607,25 +4603,26 @@ async function loadContact() {
try { renderContact(await api("/api/contact")); } catch { return; }
checkContactPrompt();
}
// One time only, only once the Frame has connected, and never in a visit that showed the privacy
// notice (two asks in a row is nagging): checked at load and whenever the Frame connects.
// The mailing list offer: once, on first launch, and never on screen with the first-run privacy
// notice (it waits for that to be closed). Checked at load, when the notice closes and when the
// Frame connects (in case the server couldn't be asked earlier).
async function checkContactPrompt() {
await telemetryLoaded;
if (privacyNoticeShown || !$("contactNotice").hidden) return;
if (!$("privacyNotice").hidden || !$("contactNotice").hidden) return;
let s;
try { s = await api("/api/contact"); } catch { return; }
if (!s.showPrompt || privacyNoticeShown || !$("contactNotice").hidden) return;
if (!s.showPrompt || !$("privacyNotice").hidden || !$("contactNotice").hidden) return;
$("contactNotice").hidden = false;
api("/api/contact/prompt", { prompt: "shown" }).catch(() => {});
}
$("contactNotice").onsubmit = async e => {
e.preventDefault();
if (!$("cpUpdates").checked && !$("cpFollowup").checked) { $("cpMsg").textContent = "Tick at least one, or choose No thanks."; return; }
$("cpSave").disabled = true;
try {
await saveContact({ email: $("cpEmail").value, updates: $("cpUpdates").checked, followup: $("cpFollowup").checked, fromPrompt: true });
// Joining is the mailing list only; follow-up questions keep whatever was agreed before.
await saveContact({ email: $("cpEmail").value, updates: true, followup: contact.followup && contact.email.toLowerCase() === $("cpEmail").value.trim().toLowerCase(), fromPrompt: true });
$("contactNotice").hidden = true;
toast("Thanks! Change or remove it any time in Privacy & updates.");
toast("Thanks for joining! Unsubscribe any time in Privacy & updates.");
} catch (err) { $("cpMsg").textContent = `Couldn't save: ${err.message}`; }
finally { $("cpSave").disabled = false; }
};
@@ -4648,6 +4645,13 @@ $("cRemove").onclick = async () => {
catch (err) { toast(`Couldn't remove it: ${err.message}`, true); }
};
loadContact();
// Help → Join the Mailing List… (desktop app): the Privacy & updates section, where joining later lives.
function openMailingList() {
location.hash = "#mailingList";
showPage(); // also when the hash was already this, so hashchange doesn't fire
$("cEmail").focus({ preventScroll: true });
}
if (window.frameApp && window.frameApp.onJoinMailingList) window.frameApp.onJoinMailingList(openMailingList);
function pageEvent(event, properties) {
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
}