mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 18:01:56 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e28e279d8d | ||
|
|
1e1541e36e |
No files matched your search
@@ -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/
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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") },
|
||||
],
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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 & 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 & 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(() => {});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user