mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 07:02:00 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aab62983e | ||
|
|
8051687e1e |
No files matched your search
@@ -5,6 +5,11 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# A newer push to the same pull request supersedes a run still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -17,7 +22,9 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||
- name: Script syntax
|
||||
run: |
|
||||
sh -n ui/local-bin/ssh
|
||||
@@ -84,6 +91,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||
- name: End-to-end tests
|
||||
run: scripts/e2e.sh
|
||||
@@ -2,14 +2,22 @@ name: release
|
||||
|
||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
||||
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||
# Pull requests that touch the app build the same installers as artifacts.
|
||||
# Pull requests that change how the installers are built (the Electron app, the
|
||||
# macOS helper it compiles, or this workflow) build the same installers as
|
||||
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
|
||||
# are tested by the checks workflow; run this one by hand to package them early.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -94,7 +102,9 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
||||
# Tagged builds are attached to the release above; only other builds need artifacts.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
with:
|
||||
name: frame-control-${{ matrix.os }}
|
||||
path: |
|
||||
|
||||
@@ -118,9 +118,8 @@ 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, and so is the
|
||||
mailing list it offers once on first launch (just an email address, **No
|
||||
thanks** to skip). See [docs/privacy.md](docs/privacy.md).
|
||||
off. Sharing compatibility results and error details is opt-in. See
|
||||
[docs/privacy.md](docs/privacy.md).
|
||||
Google doesn't publish `adb` for arm64 Linux, so that build uses your
|
||||
distribution's. If you already have `adb`, the app uses yours.
|
||||
|
||||
|
||||
@@ -602,9 +602,6 @@ 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,11 +40,6 @@ 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: {
|
||||
|
||||
+8
-16
@@ -218,27 +218,21 @@ 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`.
|
||||
|
||||
## Mailing list and contact email (optional)
|
||||
## 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 |
|
||||
|---|---|
|
||||
| **Join the mailing list: new features and updates** | Occasional emails about new features and updates |
|
||||
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
|
||||
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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
|
||||
@@ -273,9 +267,7 @@ 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`; `contacts updates` is the mailing list. If those
|
||||
addresses are ever moved to an email service to send the newsletter, this
|
||||
page will name it first.
|
||||
personal API key as `inbox`.
|
||||
|
||||
## Turning it all off
|
||||
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@ started=$SECONDS
|
||||
|
||||
print "==> Building fakeframe-frame (from $base) and fakeframe-host"
|
||||
# Quiet when it works; if a build fails, build again with the full log so CI shows why.
|
||||
build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } }
|
||||
# That second build also rides out a flaky package mirror: only its failure is fatal.
|
||||
build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 }
|
||||
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
|
||||
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
|
||||
|
||||
|
||||
@@ -38,11 +38,10 @@
|
||||
<div class="head">
|
||||
<span class="kicker">Privacy</span>
|
||||
<h1>Privacy</h1>
|
||||
<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>
|
||||
<p>Short version: the app collects nothing, 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 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 your email address is sent, privately to the maintainer, and only if you join. Unsubscribe or remove the address any time in Privacy & updates.</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 analytics and no accounts.</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>
|
||||
|
||||
+5
-16
@@ -228,9 +228,10 @@ class Contact(Base):
|
||||
|
||||
# ---- the one-time prompt
|
||||
|
||||
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
|
||||
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"])
|
||||
fc.prompt({"prompt": "dismissed"})
|
||||
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
|
||||
self.assertEqual(fc.load()["prompt"], "dismissed")
|
||||
@@ -240,24 +241,12 @@ 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):
|
||||
|
||||
@@ -121,7 +121,27 @@ class LauncherTests(unittest.TestCase):
|
||||
FIXTURES = ROOT / 'tests/fixtures/library'
|
||||
|
||||
|
||||
def wait_for_idle_resolvers(timeout=10):
|
||||
# Name lookups that outlast their deadline give their slot back from their own thread,
|
||||
# which a busy runner may not schedule before the next test; wait until all are back.
|
||||
from apk_sources import _images
|
||||
held = []
|
||||
try:
|
||||
end = time.monotonic() + timeout
|
||||
while _images._resolvers.acquire(timeout=max(0, end - time.monotonic())):
|
||||
held.append(1)
|
||||
if len(held) == 4:
|
||||
return
|
||||
raise AssertionError('artwork name lookups from an earlier test are still running')
|
||||
finally:
|
||||
for _ in held:
|
||||
_images._resolvers.release()
|
||||
|
||||
|
||||
class ArtworkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
wait_for_idle_resolvers()
|
||||
|
||||
def test_source_inputs_and_url(self):
|
||||
from apk_sources import _images
|
||||
data = (FIXTURES / 'icon.png').read_bytes()
|
||||
@@ -232,10 +252,7 @@ class ArtworkTests(unittest.TestCase):
|
||||
self.assertEqual(sum('Too many' in e for e in errors), 2)
|
||||
finally:
|
||||
gate.set()
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False):
|
||||
time.sleep(0.01)
|
||||
_images._resolvers.release() # the stuck lookups finished and gave their slots back
|
||||
wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back
|
||||
|
||||
def test_resolver_slot_released_when_thread_cannot_start(self):
|
||||
from apk_sources import _images
|
||||
@@ -243,10 +260,14 @@ class ArtworkTests(unittest.TestCase):
|
||||
for _ in range(6):
|
||||
with self.assertRaises(RuntimeError):
|
||||
_images.get('https://example.org/a.png', deadline=time.monotonic() + 1)
|
||||
for _ in range(4): # every slot came back
|
||||
self.assertTrue(_images._resolvers.acquire(blocking=False))
|
||||
for _ in range(4):
|
||||
_images._resolvers.release()
|
||||
held = 0
|
||||
try:
|
||||
while held < 4 and _images._resolvers.acquire(blocking=False):
|
||||
held += 1
|
||||
self.assertEqual(held, 4) # every slot came back
|
||||
finally:
|
||||
for _ in range(held): # even on failure, so later tests don't inherit the leak
|
||||
_images._resolvers.release()
|
||||
|
||||
def test_deadline_covers_name_resolution(self):
|
||||
import threading
|
||||
|
||||
+5
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
Two separate opt-in choices, both off until ticked:
|
||||
|
||||
- updates: the mailing list, occasional emails about new features and updates
|
||||
- updates: occasional notices about Frame Control releases and updates
|
||||
- followup: the maintainer may ask follow-up questions, mainly about problem reports
|
||||
|
||||
The address and the choices are kept on this computer (frame_host.data_dir('contact')) and
|
||||
@@ -97,12 +97,12 @@ def from_report(email):
|
||||
|
||||
|
||||
def state():
|
||||
"""What the page shows. showPrompt: the one-time 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."""
|
||||
"""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."""
|
||||
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'}
|
||||
'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new' and set_up}
|
||||
|
||||
|
||||
def _event(s):
|
||||
|
||||
+25
-29
@@ -704,12 +704,15 @@
|
||||
<button class="action small" id="noticeOk">OK</button>
|
||||
</div>
|
||||
<form class="notice" id="contactNotice" hidden>
|
||||
<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>
|
||||
<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>
|
||||
<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">Join</button>
|
||||
<button type="submit" class="action small" id="cpSave">Save</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>
|
||||
@@ -1258,17 +1261,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" id="mailingList">Mailing list and contact email (optional)</h3>
|
||||
<h3 style="margin-top:22px">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"> Join the mailing list: new features and updates</label>
|
||||
<label><input type="checkbox" id="cUpdates"> Email me about Frame Control 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.
|
||||
The mailing list is occasional news about new features and updates; follow-up questions are mainly about problem reports you send.</div>
|
||||
Updates are occasional release notices; 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>
|
||||
@@ -4298,7 +4301,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", mailingList: "tools", panels: "tools" };
|
||||
display: "android", androidLibrary: "android", appStore: "android", transfer: "tools", media: "tools", artworkSettings: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
|
||||
let page = "home";
|
||||
function showPage() {
|
||||
const id = location.hash.slice(1);
|
||||
@@ -4550,6 +4553,7 @@ 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();
|
||||
@@ -4560,6 +4564,7 @@ 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() {
|
||||
@@ -4573,25 +4578,24 @@ $("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; checkContactPrompt(); };
|
||||
$("noticeOk").onclick = () => { $("privacyNotice").hidden = true; };
|
||||
$("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"; checkContactPrompt(); };
|
||||
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; };
|
||||
const telemetryLoaded = loadTelemetry();
|
||||
|
||||
// ---- mailing list and contact email: two separate opt-ins (ui/frame_contact.py, docs/privacy.md) ----
|
||||
// ---- 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 && "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.";
|
||||
: 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.";
|
||||
}
|
||||
async function saveContact(change) {
|
||||
const s = await api("/api/contact", change);
|
||||
@@ -4603,26 +4607,25 @@ async function loadContact() {
|
||||
try { renderContact(await api("/api/contact")); } catch { return; }
|
||||
checkContactPrompt();
|
||||
}
|
||||
// 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).
|
||||
// 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.
|
||||
async function checkContactPrompt() {
|
||||
await telemetryLoaded;
|
||||
if (!$("privacyNotice").hidden || !$("contactNotice").hidden) return;
|
||||
if (privacyNoticeShown || !$("contactNotice").hidden) return;
|
||||
let s;
|
||||
try { s = await api("/api/contact"); } catch { return; }
|
||||
if (!s.showPrompt || !$("privacyNotice").hidden || !$("contactNotice").hidden) return;
|
||||
if (!s.showPrompt || privacyNoticeShown || !$("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 {
|
||||
// Joining is the mailing list only; follow-up questions keep whatever was agreed before.
|
||||
await saveContact({ email: $("cpEmail").value, updates: true, followup: contact.followup && contact.email.toLowerCase() === $("cpEmail").value.trim().toLowerCase(), fromPrompt: true });
|
||||
await saveContact({ email: $("cpEmail").value, updates: $("cpUpdates").checked, followup: $("cpFollowup").checked, fromPrompt: true });
|
||||
$("contactNotice").hidden = true;
|
||||
toast("Thanks for joining! Unsubscribe any time in Privacy & updates.");
|
||||
toast("Thanks! Change or remove it any time in Privacy & updates.");
|
||||
} catch (err) { $("cpMsg").textContent = `Couldn't save: ${err.message}`; }
|
||||
finally { $("cpSave").disabled = false; }
|
||||
};
|
||||
@@ -4645,13 +4648,6 @@ $("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