Compare commits

...
Author SHA1 Message Date
SAPHID e28e279d8d Store mailing-list opt-ins on Cloudflare Pages
Add a private D1 signup endpoint on the existing website, with capability-based updates and revision tombstones for safe retries and withdrawals. Send choices from the app background worker without waiting on either mailing storage or PostHog. Document provisioning, private export, and privacy behavior; cover the HTTP contract, SQLite ordering, offline removal, and real worker delivery.
2026-10-24 08:56:01 +00:00
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
Alex Southwell bcdd9d0830 Merge pull request #99: Queue Android and Quest installs
Queue Android and Quest installs; prepare Frame Control 0.4.7
2026-10-15 13:32:54 +11:00
17 changed files with 613 additions and 65 deletions

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ compat-db/.env.lakebed.server
compat-db/.lakebed/
tests/smoke/results/
mac/bin/
site/.wrangler/
site/.dev.vars
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
frame/kdeconnect/packages/
+5 -2
View File
@@ -118,8 +118,11 @@ 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). Signups go to the website’s private Cloudflare D1 list;
see [site/README.md](site/README.md#mailing-list) for backend setup and
[docs/privacy.md](docs/privacy.md) for what is sent.
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: {
+47 -11
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
@@ -264,10 +270,40 @@ this computer and is sent when PostHog can be reached. The earlier event
stays in PostHog until its data retention removes it; to have it deleted
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`.
**The Cloudflare mailing list.** When you explicitly join or change an existing
mailing-list choice, the app also sends that choice to
`https://framecontrol.fun/api/mailing-list`, on the website's Cloudflare Pages
project. This request contains only the email address, an explicit `updates`
boolean, a random capability used to change this copy's subscription, and a
revision number used to order retries. It contains no analytics id or machine
details. Follow-up-only choices and skipping the offer send nothing to this
endpoint. Existing saved choices aren't automatically copied to a new backend.
Cloudflare D1 privately stores the current address and revision under a hash of
the random capability, with a change timestamp. Changing the address replaces
the previous one. Unsubscribing sets the address to null; only the capability
hash, revision and timestamp remain, so a delayed older signup can't restore
it. Duplicate addresses from separate app copies are deduplicated when exported;
unsubscribing in the app withdraws this copy's subscription. The website reuses
its short-lived salted IP hashes to limit requests, without storing raw IPs in
the list. Cloudflare necessarily processes the connection's IP address.
The mailing request has its own durable local queue in `contact/contact.json`.
It retries in the background independently of PostHog, including after restarting.
Saving, skipping and continuing to use the app never wait for either service.
An offline removal clears the queued address immediately and leaves a withdrawal
to send when the endpoint is available. Until it arrives, the server still has
the previous choice. If Cloudflare storage hasn't been provisioned, the endpoint
returns 503 and the same queue retains the choice safely.
No newsletter sending service is configured and no confirmation or newsletter
emails are sent by this feature. Cloudflare is the private signup backend;
[site/README.md](../site/README.md#mailing-list) describes provisioning and a
maintainer-only export of current opt-ins. The existing
`python3 ui/frame_report.py contacts [updates|followup]` command still lists
PostHog consent records; use the Cloudflare export for the mailing list. A future
sending service must honor current withdrawals, provide an unsubscribe option
in every email, and be named here before addresses are transferred to it.
## Turning it all off
+84
View File
@@ -4,6 +4,7 @@ The Frame Control website, <https://frame-control.pages.dev>, on Cloudflare Page
- `public/`: static pages. `/` is the landing page, `/feedback/` the feedback form, `/privacy/` the privacy note.
- `functions/api/feedback.js`: `POST /api/feedback`, which turns the form into a GitHub issue labelled `feedback`.
- `functions/api/mailing-list.js`: `POST /api/mailing-list`, private mailing-list opt-ins in Cloudflare D1.
- `lib/feedback.js`: validation and issue formatting, tested by `test/feedback.test.mjs`.
- `public/js/site.js`: settings, including the Ko-fi page name for the donate buttons.
@@ -33,3 +34,86 @@ npx wrangler pages deploy --branch main # production
```
Point `GITHUB_REPO` in `.dev.vars` at a scratch repo when testing locally so test issues don't land on the real tracker.
## Mailing list
The app offers signup on first launch, after its privacy notice, because the
platform installers have no shared interactive UI. Joining later and removing
the address live in **Privacy & updates**. The email field is empty, **No thanks**
is permanent, and neither the prompt nor skipping contacts this endpoint.
The backend lives on the existing Cloudflare Pages project. It needs one private
D1 database, bound as `MAILING_LIST`. No new third-party account or API key is
needed. With an authenticated Wrangler session, provision it once:
```sh
cd site
npx wrangler login
npx wrangler d1 create frame-control-mailing-list --binding MAILING_LIST --update-config
npx wrangler d1 execute frame-control-mailing-list --remote --file migrations/mailing-list/0001_subscribers.sql
```
The create command adds the real database ID and binding to `wrangler.toml`.
Commit that non-secret configuration. If the database already exists, use its
ID from `npx wrangler d1 list` instead of creating another one. Apply the schema
above before deploying. Deploy this site's functions and privacy page with
`npx wrangler pages deploy --branch main`; the normal PR does not deploy them.
For a Pages preview, bind a separate test database rather than the production
list. Without the binding or schema, the endpoint returns 503 and the app keeps
the pending choice locally, rather than claiming it was delivered.
The contract is JSON `{token, rev, email, updates}`: `updates` must explicitly
be `true` with a valid email, or `false` with an empty email to withdraw. `token`
is a random 256-bit capability stored only in this copy's local contact file,
separate from analytics. Only its SHA-256 hash is stored on the server. Updates
use a conditional SQLite upsert so retries are idempotent and older requests
can't undo newer choices. Withdrawal clears the address, keeping a revision
tombstone. Only the current address is stored, never an address history.
Browser-origin requests are refused, bodies are limited to 2 KiB, responses
are never cached, and the site's existing `FEEDBACK_RL`/`GITHUB_TOKEN` bindings
provide a best-effort limit of 30 requests per IP per hour using salted hashes.
KV limits are eventually consistent, so use Cloudflare WAF limits if needed for
larger bursts. Database failures never log email addresses or capability tokens.
The small client integration point is `ui/frame_mailing.py:submit`, with
`FRAME_CONTROL_MAILING_LIST_URL` overriding the default
`https://framecontrol.fun/api/mailing-list`. Saving is immediate; the existing
contact worker retries mailing changes separately from PostHog every ten
minutes and after a new choice. A removal wipes any queued address immediately.
There is no app-wide secret in the client and no public list/export endpoint.
For a local test, after adding the binding, initialize only the local database:
```sh
npx wrangler d1 execute frame-control-mailing-list --local --file migrations/mailing-list/0001_subscribers.sql
npx wrangler pages dev --port 8788
```
Start the app with
`FRAME_CONTROL_MAILING_LIST_URL=http://127.0.0.1:8788/api/mailing-list` and a
disposable `FRAME_CONTROL_DATA_DIR`. Tests use temporary SQLite databases and
local HTTP servers, never the production list.
### Maintainer export
Only an authenticated Cloudflare account can read the list:
```sh
npx wrangler d1 execute frame-control-mailing-list --remote --json --command "SELECT DISTINCT lower(email) AS email FROM mailing_list WHERE email IS NOT NULL ORDER BY email"
```
Treat the output as private. Export current opt-ins immediately before sending;
do not use the older PostHog `contacts updates` output as the delivery list.
An app removal withdraws this copy's subscription; the same address explicitly
subscribed from another copy remains subscribed there. Addresses are unverified:
this collection endpoint sends no confirmation or newsletter messages. A future
sender should verify ownership before sending updates and include an unsubscribe
link in every message, apply withdrawals, and update the privacy note before
transferring addresses. Choosing or creating a sending service is not required
to collect signups on Cloudflare.
Cloudflare's own Email Service is not a newsletter sender: its
[FAQ](https://developers.cloudflare.com/email-service/reference/faq/#can-i-use-email-service-for-marketing-emails)
explicitly limits it to transactional email and excludes marketing email.
Use Pages/D1 for collecting this list; do not route feature-news campaigns
through that transactional service.
+76
View File
@@ -0,0 +1,76 @@
// Private signup storage on the existing Cloudflare Pages site. No email sender
// or third-party mailing account is needed to collect explicit opt-ins.
import { hashIp } from "../../lib/feedback.js";
export const UPDATE = `INSERT INTO mailing_list (subscriber_id, rev, email)
VALUES (?, ?, ?)
ON CONFLICT(subscriber_id) DO UPDATE SET rev = excluded.rev,
email = excluded.email, updated_at = CURRENT_TIMESTAMP
WHERE excluded.rev > mailing_list.rev`;
const json = (status, data) => new Response(JSON.stringify(data), {
status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
});
export async function onRequestPost({ request, env }) {
// This endpoint is called by the app's Python server, never a browser form.
// Reject cross-site browser submissions instead of enabling CORS.
if (request.headers.has("origin")) return json(403, { error: "Use Frame Control's mailing list setting." });
if (!env.MAILING_LIST) return json(503, { error: "Mailing list storage is not configured yet." });
if (!request.headers.get("content-type")?.startsWith("application/json")) {
return json(415, { error: "Send JSON." });
}
let input;
try {
const reader = request.body?.getReader();
if (!reader) return json(400, { error: "Send JSON." });
const chunks = [];
let length = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
length += value.length;
if (length > 2048) {
await reader.cancel();
return json(413, { error: "Request too large." });
}
chunks.push(value);
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
input = JSON.parse(new TextDecoder().decode(bytes));
} catch {
return json(400, { error: "Send JSON." });
}
if (!input || typeof input.token !== "string" || !/^[a-f0-9]{64}$/.test(input.token) ||
!Number.isSafeInteger(input.rev) || input.rev < 1 ||
typeof input.email !== "string" || typeof input.updates !== "boolean") {
return json(400, { error: "Invalid mailing list choice." });
}
const email = input.email.trim();
if ((input.updates && (!email || email.length > 254 || !/^[^@\s]+@[^@\s]+\.[^@\s.]+$/.test(email))) ||
(!input.updates && email)) {
return json(400, { error: "An email needs explicit opt-in; withdraw with an empty email." });
}
try {
// Reuse the site's existing rate-limit namespace and secret salt. Never
// store raw IP addresses, email addresses, or capabilities in rate-limit keys.
if (env.FEEDBACK_RL && env.GITHUB_TOKEN) {
const who = await hashIp(request.headers.get("cf-connecting-ip") || "unknown", env.GITHUB_TOKEN);
const key = `mailing:ip:${who}:${Math.floor(Date.now() / 3600e3)}`;
const count = Number(await env.FEEDBACK_RL.get(key)) || 0;
if (count >= 30) return json(429, { error: "Please try again later." });
await env.FEEDBACK_RL.put(key, String(count + 1), { expirationTtl: 3900 });
}
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input.token));
const id = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, "0")).join("");
await env.MAILING_LIST.prepare(UPDATE).bind(id, input.rev, email || null).run();
return json(200, { ok: true });
} catch {
// No address, token, request body, or storage error details in logs/responses.
return json(503, { error: "Couldn't save just now. Frame Control will retry." });
}
}
export const onRequest = () => json(405, { error: "POST only." });
@@ -0,0 +1,8 @@
-- One current choice per random app capability, never a history of addresses.
-- Keep a tombstone on withdrawal so an older retry cannot add the address back.
CREATE TABLE IF NOT EXISTS mailing_list (
subscriber_id TEXT PRIMARY KEY,
rev INTEGER NOT NULL CHECK (rev > 0),
email TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+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 if you join, your address and consent are sent privately to the maintainer’s PostHog project and to this website’s Cloudflare Pages endpoint. Cloudflare D1 stores the current mailing-list address with a random subscription identifier and revision; no analytics identifier or machine details go to the mailing-list endpoint. Removing it clears that address from the list when the app can reach the service. No newsletter sending service is configured yet. 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>
+101
View File
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { onRequestPost, onRequest } from "../functions/api/mailing-list.js";
const choice = (over = {}) => ({ token: "a".repeat(64), rev: 1, email: "me@example.com", updates: true, ...over });
const request = (body, headers = {}) => new Request("https://framecontrol.fun/api/mailing-list", {
method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body),
});
const call = (body, env = {}, headers) => onRequestPost({ request: request(body, headers), env });
// Execute the actual D1 SQLite statements and migration, rather than mirroring
// their conflict handling in a mock. No Cloudflare account or network needed.
function database(t) {
const dir = mkdtempSync(join(tmpdir(), "frame-mailing-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const file = join(dir, "list.sqlite");
const sql = (query, args = []) => {
const result = spawnSync("python3", ["-c", `
import json, sqlite3, sys
db = sqlite3.connect(sys.argv[1])
query, args = json.loads(sys.stdin.read())
rows = db.execute(query, args).fetchall()
db.commit()
print(json.dumps(rows))
`, file], { input: JSON.stringify([query, args]), encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
return JSON.parse(result.stdout);
};
sql(readFileSync(new URL("../migrations/mailing-list/0001_subscribers.sql", import.meta.url), "utf8"));
return { sql, prepare: query => ({ bind: (...args) => ({ run: async () => sql(query, args) }) }) };
}
test("stores one current choice; retries and delayed joins cannot undo a withdrawal", async t => {
const db = database(t), env = { MAILING_LIST: db };
assert.equal((await call(choice(), env)).status, 200);
assert.equal((await call(choice(), env)).status, 200);
assert.deepEqual(db.sql("SELECT rev, email FROM mailing_list"), [[1, "me@example.com"]]);
await call(choice({ rev: 3, email: "", updates: false }), env);
await call(choice({ rev: 2, email: "old@example.com" }), env);
assert.deepEqual(db.sql("SELECT rev, email FROM mailing_list"), [[3, null]]);
await call(choice({ rev: 4, email: "new@example.com" }), env);
assert.deepEqual(db.sql("SELECT rev, email FROM mailing_list"), [[4, "new@example.com"]]);
assert.notEqual(db.sql("SELECT subscriber_id FROM mailing_list")[0][0], choice().token);
});
test("each app capability only changes its own row", async t => {
const db = database(t), env = { MAILING_LIST: db };
await call(choice(), env);
await call(choice({ token: "b".repeat(64), email: "other@example.com" }), env);
await call(choice({ rev: 2, email: "", updates: false }), env);
assert.deepEqual(db.sql("SELECT email FROM mailing_list WHERE email IS NOT NULL"), [["other@example.com"]]);
});
test("requires a valid email and explicit boolean consent before storing anything", async () => {
let writes = 0;
const env = { MAILING_LIST: { prepare() { writes++; throw new Error(); } } };
for (const invalid of [null, {}, choice({ updates: "true" }), choice({ updates: false }),
choice({ email: "", updates: true }), choice({ email: "invalid" }),
choice({ token: "guess" }), choice({ token: ["a".repeat(64)] }), choice({ rev: 0 }), choice({ rev: 1.5 }),
choice({ email: "x".repeat(255) + "@example.com" })]) {
assert.equal((await call(invalid, env)).status, 400);
}
assert.equal(writes, 0);
});
test("rejects browser origins, oversized bodies, malformed JSON and wrong methods", async () => {
const env = { MAILING_LIST: {} };
assert.equal((await call(choice(), env, { origin: "https://elsewhere.example" })).status, 403);
assert.equal((await call(choice({ padding: "x".repeat(3000) }), env)).status, 413);
assert.equal((await call(choice(), env, { "content-type": "text/plain" })).status, 415);
const bad = new Request("https://framecontrol.fun/api/mailing-list", {
method: "POST", body: "{bad", headers: { "content-type": "application/json" },
});
assert.equal((await onRequestPost({ request: bad, env })).status, 400);
assert.equal(onRequest().status, 405);
});
test("unconfigured or unavailable storage fails safely without personal data in errors", async () => {
assert.equal((await call(choice())).status, 503);
const result = await call(choice(), { MAILING_LIST: { prepare() { throw new Error("private database details"); } } });
assert.equal(result.status, 503);
const body = await result.text();
assert.doesNotMatch(body, /me@example|aaaa|private database/);
});
test("uses the existing salted rate-limit store and refuses bursts", async () => {
const keys = [], writes = [];
const env = {
GITHUB_TOKEN: "test-salt",
FEEDBACK_RL: { get: async key => { keys.push(key); return "30"; }, put: async () => {} },
MAILING_LIST: { prepare: () => { writes.push(true); } },
};
assert.equal((await call(choice(), env, { "cf-connecting-ip": "192.0.2.8" })).status, 429);
assert.equal(writes.length, 0);
assert.equal(keys.length, 1);
assert.doesNotMatch(keys[0], /192\.0\.2\.8|example|test-salt/);
});
+4
View File
@@ -10,3 +10,7 @@ GITHUB_REPO = "saphid/frame-control"
[[kv_namespaces]]
binding = "FEEDBACK_RL"
id = "4852de5aae9e4d9f989f6dc2bc3b2b6b"
# Mailing-list D1 binding: provision once with the command in site/README.md.
# Wrangler adds [[d1_databases]] with binding = "MAILING_LIST" and the real ID.
# Until then /api/mailing-list returns 503 and the app safely queues choices.
+1
View File
@@ -22,6 +22,7 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
# A maintainer's machine holds the database key; send anything that slips through nowhere.
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
os.environ["FRAME_CONTROL_MAILING_LIST_URL"] = "http://127.0.0.1:9"
# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
# its lock file) and the other per-user folders some modules use. A server started
+73 -5
View File
@@ -17,6 +17,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_compat_db as db # noqa: E402
import frame_contact as fc # noqa: E402
import frame_mailing as fm # noqa: E402
import frame_report as fr # noqa: E402
import frame_telemetry as tm # noqa: E402
from test_telemetry import Base, ReportProblem # noqa: E402
@@ -36,6 +37,16 @@ class Contact(Base):
p.start()
self.addCleanup(p.stop)
self.got = self.serve()
mailing = mock.patch.object(fm, 'submit')
self.mailing = mailing.start()
self.addCleanup(mailing.stop)
# Most consent tests drain the background queue synchronously, so their
# assertions cover delivered events without sleeps or real services.
self.production_deliver = fc._deliver
deliver = mock.patch.object(fc, '_deliver', side_effect=lambda changed:
fc._send_pending(block=False) if changed else None)
deliver.start()
self.addCleanup(deliver.stop)
def events(self):
return [body["batch"][0] for _, body in self.got]
@@ -93,6 +104,52 @@ class Contact(Base):
fc.save({"email": "me@example.com", "updates": True})
self.assertEqual(len(self.got), 1)
def test_mailing_only_receives_explicit_updates_and_no_machine_details(self):
fc.prompt({'prompt': 'dismissed'})
fc.save({'email': 'reply@example.com', 'followup': True})
self.mailing.assert_not_called()
fc.save({'email': 'news@example.com', 'updates': True})
token, rev, email = self.mailing.call_args.args
self.assertEqual((len(token), rev, email), (64, 2, 'news@example.com'))
self.assertNotEqual(token, fc.load()['id'])
fc.save({'email': 'reply@example.com', 'followup': True})
self.assertEqual(self.mailing.call_args.args, (token, 3, ''))
def test_mailing_failure_waits_independently_of_posthog(self):
with mock.patch.object(fm, 'submit', side_effect=fm.SendError('offline')):
s = fc.save({'email': 'news@example.com', 'updates': True})
self.assertTrue(s['waiting'])
self.assertIsNone(fc.load()['pending'])
self.assertEqual(fc.load()['mailingPending']['email'], 'news@example.com')
with self.offline():
self.assertTrue(fc._send_pending()) # PostHog need not be available
self.assertFalse(fc.state()['waiting'])
def test_posthog_failure_does_not_hold_up_mailing_delivery(self):
with self.offline():
fc.save({'email': 'news@example.com', 'updates': True})
self.mailing.assert_called_once()
self.assertIsNone(fc.load()['mailingPending'])
self.assertIsNotNone(fc.load()['pending'])
def test_offline_removal_erases_the_queued_address_and_survives_reload(self):
with mock.patch.object(fm, 'submit', side_effect=fm.SendError('offline')):
fc.save({'email': 'news@example.com', 'updates': True})
fc.save({'email': ''})
s = fc.load()
self.assertEqual(s['mailingPending'], {'rev': 2, 'email': ''})
self.assertNotIn('news@example.com', fc.FILE.read_text())
self.assertTrue(fc._send_pending())
self.assertEqual(self.mailing.call_args.args, (s['mailingToken'], 2, ''))
def test_production_save_only_wakes_worker_without_network_io(self):
# Restore the production notifier while leaving the network mocked.
with mock.patch.object(fc, '_deliver', self.production_deliver):
s = fc.save({'email': 'news@example.com', 'updates': True})
self.assertTrue(s['waiting'])
self.mailing.assert_not_called()
self.assertEqual(self.got, [])
# ---- withdrawal
def test_removing_the_address_sends_a_withdrawal_without_it(self):
@@ -228,10 +285,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 +297,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):
+85
View File
@@ -0,0 +1,85 @@
"""Mailing-list HTTP contract; all requests go to a disposable local server."""
import sandbox # noqa: F401
import json
import os
import subprocess
import sys
import tempfile
import threading
import unittest
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
import frame_mailing as fm # noqa: E402
class MailingTransport(unittest.TestCase):
def setUp(self):
self.got = []
self.status, self.answer = 200, b'{"ok":true}'
owner = self
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
owner.got.append((self.path, json.loads(self.rfile.read(int(self.headers['Content-Length'])))))
self.send_response(owner.status)
self.end_headers()
self.wfile.write(owner.answer)
def log_message(self, *args):
pass
server = HTTPServer(('127.0.0.1', 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
env = mock.patch.dict('os.environ', {'FRAME_CONTROL_MAILING_LIST_URL':
f'http://127.0.0.1:{server.server_port}/api/mailing-list'})
env.start()
self.addCleanup(env.stop)
def test_only_email_consent_and_retry_credentials_leave_the_app(self):
fm.submit('a' * 64, 7, 'me@example.com')
fm.submit('a' * 64, 8, '')
self.assertEqual(self.got, [
('/api/mailing-list', {'token': 'a' * 64, 'rev': 7, 'email': 'me@example.com', 'updates': True}),
('/api/mailing-list', {'token': 'a' * 64, 'rev': 8, 'email': '', 'updates': False}),
])
def test_unavailable_unconfigured_and_invalid_responses_are_retryable(self):
for status, answer in ((503, b'{}'), (429, b'{}'), (200, b'not json'),
(200, b'[]'), (200, b'{"ok":false}')):
self.status, self.answer = status, answer
with self.subTest(status=status, answer=answer), self.assertRaises(fm.SendError):
fm.submit('a' * 64, 1, 'me@example.com')
def test_timeout_is_bounded_and_retryable(self):
with mock.patch.object(fm.urllib.request, 'urlopen', side_effect=TimeoutError) as send:
with self.assertRaises(fm.SendError):
fm.submit('a' * 64, 1, 'me@example.com')
self.assertEqual(send.call_args.kwargs['timeout'], 10)
def test_real_contact_worker_delivers_join_and_removal(self):
# Isolate the daemon worker in a short-lived process so it cannot outlive
# a test's temporary state or mocks. Only the local mailing server is real.
with tempfile.TemporaryDirectory() as state:
run = subprocess.run([sys.executable, '-c', '''
import time
import frame_contact as fc
fc.frame_telemetry.post = lambda *args, **kwargs: None
fc.start()
for choice in ({'email': 'me@example.com', 'updates': True}, {'email': ''}):
fc.save(choice)
deadline = time.monotonic() + 5
while fc.state()['waiting'] and time.monotonic() < deadline:
time.sleep(.01)
assert not fc.state()['waiting'], 'worker did not drain queue'
'''], cwd=Path(__file__).resolve().parent.parent / 'ui',
env={**os.environ, 'FRAME_CONTROL_DATA_DIR': state},
capture_output=True, text=True, timeout=15)
self.assertEqual(run.returncode, 0, run.stderr)
self.assertEqual([body['email'] for _, body in self.got], ['me@example.com', ''])
self.assertEqual([body['rev'] for _, body in self.got], [1, 2])
+49 -19
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
@@ -12,20 +12,26 @@ sends a new event under this copy's own random contact id (not the analytics id)
by `rev`, and the highest rev for an id is the one that counts, whatever the clocks say:
removing the address sends a withdrawal with no address in it, and wipes the address from
the local log of what was sent. The maintainer lists who agreed to what with
`python3 ui/frame_report.py contacts`. Nothing here sends email.
`python3 ui/frame_report.py contacts`. Mailing-list changes also go to the site's
private Cloudflare D1 list through frame_mailing.submit, independently of PostHog.
Neither endpoint sends email. No mailing request is made for a skip or for a
follow-up-only address.
A change that can't be sent (offline) waits in the state file and is retried in the
background, so a withdrawal is never lost. The page's one-time prompt is remembered here
background, so a withdrawal is never lost. Saving never waits for a network request.
The page's one-time prompt is remembered here
too: once it has been shown or dismissed it never comes back.
"""
import json
import os
import re
import secrets
import threading
import time
import uuid
import frame_host
import frame_mailing
import frame_telemetry
STATE = frame_host.data_dir('contact')
@@ -44,7 +50,8 @@ _retrier = None
def _defaults():
return {'id': str(uuid.uuid4()), 'email': '', 'updates': False, 'followup': False,
'prompt': 'new', 'pending': None, 'rev': 0}
'prompt': 'new', 'pending': None, 'rev': 0,
'mailingToken': secrets.token_hex(32), 'mailingPending': None}
def load():
@@ -61,9 +68,10 @@ def load():
def _save(s):
STATE.mkdir(parents=True, exist_ok=True)
STATE.mkdir(parents=True, exist_ok=True, mode=0o700)
tmp = FILE.with_suffix('.tmp')
tmp.write_text(json.dumps(s, indent=1))
with os.fdopen(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), 'w') as f:
json.dump(s, f, indent=1)
os.replace(tmp, FILE)
@@ -97,12 +105,13 @@ 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 or s['mailingPending'] is not None,
'showPrompt': s['prompt'] == 'new'}
def _event(s):
@@ -122,19 +131,35 @@ def _send_pending(block=True):
try:
while True:
with _lock:
event = load()['pending']
if event is None:
s = load()
event, mailing = s['pending'], s['mailingPending']
if event is None and mailing is None:
break
try:
frame_telemetry.post([event], timeout=30)
except frame_telemetry.SendError:
failed = False
if event is not None:
try:
frame_telemetry.post([event], timeout=30)
_sent(event)
except frame_telemetry.SendError:
failed = True
if mailing is not None:
try:
frame_mailing.submit(s['mailingToken'], mailing['rev'], mailing['email'])
with _lock:
latest = load()
if latest['mailingPending'] == mailing:
latest['mailingPending'] = None
_save(latest)
except frame_mailing.SendError:
failed = True
if failed:
return False
_sent(event)
finally:
_send_lock.release()
# A change saved just as this finished found the lock still held and left it to us.
with _lock:
left = load()['pending'] is not None
s = load()
left = s['pending'] is not None or s['mailingPending'] is not None
return _send_pending(block=False) if left else True
@@ -197,6 +222,7 @@ def _apply(body):
with _lock:
s = load()
old = s['email']
old_updates = s['updates']
changed = (email, updates, followup) != (s['email'], s['updates'], s['followup'])
s.update(email=email, updates=updates, followup=followup)
if body.get('fromPrompt') or email:
@@ -206,6 +232,10 @@ def _apply(body):
# is sent even for an address still waiting here: its send may already be under way.
s['rev'] += 1
s['pending'] = _event(s)
# No mailing request for a skip or a follow-up-only address. A
# withdrawal keeps no address in the queue, even while offline.
if old_updates or updates:
s['mailingPending'] = {'rev': s['rev'], 'email': email if updates else ''}
_save(s)
if old and old.lower() != email.lower():
try:
@@ -216,8 +246,8 @@ def _apply(body):
def _deliver(changed):
if changed and not _send_pending(block=False):
_wake.set() # offline, or a send under way that will take this change with it
if changed:
_wake.set() # all network I/O stays in start()'s background worker
def prompt(body):
+36
View File
@@ -0,0 +1,36 @@
"""Mailing-list transport. Consent and the retry queue live in frame_contact.py.
The Cloudflare Pages endpoint stores addresses privately in D1. Override
FRAME_CONTROL_MAILING_LIST_URL to use a local server or a future provider adapter.
No analytics identifiers or machine details are sent through this integration.
"""
import json
import os
import urllib.error
import urllib.request
DEFAULT_URL = 'https://framecontrol.fun/api/mailing-list'
class SendError(Exception):
pass
def submit(token, rev, email):
"""Set this copy's latest opt-in, or withdraw it with an empty email.
token is a random capability, separate from analytics and contact IDs. The
revision makes retries idempotent and prevents delayed joins undoing a removal.
"""
url = os.environ.get('FRAME_CONTROL_MAILING_LIST_URL', DEFAULT_URL)
body = json.dumps({'token': token, 'rev': rev, 'email': email,
'updates': bool(email)}).encode('utf-8')
try:
request = urllib.request.Request(url, data=body, headers={
'Content-Type': 'application/json', 'User-Agent': 'Frame-Control-mailing-list'})
with urllib.request.urlopen(request, timeout=10) as response:
answer = json.loads(response.read(1024))
if response.status != 200 or not isinstance(answer, dict) or answer.get('ok') is not True:
raise SendError('mailing list did not accept the change')
except (OSError, ValueError, urllib.error.URLError) as err:
raise SendError('could not reach the mailing list') from err
+31 -26
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.";
$("cStatus").textContent = s.waiting ? "Saved on this computer. Your choice will be sent when the service is available."
: 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,27 @@ 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.
const saved = 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(saved.waiting ? "Thanks! Your signup is saved here and will be sent in the background. Unsubscribe any time in Privacy & updates."
: "Thanks for joining! Unsubscribe any time in Privacy & updates.");
} catch (err) { $("cpMsg").textContent = `Couldn't save: ${err.message}`; }
finally { $("cpSave").disabled = false; }
};
@@ -4648,6 +4646,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(() => {});
}