mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 08:00:32 +02:00
Merge main into fix/server-stdin-abort
This commit is contained in:
commit
f73efe414c
11 files changed
+969
-51
No files matched your search
@@ -0,0 +1,218 @@
|
||||
"""An email address the person chooses to leave, and what it may be used for. Python stdlib only.
|
||||
|
||||
Two separate opt-in choices, both off until ticked:
|
||||
|
||||
- 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
|
||||
sent privately to Frame Control's PostHog project as a `contact_consent` event, the same way
|
||||
as problem reports (frame_report.py), so only the maintainer can read them. Every change
|
||||
sends a new event under this copy's own random contact id (not the analytics id), numbered
|
||||
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.
|
||||
|
||||
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
|
||||
too: once it has been shown or dismissed it never comes back.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import frame_host
|
||||
import frame_telemetry
|
||||
|
||||
STATE = frame_host.data_dir('contact')
|
||||
FILE = STATE / 'contact.json'
|
||||
EMAIL_MAX = 254
|
||||
EMAIL_RE = re.compile(r'[^@\s]+@[^@\s]+\.[^@\s.]+')
|
||||
PROMPTS = ('new', 'shown', 'dismissed', 'answered')
|
||||
RETRY_EVERY = 600
|
||||
|
||||
_lock = threading.RLock()
|
||||
_send_lock = threading.Lock() # one send at a time, so events reach PostHog in rev order
|
||||
_removed = {} # address (lower case) -> when it was removed, for reports still being sent then
|
||||
_wake = threading.Event()
|
||||
_retrier = None
|
||||
|
||||
|
||||
def _defaults():
|
||||
return {'id': str(uuid.uuid4()), 'email': '', 'updates': False, 'followup': False,
|
||||
'prompt': 'new', 'pending': None, 'rev': 0}
|
||||
|
||||
|
||||
def load():
|
||||
with _lock:
|
||||
s = _defaults()
|
||||
try:
|
||||
with open(FILE) as f:
|
||||
saved = json.load(f)
|
||||
if isinstance(saved, dict):
|
||||
s.update({k: v for k, v in saved.items() if k in s})
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return s
|
||||
|
||||
|
||||
def _save(s):
|
||||
STATE.mkdir(parents=True, exist_ok=True)
|
||||
tmp = FILE.with_suffix('.tmp')
|
||||
tmp.write_text(json.dumps(s, indent=1))
|
||||
os.replace(tmp, FILE)
|
||||
|
||||
|
||||
def valid_email(email):
|
||||
return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(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."""
|
||||
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}
|
||||
|
||||
|
||||
def _event(s):
|
||||
email = s['email'] if s['updates'] or s['followup'] else ''
|
||||
return {'event': 'contact_consent', 'distinct_id': s['id'], 'uuid': str(uuid.uuid4()),
|
||||
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'properties': {**frame_telemetry.common(), 'email': email, 'updates': bool(email and s['updates']),
|
||||
'followup': bool(email and s['followup']),
|
||||
'action': 'set' if email else 'withdraw', 'rev': s['rev'], 'level': 'contact'}}
|
||||
|
||||
|
||||
def _send_pending(block=True):
|
||||
"""Send what's waiting, including changes made while sending. True if nothing is left
|
||||
waiting. Without block, a send already under way is left to pick up the newest change."""
|
||||
if not _send_lock.acquire(blocking=block):
|
||||
return False
|
||||
try:
|
||||
while True:
|
||||
with _lock:
|
||||
event = load()['pending']
|
||||
if event is None:
|
||||
break
|
||||
try:
|
||||
frame_telemetry.post([event], timeout=30)
|
||||
except frame_telemetry.SendError:
|
||||
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
|
||||
return _send_pending(block=False) if left else True
|
||||
|
||||
|
||||
def _sent(event):
|
||||
with _lock:
|
||||
s = load()
|
||||
if s['pending'] and s['pending'].get('uuid') == event['uuid']: # not replaced meanwhile
|
||||
s['pending'] = None
|
||||
_save(s)
|
||||
# A withdrawal, or the address still in use: not an old one removed while this was on its way.
|
||||
if event['properties']['email'] in ('', s['email']):
|
||||
try:
|
||||
frame_telemetry.record_sent([event])
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _forget_locally(email):
|
||||
"""Take a removed address out of the log of what was sent (contact events and reports)."""
|
||||
with frame_telemetry._lock:
|
||||
_removed[email.lower()] = time.time()
|
||||
rows = frame_telemetry._read_lines(frame_telemetry.SENT)
|
||||
hit = False
|
||||
for e in rows:
|
||||
p = e.get('properties') or {}
|
||||
for k in ('email', 'contact'):
|
||||
if p.get(k) and str(p[k]).strip().lower() == email.lower():
|
||||
p[k], hit = '<removed>', True
|
||||
if hit:
|
||||
frame_telemetry._write_lines(frame_telemetry.SENT, rows)
|
||||
|
||||
|
||||
def redact_removed(event, started):
|
||||
"""Before logging a report (started at time.time() `started`) whose address was removed
|
||||
while it was being sent: take the address out. Call with frame_telemetry._lock held, so a
|
||||
removal can't slip between this and the log."""
|
||||
p = event.get('properties') or {}
|
||||
removed_at = _removed.get(str(p.get('contact') or '').strip().lower())
|
||||
if removed_at is not None and started <= removed_at:
|
||||
p['contact'] = '<removed>'
|
||||
|
||||
|
||||
def save(body):
|
||||
"""Set, change or remove the address and the two choices. An address needs at least one
|
||||
choice ticked; an empty address (or neither ticked) removes it and withdraws both."""
|
||||
email = str(body.get('email') or '').strip()
|
||||
updates, followup = bool(body.get('updates')), bool(body.get('followup'))
|
||||
if email and not valid_email(email):
|
||||
raise ValueError("that doesn't look like an email address")
|
||||
if email and not (updates or followup):
|
||||
raise ValueError('tick what the address may be used for, or remove it')
|
||||
if not email:
|
||||
updates = followup = False
|
||||
with _lock:
|
||||
s = load()
|
||||
old = s['email']
|
||||
changed = (email, updates, followup) != (s['email'], s['updates'], s['followup'])
|
||||
s.update(email=email, updates=updates, followup=followup)
|
||||
if body.get('fromPrompt') or email:
|
||||
s['prompt'] = 'answered'
|
||||
if changed:
|
||||
# Only the newest choice matters, so it replaces anything still waiting. A withdrawal
|
||||
# is sent even for an address still waiting here: its send may already be under way.
|
||||
s['rev'] += 1
|
||||
s['pending'] = _event(s)
|
||||
_save(s)
|
||||
if old and old.lower() != email.lower():
|
||||
try:
|
||||
_forget_locally(old)
|
||||
except OSError:
|
||||
pass
|
||||
if changed and not _send_pending(block=False):
|
||||
_wake.set() # offline, or a send under way that will take this change with it
|
||||
return state()
|
||||
|
||||
|
||||
def prompt(body):
|
||||
"""The one-time prompt was shown, or dismissed with No thanks. Either way it stays gone."""
|
||||
action = body.get('prompt')
|
||||
if action not in ('shown', 'dismissed'):
|
||||
raise ValueError('unknown prompt action')
|
||||
with _lock:
|
||||
s = load()
|
||||
if s['prompt'] in ('new', 'shown'):
|
||||
s['prompt'] = action
|
||||
_save(s)
|
||||
return state()
|
||||
|
||||
|
||||
def start():
|
||||
"""Retry a change that couldn't be sent, from now on in the background."""
|
||||
global _retrier
|
||||
if _retrier:
|
||||
return
|
||||
|
||||
def loop():
|
||||
while True:
|
||||
try:
|
||||
_send_pending()
|
||||
except Exception:
|
||||
pass
|
||||
_wake.wait(RETRY_EVERY)
|
||||
_wake.clear()
|
||||
|
||||
_retrier = threading.Thread(target=loop, name='contact', daemon=True)
|
||||
_retrier.start()
|
||||
@@ -153,6 +153,19 @@ def open_path(path):
|
||||
stderr=subprocess.DEVNULL, **DETACHED)
|
||||
|
||||
|
||||
def reveal_path(path):
|
||||
"""Show a file selected in its folder (Linux file managers vary, so there the folder opens)."""
|
||||
path = Path(path)
|
||||
if MAC:
|
||||
cmd = ["open", "-R", str(path)]
|
||||
elif WINDOWS:
|
||||
cmd = f'explorer /select,"{path}"' # as one string: Explorer wants the quotes after the comma
|
||||
else:
|
||||
return open_path(path.parent)
|
||||
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, **DETACHED)
|
||||
|
||||
|
||||
open_url = open_path # the same openers hand URLs to the default browser
|
||||
|
||||
|
||||
|
||||
+68
-7
@@ -6,6 +6,10 @@ project as a `problem_report` event: only the maintainer can read it, and
|
||||
nothing is published. It is sent whatever the analytics settings are, because
|
||||
the person sends it deliberately. Diagnostics are scrubbed first
|
||||
(frame_telemetry.scrub); the person's own words are sent as written.
|
||||
|
||||
An email address goes with a report only when the person ticks "may contact me with
|
||||
follow-up questions" (contact_followup). Standing choices made in Settings are
|
||||
frame_contact.py's `contact_consent` events; `contacts` lists them.
|
||||
"""
|
||||
import os
|
||||
import platform
|
||||
@@ -13,6 +17,7 @@ import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import frame_contact
|
||||
import frame_host
|
||||
import frame_telemetry
|
||||
|
||||
@@ -107,19 +112,26 @@ def send(body):
|
||||
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
|
||||
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
|
||||
title, text, diag = compose(body)
|
||||
followup = bool(body.get('contactFollowup'))
|
||||
contact = str(body.get('contact') or '').strip() if followup else ''
|
||||
if followup and not frame_contact.valid_email(contact):
|
||||
raise ValueError('add your email address for follow-up questions, or untick that box')
|
||||
ref = uuid.uuid4().hex[:8].upper()
|
||||
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
|
||||
'contact': str(body.get('contact') or '').strip()[:120], 'diagnostics': diag,
|
||||
'contact': contact, 'contact_followup': followup, 'diagnostics': diag,
|
||||
'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
|
||||
# Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics.
|
||||
event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
|
||||
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props}
|
||||
started = time.time()
|
||||
try:
|
||||
frame_telemetry.post([event], timeout=30)
|
||||
except frame_telemetry.SendError as e:
|
||||
raise ReportError(str(e))
|
||||
try:
|
||||
frame_telemetry.record_sent([event])
|
||||
with frame_telemetry._lock: # the lock a removal holds while wiping its address
|
||||
frame_contact.redact_removed(event, started)
|
||||
frame_telemetry.record_sent([event])
|
||||
except OSError:
|
||||
pass # it was sent; failing to log it here mustn't make the person send it again
|
||||
return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'}
|
||||
@@ -135,22 +147,71 @@ def inbox(days=30):
|
||||
import frame_compat_db
|
||||
res = frame_compat_db._posthog_query(
|
||||
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
|
||||
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics "
|
||||
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, "
|
||||
"properties.contact_followup "
|
||||
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
|
||||
"ORDER BY timestamp DESC LIMIT 200")
|
||||
return res.get('results') or []
|
||||
|
||||
|
||||
def _yes(v):
|
||||
return v is True or str(v).lower() in ('true', '1')
|
||||
|
||||
|
||||
def contacts():
|
||||
"""{'updates': [(email, since)], 'followup': [...]}: the addresses whose newest
|
||||
contact_consent event agrees to each, oldest first. A withdrawal, or a change to another
|
||||
address, replaces what came before, so withdrawn addresses are never listed. "Newest" is
|
||||
the highest rev from that copy (then time), so every field comes from the same event
|
||||
whatever order they arrived in or what the clocks said."""
|
||||
import frame_compat_db
|
||||
newest = "tuple(ifNull(toInt(properties.rev), 0), timestamp)"
|
||||
res = frame_compat_db._posthog_query(
|
||||
f"SELECT distinct_id, argMax(properties.email, {newest}), argMax(properties.updates, {newest}), "
|
||||
f"argMax(properties.followup, {newest}), argMax(timestamp, {newest}) FROM events "
|
||||
"WHERE event = 'contact_consent' GROUP BY distinct_id ORDER BY max(timestamp) LIMIT 100000")
|
||||
out = {'updates': [], 'followup': []}
|
||||
for row in res.get('results') or []:
|
||||
if not isinstance(row, list) or len(row) != 5:
|
||||
continue
|
||||
_, email, updates, followup, ts = row
|
||||
email = str(email or '').strip()
|
||||
if not frame_contact.valid_email(email):
|
||||
continue
|
||||
for kind, agreed in (('updates', updates), ('followup', followup)):
|
||||
if _yes(agreed):
|
||||
out[kind].append((email, str(ts or '')[:10]))
|
||||
return out
|
||||
|
||||
|
||||
USAGE = 'usage: frame_report.py inbox [days] | contacts [updates|followup]'
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['inbox']
|
||||
if cmd == 'contacts':
|
||||
kinds = args[:1] or ['updates', 'followup']
|
||||
if not set(kinds) <= {'updates', 'followup'}:
|
||||
sys.exit(USAGE)
|
||||
found = contacts()
|
||||
for kind in kinds:
|
||||
print(f"== {'Release and update notices' if kind == 'updates' else 'Follow-up questions'}"
|
||||
f" ({len(found[kind])})")
|
||||
for email, since in found[kind]:
|
||||
print(f" {email} (since {since})")
|
||||
print()
|
||||
return
|
||||
if cmd != 'inbox':
|
||||
sys.exit('usage: frame_report.py inbox [days]')
|
||||
sys.exit(USAGE)
|
||||
for row in inbox(*(args[:1] or [30])):
|
||||
if not isinstance(row, list) or len(row) != 10:
|
||||
if not isinstance(row, list) or len(row) != 11:
|
||||
continue
|
||||
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row)
|
||||
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10])
|
||||
# Reports from before contact_followup existed only carried an address given for a reply.
|
||||
reply = contact and (row[10] is None or _yes(row[10]))
|
||||
print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
|
||||
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}")
|
||||
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}"
|
||||
f"{', may follow up at ' + contact if reply else ''}")
|
||||
print(' ' + text.replace('\n', '\n '))
|
||||
if diag:
|
||||
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
|
||||
|
||||
+302
-35
@@ -175,7 +175,7 @@
|
||||
.seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; }
|
||||
.seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
|
||||
.seg button.on { background: var(--btn-hi); color: var(--bright); }
|
||||
input[type=text], input[type=search], input[type=url], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
input[type=text], input[type=search], input[type=url], input[type=password], input[type=email], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
border-radius: 3px; padding: 9px 11px; font: inherit; }
|
||||
textarea { resize: vertical; min-height: 76px; }
|
||||
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
|
||||
@@ -258,7 +258,12 @@
|
||||
.shot-card .row { flex-wrap: nowrap; }
|
||||
.shot-card .grow { flex: 1; min-width: 0; }
|
||||
.shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.shot-card .s { color: var(--muted); font-size: 12px; }
|
||||
.shot-card .s { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ctx-menu { position: fixed; z-index: 1000; min-width: 200px; padding: 4px; border-radius: 4px; background: #232c38;
|
||||
box-shadow: 0 10px 28px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.08); }
|
||||
.ctx-menu button { display: block; width: 100%; height: 30px; padding: 0 10px; text-align: left; background: none; }
|
||||
.ctx-menu button:hover, .ctx-menu button:focus-visible { background: var(--blue); color: #fff; outline: none; }
|
||||
.ctx-menu hr { border: 0; border-top: 1px solid rgba(255,255,255,.1); margin: 4px 2px; }
|
||||
|
||||
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
|
||||
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
||||
@@ -296,6 +301,11 @@
|
||||
background: rgba(26,159,255,.12); border-left: 3px solid var(--blue); font-size: 13.5px; line-height: 1.5; }
|
||||
.notice .grow { flex: 1; min-width: 260px; }
|
||||
.notice .progress { width: 160px; margin-top: 0; display: block; }
|
||||
.contact-opts { display: flex; gap: 6px 18px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.contact-opts label { display: inline-flex; gap: 7px; align-items: center; cursor: pointer; }
|
||||
.contact-opts input { width: 15px; height: 15px; margin: 0; accent-color: var(--blue); }
|
||||
#contactNotice input[type=email] { width: min(320px, 100%); margin-top: 8px; padding: 7px 10px; }
|
||||
#cEmail { max-width: 420px; }
|
||||
.popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; }
|
||||
.popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); }
|
||||
.popt b { font-weight: 600; color: var(--text); }
|
||||
@@ -674,6 +684,17 @@
|
||||
<button class="small" id="noticeMore" title="Also share compatibility results and error details (you can turn either off later)">Share more to help fix problems</button>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<div class="grow"><div class="t" id="offMsg">Can't reach the Frame</div>
|
||||
@@ -701,6 +722,7 @@
|
||||
<button class="action" id="shotBtn">Capture</button>
|
||||
<button id="liveBtn" title="Keep updating, as video">Live</button>
|
||||
<button id="ctrlBtn" title="Control the Frame by tapping or clicking on the view (C)">Control</button>
|
||||
<button id="copyBtn" disabled title="Copy the image to the clipboard (or right-click it)">Copy</button>
|
||||
<button id="saveBtn" disabled>Save</button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -867,7 +889,7 @@
|
||||
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
||||
</div>
|
||||
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
||||
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
|
||||
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. New ones appear on their own. Click one to open it in the viewer, Copy puts it on the clipboard, and Save copies it to <code>~/Pictures/SteamFrame</code>. Right-click for more.</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1127,6 +1149,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>
|
||||
<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="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>
|
||||
<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>
|
||||
@@ -1234,7 +1267,9 @@
|
||||
placeholder="e.g. Installing an APK stops at 'copying to the Frame'"></label>
|
||||
<label class="field">What happened?<textarea id="bugText" maxlength="5000" required minlength="10"
|
||||
placeholder="What you did, what happened, and what you expected."></textarea></label>
|
||||
<label class="field">How can we reach you? (optional, for a reply)<input type="text" id="bugContact" maxlength="120" placeholder="Email, GitHub or Discord name"></label>
|
||||
<label class="popt"><input type="checkbox" id="bugFollowup"><b>The maintainer may contact me with follow-up questions</b>
|
||||
<span class="sub">Optional. Your email address goes with this report only when this is ticked.</span></label>
|
||||
<label class="field">Your email address<input type="email" id="bugContact" maxlength="254" placeholder="you@example.com" disabled></label>
|
||||
<label class="popt"><input type="checkbox" id="bugDiag" checked><b>Include diagnostics</b>
|
||||
<span class="sub">Frame Control's version, your OS and the Frame's SteamOS build.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="bugLogs"><b>Also include recent activity and the server log</b>
|
||||
@@ -1869,7 +1904,7 @@ async function capture() {
|
||||
if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`;
|
||||
ctrlShow();
|
||||
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
|
||||
$("saveBtn").disabled = false;
|
||||
$("saveBtn").disabled = $("copyBtn").disabled = false;
|
||||
return true;
|
||||
} catch (e) {
|
||||
log("Capture failed: " + e.message, "e");
|
||||
@@ -1951,7 +1986,7 @@ async function startVideo() {
|
||||
$("viewer").classList.remove("busy");
|
||||
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
|
||||
$("srcBadge").hidden = false; $("srcBadge").textContent = `${video.label} · video`;
|
||||
$("stamp").hidden = false; $("saveBtn").disabled = false;
|
||||
$("stamp").hidden = false; $("saveBtn").disabled = $("copyBtn").disabled = false;
|
||||
}
|
||||
frames++;
|
||||
const now = performance.now();
|
||||
@@ -2020,6 +2055,35 @@ $("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) :
|
||||
if (!b) return toast("Couldn't encode the image", true);
|
||||
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
|
||||
}, "image/png");
|
||||
const copyViewer = () => act("Copy image", () => copyImage(lastShot ? lastShot.blob
|
||||
: new Promise((ok, bad) => $("canvas").toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png"))));
|
||||
$("copyBtn").onclick = copyViewer;
|
||||
// Right-click the viewer to copy or save what it shows (in Control, a right-click goes to the Frame).
|
||||
$("canvas").oncontextmenu = e => {
|
||||
if (ctrl.on || $("saveBtn").disabled) return;
|
||||
e.preventDefault();
|
||||
showMenu(e, [["Copy image", copyViewer], ["Save image", () => $("saveBtn").click()]]);
|
||||
};
|
||||
// An image onto the clipboard. The app does it natively (JPEG too); a browser
|
||||
// takes PNG only, and the blob is handed over as a promise so the click still counts.
|
||||
async function copyImage(blob) {
|
||||
if (window.frameApp?.writeImage) {
|
||||
if (!await window.frameApp.writeImage(new Uint8Array(await (await blob).arrayBuffer()))) throw new Error("the app refused the image");
|
||||
return { message: "Copied the image" };
|
||||
}
|
||||
if (!navigator.clipboard?.write || !window.ClipboardItem) throw new Error("This browser can't copy images here");
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": Promise.resolve(blob).then(pngBlob) })]);
|
||||
return { message: "Copied the image" };
|
||||
}
|
||||
async function pngBlob(blob) {
|
||||
if (blob.type === "image/png") return blob;
|
||||
const bmp = await createImageBitmap(blob);
|
||||
const c = document.createElement("canvas");
|
||||
c.width = bmp.width; c.height = bmp.height;
|
||||
c.getContext("2d").drawImage(bmp, 0, 0);
|
||||
bmp.close();
|
||||
return new Promise((ok, bad) => c.toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png"));
|
||||
}
|
||||
function download(blob, name) {
|
||||
if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }]));
|
||||
const a = document.createElement("a");
|
||||
@@ -3497,8 +3561,11 @@ loadReports();
|
||||
api("/api/host").then(applyHostWording).catch(() => {});
|
||||
|
||||
// ---- Steam screenshots from the headset ----
|
||||
const shots = { list: [], urls: [] };
|
||||
// thumbs: id -> promise of an object URL, kept across reloads so a refresh
|
||||
// that finds a new shot fetches only that one's thumbnail.
|
||||
const shots = { list: [], sig: null, thumbs: new Map(), loading: null, gen: 0, fill: 0 };
|
||||
const STEAMVR_APPID = "250820";
|
||||
const SHOTS_POLL_MS = 8000;
|
||||
function shotApp(appid) {
|
||||
if (appid === STEAMVR_APPID) return "SteamVR";
|
||||
const g = state?.games?.find(x => x.appid === appid);
|
||||
@@ -3510,35 +3577,85 @@ async function shotBlob(id, thumb) {
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||
return r.blob();
|
||||
}
|
||||
async function loadShots() {
|
||||
$("shotsRefresh").disabled = true;
|
||||
function shotThumb(id) {
|
||||
const thumbs = shots.thumbs; // this headset's: a late failure must not touch the next one's
|
||||
if (!thumbs.has(id)) {
|
||||
const p = shotBlob(id, true).then(b => URL.createObjectURL(b));
|
||||
p.catch(() => { if (thumbs.get(id) === p) thumbs.delete(id); }); // try again next time
|
||||
thumbs.set(id, p);
|
||||
}
|
||||
return thumbs.get(id);
|
||||
}
|
||||
// quiet: a background check. It keeps what's shown if the Frame can't be read,
|
||||
// and redraws only when the shots (or whether they're saved here) changed.
|
||||
function loadShots(quiet) {
|
||||
if (shots.loading) {
|
||||
// A check already on its way will do for another check; Refresh, or a save
|
||||
// that just finished, reads again after it so the answer is a fresh one.
|
||||
const again = () => loadShots();
|
||||
return quiet === true ? shots.loading : shots.loading.then(again, again);
|
||||
}
|
||||
const p = readShots(quiet === true).finally(() => { if (shots.loading === p) shots.loading = null; });
|
||||
return shots.loading = p;
|
||||
}
|
||||
// Forget the shots of a headset we've switched away from, and ignore its answers still on their way.
|
||||
function resetShots() {
|
||||
closeMenu(); // its items were about the other headset's shot
|
||||
shots.gen++; shots.fill++; shots.loading = null; shots.list = []; shots.sig = null;
|
||||
for (const p of shots.thumbs.values()) p.then(URL.revokeObjectURL, () => {});
|
||||
shots.thumbs = new Map();
|
||||
}
|
||||
async function readShots(quiet) {
|
||||
const gen = shots.gen;
|
||||
if (!quiet) $("shotsRefresh").disabled = true;
|
||||
let got;
|
||||
try {
|
||||
shots.list = (await api("/api/shots")).shots;
|
||||
got = await api("/api/shots");
|
||||
} catch (e) {
|
||||
return failed($("shotGrid"), e);
|
||||
if (!quiet && gen === shots.gen) { shots.sig = null; failed($("shotGrid"), e); }
|
||||
return;
|
||||
} finally { $("shotsRefresh").disabled = false; }
|
||||
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
||||
if (gen !== shots.gen) return;
|
||||
({ shots: shots.list, folder: shots.folder } = got);
|
||||
const sig = JSON.stringify(shots.list.map(s => [s.id, s.saved, shotApp(s.appid)]));
|
||||
if (sig !== shots.sig) { shots.sig = sig; drawShots(); }
|
||||
fillThumbs(quiet); // not waited for: the next check, or a save, needn't sit behind the thumbnails
|
||||
}
|
||||
// Thumbnails one at a time over the shared SSH connection. One that failed is
|
||||
// tried again on Refresh, not by every background check.
|
||||
async function fillThumbs(quiet) {
|
||||
const run = ++shots.fill;
|
||||
// Refresh puts every failed one back in line first, so a background pass that takes over carries them all on.
|
||||
if (!quiet) for (const img of document.querySelectorAll("#shotGrid img[data-failed]")) delete img.dataset.failed;
|
||||
for (const img of document.querySelectorAll("#shotGrid img[data-shot]:not([src])" + (quiet ? ":not([data-failed])" : ""))) {
|
||||
const s = shots.list[+img.dataset.shot];
|
||||
let url;
|
||||
try { url = await shotThumb(s.id); } catch (e) { img.alt = "Preview failed"; img.dataset.failed = 1; }
|
||||
if (run !== shots.fill) return; // a newer pass (after a check, a redraw or another headset) has taken over
|
||||
if (url) img.src = url;
|
||||
}
|
||||
}
|
||||
// "Sep 28, 10:37 PM": short enough to sit beside the card's buttons (the full date is its tooltip).
|
||||
function shotTime(t) {
|
||||
return new Date(t * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
function drawShots() {
|
||||
const ids = new Set(shots.list.map(s => s.id));
|
||||
for (const [id, p] of shots.thumbs) {
|
||||
if (!ids.has(id)) { shots.thumbs.delete(id); p.then(URL.revokeObjectURL, () => {}); }
|
||||
}
|
||||
const unsaved = shots.list.filter(s => !s.saved).length;
|
||||
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved && !HOST.mobile ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
|
||||
$("shotsSaveNew").disabled = HOST.mobile ? !shots.list.length : !unsaved;
|
||||
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
|
||||
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
|
||||
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card" data-card="${i}">
|
||||
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer (right-click for more)">
|
||||
<div class="row"><div class="grow">
|
||||
<div class="t">${esc(shotApp(s.appid))}</div>
|
||||
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
|
||||
<div class="s" title="${esc(new Date(s.time * 1000).toLocaleString())}">${esc(shotTime(s.time))}</div></div>
|
||||
<button class="small" data-shot-copy="${i}" title="Copy the image to the clipboard">Copy</button>
|
||||
${s.saved && !HOST.mobile ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
||||
</div></div>`).join("")
|
||||
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
||||
// Thumbnails one at a time over the shared SSH connection.
|
||||
for (const img of document.querySelectorAll("#shotGrid img[data-shot]")) {
|
||||
const s = shots.list[+img.dataset.shot];
|
||||
try {
|
||||
const url = URL.createObjectURL(await shotBlob(s.id, true));
|
||||
shots.urls.push(url);
|
||||
img.src = url;
|
||||
} catch (e) { img.alt = "Preview failed"; }
|
||||
if (!img.isConnected) return; // the list was reloaded meanwhile
|
||||
}
|
||||
}
|
||||
async function openShot(s) {
|
||||
if (live) toggleLive(false);
|
||||
@@ -3556,7 +3673,7 @@ async function openShot(s) {
|
||||
draw();
|
||||
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
|
||||
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
|
||||
$("saveBtn").disabled = false;
|
||||
$("saveBtn").disabled = $("copyBtn").disabled = false;
|
||||
$("view").scrollIntoView({ behavior: "smooth" });
|
||||
} catch (e) {
|
||||
toast("Couldn't open the screenshot: " + e.message, true);
|
||||
@@ -3575,15 +3692,95 @@ async function saveShots(list, btn) {
|
||||
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
|
||||
if (res) loadShots();
|
||||
}
|
||||
const copyShot = (s, btn) => act("Copy screenshot", () => copyImage(shotBlob(s.id, false)), btn);
|
||||
$("shotGrid").onclick = e => {
|
||||
const img = e.target.closest("img[data-shot]");
|
||||
if (img) return openShot(shots.list[+img.dataset.shot]);
|
||||
const c = e.target.closest("[data-shot-copy]");
|
||||
if (c) return copyShot(shots.list[+c.dataset.shotCopy], c);
|
||||
const b = e.target.closest("[data-shot-save]");
|
||||
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
|
||||
};
|
||||
$("shotsRefresh").onclick = loadShots;
|
||||
// A small right-click menu. items: [label, fn] pairs, null for a divider.
|
||||
// back: the keyboard closed it, so focus returns to where it was.
|
||||
function closeMenu(back) {
|
||||
const menu = document.querySelector(".ctx-menu");
|
||||
if (!menu) return;
|
||||
menu.remove();
|
||||
if (back === true && menu.opener?.isConnected) menu.opener.focus();
|
||||
}
|
||||
function showMenu(e, items) {
|
||||
closeMenu();
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "ctx-menu";
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.opener = document.activeElement;
|
||||
for (const it of items) {
|
||||
if (!it) { menu.append(document.createElement("hr")); continue; }
|
||||
const b = document.createElement("button");
|
||||
b.textContent = it[0];
|
||||
b.setAttribute("role", "menuitem");
|
||||
b.onclick = () => { closeMenu(); it[1](); };
|
||||
menu.append(b);
|
||||
}
|
||||
(document.fullscreenElement || document.body).append(menu); // in front of a fullscreen viewer
|
||||
const r = menu.getBoundingClientRect();
|
||||
menu.style.left = Math.max(4, Math.min(e.clientX, innerWidth - r.width - 4)) + "px";
|
||||
menu.style.top = Math.max(4, Math.min(e.clientY, innerHeight - r.height - 4)) + "px";
|
||||
menu.querySelector("button").focus();
|
||||
}
|
||||
document.addEventListener("pointerdown", e => { if (!e.target.closest(".ctx-menu")) closeMenu(); }, true);
|
||||
document.addEventListener("keydown", e => {
|
||||
const menu = document.querySelector(".ctx-menu");
|
||||
if (!menu) return;
|
||||
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); return closeMenu(true); } // Tab doesn't wander off behind it
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
e.preventDefault(); // arrows move through the menu, not the page
|
||||
const items = [...menu.querySelectorAll("button")];
|
||||
const at = items.indexOf(document.activeElement), step = e.key === "ArrowDown" ? 1 : -1;
|
||||
items[at < 0 ? (step > 0 ? 0 : items.length - 1) : (at + step + items.length) % items.length].focus();
|
||||
});
|
||||
addEventListener("blur", closeMenu);
|
||||
document.addEventListener("fullscreenchange", closeMenu);
|
||||
addEventListener("scroll", closeMenu, true);
|
||||
// Right-click a screenshot for everything it can do.
|
||||
$("shotGrid").oncontextmenu = e => {
|
||||
const card = e.target.closest("[data-card]");
|
||||
if (!card) return;
|
||||
e.preventDefault();
|
||||
const s = shots.list[+card.dataset.card];
|
||||
const items = [
|
||||
["Open in viewer", () => openShot(s)],
|
||||
["Copy image", () => copyShot(s)],
|
||||
];
|
||||
if (!s.saved || HOST.mobile) items.push(null, [`Save to ${HOST.computer}`, () => saveShots([s])]);
|
||||
else {
|
||||
const sep = shots.folder.includes("\\") ? "\\" : "/";
|
||||
items.push(null);
|
||||
items.push([`Show in ${!HOST.fileManager || HOST.fileManager === "your file manager" ? "folder" : HOST.fileManager}`,
|
||||
() => act("Show screenshot", () => api("/api/open", { what: "shot", id: s.id }))]);
|
||||
items.push(["Copy file path", () => act("Copy file path", async () => {
|
||||
await copyText(shots.folder + sep + s.file);
|
||||
return { message: "Copied the file path" };
|
||||
})]);
|
||||
}
|
||||
items.push(null, ["Copy file name", () => act("Copy file name", async () => {
|
||||
await copyText(s.file);
|
||||
return { message: "Copied " + s.file };
|
||||
})]);
|
||||
showMenu(e, items);
|
||||
};
|
||||
function copyText(text) {
|
||||
if (!navigator.clipboard?.writeText) throw new Error("This browser can't copy here");
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
$("shotsRefresh").onclick = () => loadShots();
|
||||
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
||||
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
|
||||
// Watch for new shots: a cheap listing over the shared SSH connection while the
|
||||
// window is visible and the Frame is reachable, and again on coming back to it.
|
||||
setInterval(() => { if (!document.hidden && online) loadShots(true); }, SHOTS_POLL_MS);
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online) loadShots(true); });
|
||||
|
||||
// ---- Panel switcher: our UI over SteamVR's panel API ----
|
||||
let panelSeq = 0;
|
||||
@@ -3964,7 +4161,68 @@ $("noticeMore").onclick = async () => {
|
||||
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"; };
|
||||
loadTelemetry();
|
||||
const telemetryLoaded = loadTelemetry();
|
||||
|
||||
// ---- 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.";
|
||||
}
|
||||
async function saveContact(change) {
|
||||
const s = await api("/api/contact", change);
|
||||
renderContact(s);
|
||||
return s;
|
||||
}
|
||||
async function loadContact() {
|
||||
await telemetryLoaded;
|
||||
try { renderContact(await api("/api/contact")); } catch { return; }
|
||||
checkContactPrompt();
|
||||
}
|
||||
// One time only, only once the Frame has connected, and never on top of the privacy notice or
|
||||
// straight after it (two asks in a row is nagging): checked at load and whenever the Frame connects.
|
||||
async function checkContactPrompt() {
|
||||
if (!$("contactNotice").hidden || !$("privacyNotice").hidden) return;
|
||||
let s;
|
||||
try { s = await api("/api/contact"); } catch { return; }
|
||||
if (!s.showPrompt || !$("contactNotice").hidden || !$("privacyNotice").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 });
|
||||
$("contactNotice").hidden = true;
|
||||
toast("Thanks! Change or remove it any time in Privacy & updates.");
|
||||
} catch (err) { $("cpMsg").textContent = `Couldn't save: ${err.message}`; }
|
||||
finally { $("cpSave").disabled = false; }
|
||||
};
|
||||
$("cpNo").onclick = async () => {
|
||||
try { await api("/api/contact/prompt", { prompt: "dismissed" }); $("contactNotice").hidden = true; }
|
||||
catch (err) { $("cpMsg").textContent = `Couldn't save that: ${err.message}. Try again.`; }
|
||||
};
|
||||
$("contactForm").onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const email = $("cEmail").value.trim();
|
||||
if (email && !$("cUpdates").checked && !$("cFollowup").checked) {
|
||||
$("cStatus").textContent = "Tick what your email may be used for, or use Remove my email."; return;
|
||||
}
|
||||
try { await saveContact({ email, updates: $("cUpdates").checked, followup: $("cFollowup").checked });
|
||||
toast(email ? "Contact email saved." : "Contact email removed."); }
|
||||
catch (err) { toast(`Couldn't save: ${err.message}`, true); }
|
||||
};
|
||||
$("cRemove").onclick = async () => {
|
||||
try { await saveContact({ email: "", updates: false, followup: false }); toast("Contact email removed. Neither choice applies any more."); }
|
||||
catch (err) { toast(`Couldn't remove it: ${err.message}`, true); }
|
||||
};
|
||||
loadContact();
|
||||
function pageEvent(event, properties) {
|
||||
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
|
||||
}
|
||||
@@ -3979,8 +4237,8 @@ document.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () =
|
||||
const bug = { preview: "" };
|
||||
const activityLines = () => [...$("log").children].slice(0, 25).map(el => el.textContent.trim());
|
||||
function bugReportText() {
|
||||
const contact = $("bugContact").value.trim();
|
||||
const body = `Kind: ${$("bugKind").value}${contact ? `\nContact: ${contact}` : ""}\n\n${$("bugText").value.trim()}${bug.preview ? "\n\n---\nDiagnostics:\n```\n" + bug.preview + "\n```" : ""}`;
|
||||
const email = $("bugFollowup").checked ? $("bugContact").value.trim() : "";
|
||||
const body = `Kind: ${$("bugKind").value}${email ? `\nFollow-up questions welcome: ${email}` : ""}\n\n${$("bugText").value.trim()}${bug.preview ? "\n\n---\nDiagnostics:\n```\n" + bug.preview + "\n```" : ""}`;
|
||||
return { title: $("bugTitleIn").value.trim(), body };
|
||||
}
|
||||
// The preview is a snapshot: exactly this text is sent, even if more activity happens meanwhile.
|
||||
@@ -3996,12 +4254,20 @@ function openBugReport() {
|
||||
$("bugMsg").textContent = ""; $("bugSend").disabled = false;
|
||||
$("bugCancel").textContent = "Cancel";
|
||||
$("bugDiagBox").open = false; $("bugLogs").disabled = false;
|
||||
// A standing yes to follow-up questions (Privacy & updates) fills this in; it can be unticked.
|
||||
$("bugFollowup").checked = contact.followup; $("bugContact").value = contact.followup ? contact.email : "";
|
||||
$("bugContact").disabled = !contact.followup; $("bugContact").required = contact.followup;
|
||||
$("bugDlg").showModal();
|
||||
loadBugPreview();
|
||||
}
|
||||
document.body.addEventListener("click", e => { if (e.target.closest("[data-report]")) openBugReport(); });
|
||||
$("bugDiag").onchange = () => { $("bugLogs").disabled = !$("bugDiag").checked; loadBugPreview(); };
|
||||
$("bugLogs").onchange = loadBugPreview;
|
||||
$("bugFollowup").onchange = () => {
|
||||
const on = $("bugFollowup").checked;
|
||||
$("bugContact").disabled = !on; $("bugContact").required = on;
|
||||
if (on && !$("bugContact").value) { $("bugContact").value = contact.email; $("bugContact").focus(); }
|
||||
};
|
||||
$("bugCancel").onclick = () => $("bugDlg").close();
|
||||
$("bugCopy").onclick = async () => {
|
||||
const { title, body } = bugReportText();
|
||||
@@ -4015,7 +4281,8 @@ $("bugForm").onsubmit = async e => {
|
||||
try {
|
||||
const res = await api("/api/report", {
|
||||
kind: $("bugKind").value, title: $("bugTitleIn").value, message: $("bugText").value,
|
||||
contact: $("bugContact").value, diagnostics: $("bugDiag").checked ? bug.preview : "" });
|
||||
contactFollowup: $("bugFollowup").checked, contact: $("bugFollowup").checked ? $("bugContact").value : "",
|
||||
diagnostics: $("bugDiag").checked ? bug.preview : "" });
|
||||
$("bugMsg").textContent = `Sent, thank you. Your reference is ${res.id}.`;
|
||||
$("bugCancel").textContent = "Close";
|
||||
log(res.message, "ok");
|
||||
@@ -4177,7 +4444,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
|
||||
}
|
||||
|
||||
setView("headset");
|
||||
refresh().then(loadShots); // after status, so app names resolve
|
||||
refresh().then(() => loadShots()); // after status, so app names resolve
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||
</script>
|
||||
<script>
|
||||
@@ -4298,7 +4565,7 @@ function onConnection(s) {
|
||||
lastImg = null; lastShot = null;
|
||||
$("canvas").hidden = true; $("viewerEmpty").hidden = false; $("zoombar").hidden = true;
|
||||
["stamp", "srcBadge", "asleep"].forEach(id => $(id).hidden = true);
|
||||
$("saveBtn").disabled = true;
|
||||
$("saveBtn").disabled = $("copyBtn").disabled = true;
|
||||
// Lists and their buttons (Remove, Launch…) were the other headset's: clear them
|
||||
// before anyone clicks one, until the new headset's arrive.
|
||||
["games", "titleList", "andApps", "flatpaks", "shotGrid", "gmGrid"].forEach(id => {
|
||||
@@ -4307,7 +4574,7 @@ function onConnection(s) {
|
||||
disp.list = []; disp.port = null;
|
||||
// The lists behind those panels too, so filters can't bring the old ones back.
|
||||
gm.owned = null; gm.byId = new Map(); gm.results = []; gm.store = []; gm.storeQ = null; gm.shown = 0; gm.seq++;
|
||||
androidApps = []; shots.list = [];
|
||||
androidApps = []; resetShots();
|
||||
titlesSeq++; disp.seq++; // answers to loads already on their way are ignored
|
||||
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
|
||||
// Confirmations still open were checked against the other headset.
|
||||
@@ -4323,7 +4590,7 @@ function onConnection(s) {
|
||||
$("offline").hidden = true;
|
||||
const reload = link.reload;
|
||||
link.reload = false;
|
||||
refresh().then(() => { if (reload) reloadAll(); });
|
||||
refresh().then(() => { if (reload) reloadAll(); checkContactPrompt(); });
|
||||
} else if (s.phase === "failed" && s.error) {
|
||||
setOnline(false, s.error.message);
|
||||
} else if (s.phase === "connecting" && prev && prev.phase === "connected") {
|
||||
|
||||
@@ -50,6 +50,7 @@ import frame_catalog # noqa: E402
|
||||
import frame_devices # noqa: E402
|
||||
import frame_steamgriddb
|
||||
import frame_comfort # noqa: E402
|
||||
import frame_contact # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_link # noqa: E402
|
||||
import frame_macview # noqa: E402
|
||||
@@ -1192,6 +1193,12 @@ def open_thing(body):
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
frame_host.open_path(SHOTS_DIR)
|
||||
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
||||
if what == "shot":
|
||||
saved = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1]
|
||||
if not saved.exists():
|
||||
raise Failure("That screenshot isn't saved on this computer yet", 404)
|
||||
frame_host.reveal_path(saved)
|
||||
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
raise Failure("unknown target", 400)
|
||||
@@ -2151,6 +2158,7 @@ POST = {
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
||||
"/api/contact": frame_contact.save, "/api/contact/prompt": frame_contact.prompt,
|
||||
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action,
|
||||
"/api/devices": lambda body: devices_post(body)}
|
||||
|
||||
@@ -2380,6 +2388,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(macview_state(parse_qs(url.query)))
|
||||
elif path == "/api/telemetry":
|
||||
self.send_json(frame_telemetry.state())
|
||||
elif path == "/api/contact":
|
||||
self.send_json(frame_contact.state())
|
||||
elif path == "/api/computer/state":
|
||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
||||
elif path == "/api/status":
|
||||
@@ -2644,6 +2654,7 @@ def main():
|
||||
sweep_tmp()
|
||||
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
||||
frame_telemetry.start()
|
||||
frame_contact.start()
|
||||
global LINK, _ONE_SERVER
|
||||
if not LOCAL:
|
||||
if not PRIVATE: # a private server only uses the headsets (see one_server)
|
||||
|
||||
Reference in new issue
Block a user