mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90034540ae | ||
|
|
5b4efc87b3 | ||
|
|
b9f96866e5 | ||
|
|
0bb2f9eb03 | ||
|
|
22548bc42f | ||
|
|
1863c92101 | ||
|
|
22bb9c6469 | ||
|
|
c0ae75d56b | ||
|
|
0520082586 | ||
|
|
36dd5a05f2 | ||
|
|
36db6b1ac6 | ||
|
|
12aaec7888 | ||
|
|
7ea999d5b4 | ||
|
|
cb0a90a9c3 | ||
|
|
32fbbfe475 | ||
|
|
fa6d4fd81b | ||
|
|
7cc4abaffa | ||
|
|
dc4a64a9c3 |
No files matched your search
@@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
|||||||
|
|
||||||
This is a first public test, so reports are really useful, especially from
|
This is a first public test, so reports are really useful, especially from
|
||||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||||
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
|
||||||
diagnostics with personal details removed, shows you exactly what's included,
|
diagnostics with personal details removed, shows you exactly what's included,
|
||||||
and sends it privately to the maintainer; nothing is published. Without the app,
|
and sends it privately to the maintainer; nothing is published. Without the app,
|
||||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||||
|
|||||||
+16
-4
@@ -3,8 +3,9 @@
|
|||||||
Frame Control can manage more than one Steam Frame, and each headset can be
|
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||||
reached at more than one address: a LAN IP at home, another at the office, its
|
reached at more than one address: a LAN IP at home, another at the office, its
|
||||||
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||||
tab (key 5) lists them, and the connection pill in the header shows what the
|
tab (key 5) lists them. The connection pill in the header says whether the one in
|
||||||
app is doing to reach the one in use, step by step, as it happens.
|
use is connected and how (Tailscale, or the network's name); click it to add,
|
||||||
|
edit or reorder that headset's addresses, or to see each step of connecting.
|
||||||
|
|
||||||
The code is in three modules, all stdlib-only Python on your computer:
|
The code is in three modules, all stdlib-only Python on your computer:
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ and ranks them:
|
|||||||
5. addresses that only ever worked on other networks;
|
5. addresses that only ever worked on other networks;
|
||||||
6. Tailscale addresses while Tailscale is off.
|
6. Tailscale addresses while Tailscale is off.
|
||||||
|
|
||||||
Your order on the Devices tab breaks ties. The best-ranked address that answers
|
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
|
||||||
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
||||||
still trying. If SSH to the winner fails in a way another address could fix
|
still trying. If SSH to the winner fails in a way another address could fix
|
||||||
(a different device answered there, or the link dropped), the next one that
|
(a different device answered there, or the link dropped), the next one that
|
||||||
@@ -72,6 +73,16 @@ status --json`, including the Mac app's own CLI) with buttons to add their
|
|||||||
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
||||||
services and checks `ALIAS.local` and `frame.local`.
|
services and checks `ALIAS.local` and `frame.local`.
|
||||||
|
|
||||||
|
**The pill's dialog** lists the same addresses, with what each one answered, and
|
||||||
|
can add, edit, reorder and remove them without leaving the page you're on. When
|
||||||
|
the headset reports a LAN IP on the same network as this computer and that IP
|
||||||
|
isn't saved, it offers to add it. The offer puts the address first in the list,
|
||||||
|
so on that network it wins over the Tailscale name; away from home the Tailscale
|
||||||
|
name still leads. A new or edited address is tested straight away. While
|
||||||
|
connected, **Reconnect** applies your changes now rather than at the next
|
||||||
|
connection: it tries the addresses again, ranked as above, and the best-ranked
|
||||||
|
one that answers promptly wins. It doesn't pick a particular address.
|
||||||
|
|
||||||
## Networks
|
## Networks
|
||||||
|
|
||||||
A network is told apart by its default gateway: the router's IP address plus its
|
A network is told apart by its default gateway: the router's IP address plus its
|
||||||
@@ -122,7 +133,8 @@ file per headset instead, so saving or forgetting one headset's key never touche
|
|||||||
another's: a different device answering at one of its
|
another's: a different device answering at one of its
|
||||||
addresses is refused, and the pill says so. A headset's first connection trusts
|
addresses is refused, and the pill says so. A headset's first connection trusts
|
||||||
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
|
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
|
||||||
headset has a new key; **Forget identity** on the Devices tab lets the next
|
headset has a new key; **Forget identity** (Devices tab → **Advanced**, with the
|
||||||
|
SSH alias, user and port) lets the next
|
||||||
connection save the new one.
|
connection save the new one.
|
||||||
|
|
||||||
## One server at a time
|
## One server at a time
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
Locked real-Frame repeat, 2026-09-29
|
||||||
|
SteamOS 0.4.1, BUILD_ID 20260925.6191901; aarch64.
|
||||||
|
mkdir /tmp/frame-test.lock succeeded before installs/launches; rmdir issued after cleanup.
|
||||||
|
Preflight battery 44%, charging; before WiVRn 46%, before ALVR 47%, cleanup 47%.
|
||||||
|
Original Steam PID 49823 and vrserver PID 49571 present after cleanup.
|
||||||
|
Same unmodified upstream release APKs and SHA-256s as 2026-09-28.txt.
|
||||||
|
Installer functions loaded from a613735 before checkout was fast-forwarded to current main.
|
||||||
|
No compatibility layer injected. Per-app immersive Lepton instances, Steam shortcut launches.
|
||||||
|
Headset unworn. No Linux gaming host. No pairing or streaming session reached.
|
||||||
|
|
||||||
|
WIVRN: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.202 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.210 1153 1181 I WiVRn : [2026-09-29 01:03:15.210] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.248 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.256 1153 1181 I WiVRn : [2026-09-29 01:03:15.256] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.257 1153 1181 E WiVRn : [2026-09-29 01:03:15.257] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||||
|
|
||||||
|
ALVR: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1167 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1165 I RustStdoutStderr: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: panicked at alvr/client_openxr/src/lib.rs:220:10:
|
||||||
|
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
|
||||||
|
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||||
|
|
||||||
|
Cleanup: both test app directories, compatdata, shadercache, containers and shortcuts absent.
|
||||||
|
Capture output directory removed. No global settings changed; Steam/SteamVR not stopped.
|
||||||
|
The shared lock was subsequently acquired by another thread (new directory timestamp 11:03:49 +1000).
|
||||||
|
Native clients and Valve host streaming not exercised: no native build or Linux gaming host available.
|
||||||
@@ -22,9 +22,9 @@ The window has five tabs: **Home** (headset view, status, screenshots),
|
|||||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||||
connection pill in the header always shows which headset, which network this
|
connection pill in the header always shows which headset and whether it's
|
||||||
computer is on, the address in use or being tried, and each step of connecting
|
connected, and how (Tailscale, or the network's name). Click it to add, edit or
|
||||||
as it happens; click it for the whole timeline. When the Frame can't be
|
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
|
||||||
reached, a banner says why in plain words, what was tried, and counts down to
|
reached, a banner says why in plain words, what was tried, and counts down to
|
||||||
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
||||||
counts them while they run.
|
counts them while they run.
|
||||||
|
|||||||
@@ -38,6 +38,33 @@ after its container exited. No headset was worn and no host was connected.
|
|||||||
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
||||||
SteamVR's original process remained running. No global settings changed.
|
SteamVR's original process remained running. No global settings changed.
|
||||||
|
|
||||||
|
### Locked repeat, 2026-09-29 (verified)
|
||||||
|
|
||||||
|
Acquired `/tmp/frame-test.lock` before installing or launching anything and
|
||||||
|
released it after cleanup. Battery was 44% and charging at preflight, 46–47%
|
||||||
|
during the launches, and 47% at cleanup. The SteamOS version/build was unchanged.
|
||||||
|
|
||||||
|
Reinstalled and launched both original APKs in immersive Lepton instances.
|
||||||
|
WiVRn again failed at OpenXR 1.1 and 1.0 with the missing timespec extension;
|
||||||
|
ALVR again panicked on `ERROR_EXTENSION_NOT_PRESENT`. This repeats the
|
||||||
|
unmodified-client test, not the newer installer's automatic compatibility-layer
|
||||||
|
path. Neither reached a session that could be paired or exercised further.
|
||||||
|
Fresh [journal excerpts and cleanup evidence](evidence/linux-vr/2026-09-29.txt)
|
||||||
|
record the failures.
|
||||||
|
|
||||||
|
SteamVR's screenshot API returned a 1920×1080 headset capture after each
|
||||||
|
launch. Both are uniformly dark: [WiVRn](evidence/linux-vr/2026-09-29-wivrn.png)
|
||||||
|
and [ALVR](evidence/linux-vr/2026-09-29-alvr.png). These images do **not** prove
|
||||||
|
rendering or a working client. The headset was unworn; visibility, controllers,
|
||||||
|
frame rate and motion-to-photon latency could not be judged. The explicit
|
||||||
|
OpenXR errors, rather than the dark captures, establish the client blocker.
|
||||||
|
|
||||||
|
Both test installs, app data, shader caches, shortcuts, containers and temporary
|
||||||
|
capture files were removed. The original Steam and SteamVR process IDs were
|
||||||
|
unchanged. No reboot, power action or global setting change was used. Native
|
||||||
|
clients remain untested, and no Linux gaming host was available for Valve's
|
||||||
|
streaming path. The recommendation below is unchanged.
|
||||||
|
|
||||||
### Relation to the VR APK branch
|
### Relation to the VR APK branch
|
||||||
|
|
||||||
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
||||||
|
|||||||
+1
-1
@@ -97,7 +97,7 @@ The same error is sent at most once every 10 minutes.
|
|||||||
|
|
||||||
## Report a problem
|
## Report a problem
|
||||||
|
|
||||||
**Report a problem** is the warning-sign button in the header, also in the
|
**Report a problem** is the speech-bubble button in the header, also in the
|
||||||
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
||||||
privately to Frame Control's PostHog project as a `problem_report` event, the
|
privately to Frame Control's PostHog project as a `problem_report` event, the
|
||||||
same way as the analytics above, so only the maintainer can read it and
|
same way as the analytics above, so only the maintainer can read it and
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ it twice reuses the existing process.
|
|||||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
|
||||||
|
|
||||||
OpenVR uses background application mode, which does not start SteamVR or keep
|
OpenVR uses background application mode, which does not start SteamVR or keep
|
||||||
it running. This mode also returned live timing in a read-only device probe.
|
it running. This mode also returned live timing in a read-only device probe.
|
||||||
|
|||||||
@@ -296,6 +296,20 @@ class Pins(Base):
|
|||||||
|
|
||||||
|
|
||||||
class Registry(Base):
|
class Registry(Base):
|
||||||
|
def test_an_address_added_first_wins_on_its_own_network(self):
|
||||||
|
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
|
||||||
|
# worked here before. The LAN address has to go ahead of it to be used at home.
|
||||||
|
d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"])
|
||||||
|
self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0)
|
||||||
|
self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True)
|
||||||
|
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it
|
||||||
|
addrs = self.reg.get(d["id"])["addresses"]
|
||||||
|
self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"])
|
||||||
|
at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
|
||||||
|
self.assertEqual(at_home[0], "192.168.1.40")
|
||||||
|
away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)]
|
||||||
|
self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads
|
||||||
|
|
||||||
def test_address_editing(self):
|
def test_address_editing(self):
|
||||||
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
||||||
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
||||||
|
|||||||
@@ -457,6 +457,47 @@ class Connecting(unittest.TestCase):
|
|||||||
self.assertEqual(self.routes, [])
|
self.assertEqual(self.routes, [])
|
||||||
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
||||||
|
|
||||||
|
def test_a_test_started_earlier_cant_overwrite_a_newer_one(self):
|
||||||
|
d = self.device("nothing.invalid")
|
||||||
|
entered = {1: threading.Event(), 2: threading.Event()}
|
||||||
|
release = {1: threading.Event(), 2: threading.Event()}
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def probe(host, port, update=None):
|
||||||
|
calls.append(host)
|
||||||
|
n = len(calls)
|
||||||
|
entered[n].set()
|
||||||
|
release[n].wait(10)
|
||||||
|
return {"state": "refused", "detail": f"test {n}", "ip": None, "rtt_ms": None}
|
||||||
|
|
||||||
|
tests = [threading.Thread(target=self.link.test, args=(d["id"],), daemon=True) for _ in range(2)]
|
||||||
|
with mock.patch.object(fl, "probe", probe):
|
||||||
|
try:
|
||||||
|
tests[0].start()
|
||||||
|
self.assertTrue(entered[1].wait(5), "the first test never probed")
|
||||||
|
tests[1].start()
|
||||||
|
self.assertTrue(entered[2].wait(5), "the second test never probed")
|
||||||
|
# The first finishes while the second is still probing: it mustn't show its
|
||||||
|
# rows or mark the second done.
|
||||||
|
release[1].set()
|
||||||
|
tests[0].join(10)
|
||||||
|
self.assertFalse(tests[0].is_alive())
|
||||||
|
running = self.link.snapshot()["tests"][d["id"]]
|
||||||
|
self.assertFalse(running["done"])
|
||||||
|
self.assertEqual(running["rows"][0]["detail"], "Waiting")
|
||||||
|
release[2].set()
|
||||||
|
tests[1].join(10)
|
||||||
|
self.assertFalse(tests[1].is_alive())
|
||||||
|
finally:
|
||||||
|
for e in release.values():
|
||||||
|
e.set()
|
||||||
|
for t in tests:
|
||||||
|
if t.ident: # started
|
||||||
|
t.join(10)
|
||||||
|
result = self.link.snapshot()["tests"][d["id"]]
|
||||||
|
self.assertTrue(result["done"])
|
||||||
|
self.assertEqual(result["rows"][0]["detail"], "test 2")
|
||||||
|
|
||||||
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
||||||
self.device("localhost")
|
self.device("localhost")
|
||||||
self.hosts({"localhost": "ok"})
|
self.hosts({"localhost": "ok"})
|
||||||
|
|||||||
+4
-2
@@ -623,7 +623,9 @@ class Registry:
|
|||||||
return a
|
return a
|
||||||
raise DeviceError(f"{host} isn't one of this headset's addresses")
|
raise DeviceError(f"{host} isn't one of this headset's addresses")
|
||||||
|
|
||||||
def add_address(self, device_id, host, kind=None, label=""):
|
def add_address(self, device_id, host, kind=None, label="", first=False):
|
||||||
|
"""Add an address at the end of the list, or at the front (first=True), where the
|
||||||
|
user's order makes it win over the others that work on the same network."""
|
||||||
with self._changing():
|
with self._changing():
|
||||||
d = self._find(device_id)
|
d = self._find(device_id)
|
||||||
a = new_address(host, kind, label)
|
a = new_address(host, kind, label)
|
||||||
@@ -631,7 +633,7 @@ class Registry:
|
|||||||
raise DeviceError(f"{a['host']} is already on the list")
|
raise DeviceError(f"{a['host']} is already on the list")
|
||||||
if len(d["addresses"]) >= 32:
|
if len(d["addresses"]) >= 32:
|
||||||
raise DeviceError("That's enough addresses for one headset")
|
raise DeviceError("That's enough addresses for one headset")
|
||||||
d["addresses"].append(a)
|
d["addresses"].insert(0 if first else len(d["addresses"]), a)
|
||||||
self.save()
|
self.save()
|
||||||
return copy.deepcopy(a)
|
return copy.deepcopy(a)
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -169,6 +169,7 @@ class Link:
|
|||||||
self.version = 0
|
self.version = 0
|
||||||
self.stopped = False
|
self.stopped = False
|
||||||
self.kicks = [] # reasons someone asked for a (re)connect
|
self.kicks = [] # reasons someone asked for a (re)connect
|
||||||
|
self.test_gen = {} # device id -> its newest test of the addresses (see test())
|
||||||
self.busy = False # the loop is handling kicks
|
self.busy = False # the loop is handling kicks
|
||||||
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
|
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
|
||||||
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
|
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
|
||||||
@@ -955,9 +956,13 @@ class Link:
|
|||||||
started = now()
|
started = now()
|
||||||
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
|
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
|
||||||
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
|
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
|
||||||
|
with self.cond:
|
||||||
|
gen = self.test_gen[device_id] = self.test_gen.get(device_id, 0) + 1
|
||||||
|
|
||||||
def put(**fields):
|
def put(**fields):
|
||||||
with self.cond:
|
with self.cond:
|
||||||
|
if gen != self.test_gen[device_id]:
|
||||||
|
return # a newer test has started: its results are the ones to show
|
||||||
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
|
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
|
||||||
self.version += 1
|
self.version += 1
|
||||||
self.cond.notify_all()
|
self.cond.notify_all()
|
||||||
@@ -1111,7 +1116,8 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
|
|||||||
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
|
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
|
||||||
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
|
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
|
||||||
elif action == "address-add":
|
elif action == "address-add":
|
||||||
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
|
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "",
|
||||||
|
first=body.get("first") is True)
|
||||||
if is_active and link.state["phase"] == "failed":
|
if is_active and link.state["phase"] == "failed":
|
||||||
link.kick("retry")
|
link.kick("retry")
|
||||||
msg = f"Added {a['host']}"
|
msg = f"Added {a['host']}"
|
||||||
|
|||||||
+351
-132
@@ -27,8 +27,10 @@
|
|||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
[hidden] { display: none !important; } /* class display rules would otherwise win */
|
[hidden] { display: none !important; } /* class display rules would otherwise win */
|
||||||
html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); }
|
/* On a computer the header and the Activity bar are the window's chrome: the page scrolls in #scroll
|
||||||
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom));
|
between them, so neither covers anything. Phones scroll the window (see the phone rules below). */
|
||||||
|
body { margin: 0; color: var(--text); height: 100vh; display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
padding-bottom: calc(42px + env(safe-area-inset-bottom));
|
||||||
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
|
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
|
||||||
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
|
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
|
||||||
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
|
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
|
||||||
@@ -38,13 +40,14 @@
|
|||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
|
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
|
||||||
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px;
|
#scroll { flex: 1; min-height: 0; overflow: auto; scroll-behavior: smooth; scroll-padding-top: 12px; }
|
||||||
|
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px; flex: none; min-width: 0;
|
||||||
height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
|
height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
|
||||||
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
|
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
|
||||||
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
|
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; flex: none; }
|
||||||
.brand svg { width: 30px; height: 30px; }
|
.brand svg { width: 30px; height: 30px; }
|
||||||
.brand b { font-weight: 700; font-size: 15px; letter-spacing: 2.5px; }
|
.brand b { font-weight: 700; font-size: 15px; letter-spacing: 2.5px; }
|
||||||
nav { display: flex; gap: 4px; height: 100%; }
|
nav { display: flex; gap: 4px; height: 100%; flex: none; }
|
||||||
nav a { display: flex; align-items: center; padding: 0 13px; height: 100%; color: var(--text); text-decoration: none;
|
nav a { display: flex; align-items: center; padding: 0 13px; height: 100%; color: var(--text); text-decoration: none;
|
||||||
font-weight: 500; font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase;
|
font-weight: 500; font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase;
|
||||||
border-bottom: 3px solid transparent; transition: color .15s; }
|
border-bottom: 3px solid transparent; transition: color .15s; }
|
||||||
@@ -58,10 +61,18 @@
|
|||||||
.dot.off { background: var(--bad); }
|
.dot.off { background: var(--bad); }
|
||||||
|
|
||||||
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
|
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
|
||||||
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
|
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; align-items: start; }
|
||||||
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
|
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
|
||||||
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
|
/* The header gives way as the window narrows: the wordmark goes, then the tabs tighten, then the
|
||||||
@media (max-width: 820px) { .brand b { display: none; } }
|
connection pill drops its second line. It fits down to the app's 760 px minimum, with room for
|
||||||
|
the macOS window buttons the app adds on the left. */
|
||||||
|
header > button:not(.pill), header > .chip { flex: none; }
|
||||||
|
header > button:not(.pill):not(.chip) { width: 36px; padding: 0; justify-content: center; }
|
||||||
|
@media (max-width: 1340px) { .brand b { display: none; } header { gap: 22px; } }
|
||||||
|
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
|
||||||
|
@media (max-width: 960px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; }
|
||||||
|
.brand, #refreshAll { display: none; }
|
||||||
|
.pill .pt > b, .pill .via { display: none; } .pill .pt > span { font-size: 13px; color: var(--bright); } }
|
||||||
nav svg { display: none; }
|
nav svg { display: none; }
|
||||||
html { -webkit-text-size-adjust: 100%; }
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
body { -webkit-tap-highlight-color: transparent; }
|
body { -webkit-tap-highlight-color: transparent; }
|
||||||
@@ -87,13 +98,35 @@
|
|||||||
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
|
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
|
||||||
|
|
||||||
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
|
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
|
||||||
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 190px; flex: 0 1 auto; background: var(--btn); }
|
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 320px; min-width: 124px; flex: 0 1 auto; background: var(--btn); }
|
||||||
.pill .dot { flex: none; }
|
.pill .dot { flex: none; }
|
||||||
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
|
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
|
||||||
@keyframes pulse { 50% { opacity: .35; } }
|
@keyframes pulse { 50% { opacity: .35; } }
|
||||||
.pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; }
|
.pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; }
|
||||||
.pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
/* "Connected" is never cut short: when the route doesn't fit beside it, the route wraps out of sight. */
|
||||||
|
.pill .pt > span { font-size: 11.5px; color: var(--muted); max-width: 100%; display: flex; flex-wrap: wrap; height: 1.2em; overflow: hidden; white-space: nowrap; }
|
||||||
|
.pill .via { white-space: pre; }
|
||||||
|
.conn-now { display: flex; gap: 10px; align-items: baseline; }
|
||||||
|
.conn-now .dot { position: relative; top: -1px; }
|
||||||
|
.conn-now b { color: var(--bright); font-weight: 500; font-size: 15px; }
|
||||||
|
.conn-offer { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 14px; padding: 10px 12px; border-radius: 4px;
|
||||||
|
background: rgba(26,159,255,.1); border-left: 3px solid var(--blue); font-size: 13px; }
|
||||||
|
.conn-offer .grow { flex: 1 1 260px; }
|
||||||
|
.h3-note { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 6px; }
|
||||||
|
#connAddrs .item { gap: 6px; }
|
||||||
|
#connAddrs .t { font-size: 13.5px; overflow-wrap: anywhere; }
|
||||||
|
.conn-add { grid-template-columns: 2fr 1.2fr auto; }
|
||||||
|
.conn-edit { grid-template-columns: 2fr 1.2fr auto auto; }
|
||||||
|
.addr-add button.small, .dev-name button.small { height: 40px; }
|
||||||
|
.dev-name { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; max-width: 520px; }
|
||||||
|
.adv { margin-top: 18px; border-top: 1px solid rgba(255,255,255,.06); padding-top: 4px; }
|
||||||
|
.adv .dev-form { grid-template-columns: 1.3fr 1fr .6fr; }
|
||||||
|
.conn-more { margin-top: 16px; }
|
||||||
|
.conn-more summary { margin-top: 0; }
|
||||||
|
/* The dialog scrolls when the steps are open in a short window; its buttons stay in reach. */
|
||||||
|
#connDlg .rep-actions { position: sticky; bottom: -22px; margin: 16px -22px -22px; padding: 12px 22px;
|
||||||
|
background: #1e2329; border-top: 1px solid rgba(255,255,255,.06); }
|
||||||
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
|
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
|
||||||
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||||
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||||
@@ -134,8 +167,8 @@
|
|||||||
.addr .t { overflow-wrap: anywhere; }
|
.addr .t { overflow-wrap: anywhere; }
|
||||||
.addr .s { white-space: normal; }
|
.addr .s { white-space: normal; }
|
||||||
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
|
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
|
||||||
.addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; }
|
.addr-add { display: grid; grid-template-columns: 2fr 1.4fr auto; gap: 8px; margin-top: 12px; }
|
||||||
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } }
|
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add, .adv .dev-form, .conn-add, .conn-edit { grid-template-columns: 1fr; } }
|
||||||
.found { margin-top: 10px; }
|
.found { margin-top: 10px; }
|
||||||
|
|
||||||
/* ---- drop anywhere ---- */
|
/* ---- drop anywhere ---- */
|
||||||
@@ -149,8 +182,10 @@
|
|||||||
/* ---- panels and section headers ---- */
|
/* ---- panels and section headers ---- */
|
||||||
.panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%);
|
.panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%);
|
||||||
border-radius: 4px; padding: 18px; box-shadow: 0 8px 24px rgba(0,0,0,.25); min-width: 0; }
|
border-radius: 4px; padding: 18px; box-shadow: 0 8px 24px rgba(0,0,0,.25); min-width: 0; }
|
||||||
.shelf-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
|
/* Buttons in a heading row wrap onto a line of their own rather than squeeze the heading. */
|
||||||
.shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--text); }
|
.shelf-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 12px; margin: 0 0 14px; }
|
||||||
|
.panel > .shelf-head:first-child h2 { line-height: 28px; } /* as tall as a button, so headings line up across panels */
|
||||||
|
.shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--text); white-space: nowrap; }
|
||||||
.shelf-head .count { color: var(--muted); font-size: 13px; }
|
.shelf-head .count { color: var(--muted); font-size: 13px; }
|
||||||
.shelf-head .spacer { flex: 1; }
|
.shelf-head .spacer { flex: 1; }
|
||||||
.sub { color: var(--muted); font-size: 12.5px; }
|
.sub { color: var(--muted); font-size: 12.5px; }
|
||||||
@@ -179,6 +214,11 @@
|
|||||||
border-radius: 3px; padding: 9px 11px; font: inherit; }
|
border-radius: 3px; padding: 9px 11px; font: inherit; }
|
||||||
textarea { resize: vertical; min-height: 76px; }
|
textarea { resize: vertical; min-height: 76px; }
|
||||||
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
|
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
|
||||||
|
/* Every drop-down dark, like the fields; the panels' own rules below still size them. */
|
||||||
|
select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||||||
|
padding: 7px 8px; font: inherit; font-size: 13px; max-width: 100%; min-width: 0; }
|
||||||
|
select:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }
|
||||||
|
option { background: #1e2329; color: var(--text); }
|
||||||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
/* ---- headset view ---- */
|
/* ---- headset view ---- */
|
||||||
@@ -223,15 +263,21 @@
|
|||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
|
|
||||||
/* ---- device ---- */
|
/* ---- the headset's details: a menu under the battery chip ---- */
|
||||||
.battery { display: flex; gap: 16px; align-items: center; padding: 16px; border-radius: 3px; background: rgba(0,0,0,.22); }
|
.pop { position: fixed; inset: auto; margin: 0; width: min(390px, calc(100vw - 24px)); max-height: calc(100vh - 90px); overflow: auto;
|
||||||
.battery svg { width: 74px; height: 38px; flex: none; }
|
padding: 16px; border: 1px solid rgba(255,255,255,.1); border-radius: 6px; background: #1e2329; color: var(--text);
|
||||||
.battery .pct { font-size: 34px; font-weight: 300; color: var(--bright); line-height: 1; font-variant-numeric: tabular-nums; }
|
box-shadow: 0 20px 60px rgba(0,0,0,.6); -webkit-app-region: no-drag; }
|
||||||
.battery .state { font-weight: 700; font-size: 13px; letter-spacing: 1.2px; text-transform: uppercase; margin-top: 6px; }
|
.pop .shelf-head { margin-bottom: 10px; }
|
||||||
.battery .state.charging { color: var(--green-hi); } .battery .state.full { color: var(--green); }
|
.pop .shelf-head h2 { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.battery .state.low { color: var(--bad); } .battery .state.draining { color: var(--muted); }
|
.pop .stats { margin-top: 12px; }
|
||||||
.battery .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
|
.pop .chips { margin-top: 12px; }
|
||||||
|
.batt .state { font-weight: 700; font-size: 12.5px; letter-spacing: 1.2px; text-transform: uppercase; }
|
||||||
|
.batt .state.charging { color: var(--green-hi); } .batt .state.full { color: var(--green); }
|
||||||
|
.batt .state.low { color: var(--bad); } .batt .state.draining { color: var(--text); }
|
||||||
|
.batt .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
|
||||||
|
button.chip { height: 32px; padding: 0 12px; font-size: 13px; }
|
||||||
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; }
|
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; }
|
||||||
|
.stats > .stat:last-child:nth-child(odd) { grid-column: 1 / -1; }
|
||||||
.stat { background: rgba(0,0,0,.18); padding: 10px 12px; min-width: 0; }
|
.stat { background: rgba(0,0,0,.18); padding: 10px 12px; min-width: 0; }
|
||||||
.stat .k { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; }
|
.stat .k { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; }
|
||||||
.stat .v { color: var(--bright); font-size: 17px; margin-top: 1px; font-variant-numeric: tabular-nums;
|
.stat .v { color: var(--bright); font-size: 17px; margin-top: 1px; font-variant-numeric: tabular-nums;
|
||||||
@@ -322,7 +368,7 @@
|
|||||||
white-space: pre-wrap; padding: 12px calc(28px + env(safe-area-inset-right)) 12px calc(28px + env(safe-area-inset-left)); }
|
white-space: pre-wrap; padding: 12px calc(28px + env(safe-area-inset-right)) 12px calc(28px + env(safe-area-inset-left)); }
|
||||||
.log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); }
|
.log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); }
|
||||||
.log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); }
|
.log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); }
|
||||||
.toast { position: fixed; bottom: calc(58px + env(safe-area-inset-bottom)); right: calc(28px + env(safe-area-inset-right)); z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
|
.toast { position: fixed; inset: auto; margin: 0; border: 0; overflow: visible; width: auto; height: auto; bottom: calc(58px + env(safe-area-inset-bottom)); right: calc(28px + env(safe-area-inset-right)); z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
|
||||||
background: #3d4450; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.5); border-left: 3px solid var(--blue);
|
background: #3d4450; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.5); border-left: 3px solid var(--blue);
|
||||||
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
|
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
|
||||||
.toast.show { opacity: 1; transform: none; }
|
.toast.show { opacity: 1; transform: none; }
|
||||||
@@ -350,7 +396,7 @@
|
|||||||
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||||||
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||||
border-radius: 3px; padding: 8px 10px; font: inherit; }
|
border-radius: 3px; padding: 8px 10px; font: inherit; }
|
||||||
#titleForm select:focus { outline: none; border-color: var(--blue); }
|
#titleForm select:focus { border-color: var(--blue); }
|
||||||
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||||
#titleForm .note.warn { color: #d9a23a; }
|
#titleForm .note.warn { color: #d9a23a; }
|
||||||
#titleList { margin-top: 8px; }
|
#titleList { margin-top: 8px; }
|
||||||
@@ -397,7 +443,6 @@
|
|||||||
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
|
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
|
||||||
.f-0 { color: var(--muted); }
|
.f-0 { color: var(--muted); }
|
||||||
/* ---- Android display ---- */
|
/* ---- Android display ---- */
|
||||||
#mvQuality { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 4px 6px; font: inherit; font-size: 12px; }
|
|
||||||
.mv-windows { max-height: 420px; overflow-y: auto; }
|
.mv-windows { max-height: 420px; overflow-y: auto; }
|
||||||
.mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
|
.mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
|
||||||
.mv-perm .row { margin-top: 8px; }
|
.mv-perm .row { margin-top: 8px; }
|
||||||
@@ -406,7 +451,7 @@
|
|||||||
.mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; }
|
.mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; }
|
||||||
.disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
.disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||||||
padding: 8px 10px; font: inherit; font-size: 13px; }
|
padding: 8px 10px; font: inherit; font-size: 13px; }
|
||||||
.disp select:focus { outline: none; border-color: var(--blue); }
|
.disp select:focus { border-color: var(--blue); }
|
||||||
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
|
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
|
||||||
.disp select:disabled { opacity: .6; }
|
.disp select:disabled { opacity: .6; }
|
||||||
.disp .stat .v { font-size: 15px; }
|
.disp .stat .v { font-size: 15px; }
|
||||||
@@ -433,11 +478,17 @@
|
|||||||
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
|
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
|
||||||
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
|
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
|
||||||
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
|
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
|
||||||
html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
|
html { scroll-behavior: smooth; scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
|
||||||
|
body { height: auto; display: block; overflow: visible; }
|
||||||
|
#scroll { overflow: visible; }
|
||||||
/* No blur here: it would make the header the containing block of the fixed tab bar. */
|
/* No blur here: it would make the header the containing block of the fixed tab bar. */
|
||||||
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
|
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
|
||||||
backdrop-filter: none; background: rgb(23, 29, 37); }
|
backdrop-filter: none; background: rgb(23, 29, 37); }
|
||||||
.brand svg { width: 26px; height: 26px; }
|
.brand svg { width: 26px; height: 26px; }
|
||||||
|
.brand { display: flex; } #refreshAll { display: inline-flex; } /* the tabs are at the bottom here */
|
||||||
|
.pill .pt > b { display: block; } .pill .via { display: inline; } .pill .pt > span { font-size: 11.5px; color: var(--muted); }
|
||||||
|
.pill { min-width: 104px; }
|
||||||
|
@media (max-width: 374px) { .brand { display: none; } } /* the smallest phones: the pill needs the room */
|
||||||
header .chip { height: 30px; padding: 0 10px; }
|
header .chip { height: 30px; padding: 0 10px; }
|
||||||
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
|
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
|
||||||
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||||
@@ -623,7 +674,7 @@
|
|||||||
.detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; }
|
.detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; }
|
||||||
.sources-dialog { padding:24px; } .sources-dialog .store-close { margin:-12px -12px -20px 0; }
|
.sources-dialog { padding:24px; } .sources-dialog .store-close { margin:-12px -12px -20px 0; }
|
||||||
}
|
}
|
||||||
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
|
@media(prefers-reduced-motion:reduce) { html, #scroll { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -648,15 +699,29 @@
|
|||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
|
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
|
||||||
<select class="devsel desk-only" id="devSel" aria-label="Headset" title="Switch headset" hidden></select>
|
<select class="devsel desk-only" id="devSel" aria-label="Headset" title="Switch headset" hidden></select>
|
||||||
<span class="chip" id="battChip" title="Battery">—</span>
|
<button class="chip" id="battChip" title="Battery and headset details" popovertarget="devPop" aria-haspopup="dialog" hidden>—</button>
|
||||||
<button id="reportBtn" data-report title="Report a problem, with diagnostics" aria-label="Report a problem">
|
<button id="reportBtn" data-report title="Report a problem or send feedback" aria-label="Report a problem or send feedback">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16v11H9l-5 4z"/><path d="M8 9.5h8M8 12.5h5" stroke-linecap="round"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
|
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
<div class="pop" id="devPop" popover role="dialog" aria-labelledby="devPopTitle">
|
||||||
|
<div class="shelf-head"><h2 id="devPopTitle">Headset</h2><span class="count" id="devName"></span><span class="spacer"></span><span class="sub" id="updated"></span></div>
|
||||||
|
<div class="batt"><div class="state draining" id="battState"> </div><div class="detail" id="battDetail"> </div></div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><div class="k">Storage free</div><div class="v" id="sDisk">—</div><div class="meter"><i id="mDisk"></i></div></div>
|
||||||
|
<div class="stat"><div class="k">Memory used</div><div class="v" id="sMem">—</div><div class="meter"><i id="mMem"></i></div></div>
|
||||||
|
<div class="stat"><div class="k">Temperature</div><div class="v" id="sTemp">—</div><div class="d" id="dTemp"> </div></div>
|
||||||
|
<div class="stat"><div class="k">Wi-Fi</div><div class="v" id="sWifi">—</div><div class="d" id="dWifi"> </div></div>
|
||||||
|
<div class="stat"><div class="k">Uptime</div><div class="v" id="sUp">—</div><div class="d" id="dUp"> </div></div>
|
||||||
|
<div class="stat"><div class="k">SteamOS</div><div class="v" id="sOs">—</div><div class="d" id="dOs"> </div></div>
|
||||||
|
</div>
|
||||||
|
<div class="chips" id="services" aria-label="Running on the headset"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="scroll">
|
||||||
<main>
|
<main>
|
||||||
<div class="notice" id="updateBar" hidden>
|
<div class="notice" id="updateBar" hidden>
|
||||||
<div class="grow" id="updateText"></div>
|
<div class="grow" id="updateText"></div>
|
||||||
@@ -747,29 +812,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="device">
|
<section class="panel" id="device">
|
||||||
<div class="shelf-head"><h2>Device</h2><span class="count" id="devName"></span><span class="spacer"></span><span class="sub" id="updated"></span></div>
|
<div class="shelf-head"><h2>Volume</h2></div>
|
||||||
<div class="battery">
|
|
||||||
<svg viewBox="0 0 74 38" aria-hidden="true">
|
|
||||||
<rect x="1.5" y="1.5" width="64" height="35" rx="5" fill="none" stroke="#8f98a0" stroke-width="3"/>
|
|
||||||
<rect x="67" y="12" width="5.5" height="14" rx="2" fill="#8f98a0"/>
|
|
||||||
<rect id="battFill" x="6" y="6" width="0" height="26" rx="2.5" fill="#59bf40"/>
|
|
||||||
<path id="battBolt" d="M37 5 L27 21 H34 L31 33 L42 16 H35 Z" fill="#fff" stroke="#1e2329" stroke-width="1.6" stroke-linejoin="round" visibility="hidden"/>
|
|
||||||
</svg>
|
|
||||||
<div style="min-width:0">
|
|
||||||
<div class="pct" id="battPct">—</div>
|
|
||||||
<div class="state draining" id="battState"> </div>
|
|
||||||
<div class="detail" id="battDetail"> </div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats">
|
|
||||||
<div class="stat"><div class="k">Storage free</div><div class="v" id="sDisk">—</div><div class="meter"><i id="mDisk"></i></div></div>
|
|
||||||
<div class="stat"><div class="k">Memory used</div><div class="v" id="sMem">—</div><div class="meter"><i id="mMem"></i></div></div>
|
|
||||||
<div class="stat"><div class="k">Temperature</div><div class="v" id="sTemp">—</div><div class="d" id="dTemp"> </div></div>
|
|
||||||
<div class="stat"><div class="k">Wi-Fi</div><div class="v" id="sWifi">—</div><div class="d" id="dWifi"> </div></div>
|
|
||||||
<div class="stat"><div class="k">Uptime</div><div class="v" id="sUp">—</div><div class="d" id="dUp"> </div></div>
|
|
||||||
<div class="stat"><div class="k">SteamOS</div><div class="v" id="sOs">—</div><div class="d" id="dOs"> </div></div>
|
|
||||||
</div>
|
|
||||||
<div class="chips" id="services"></div>
|
|
||||||
<div class="vol">
|
<div class="vol">
|
||||||
<button class="small" id="muteBtn" aria-label="Mute">
|
<button class="small" id="muteBtn" aria-label="Mute">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 9h4l5-4v14l-5-4H4z"/><path id="volWaves" d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 9h4l5-4v14l-5-4H4z"/><path id="volWaves" d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
@@ -783,7 +826,7 @@
|
|||||||
<section class="panel" id="vrTools">
|
<section class="panel" id="vrTools">
|
||||||
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
|
||||||
<div class="stats" id="vrMetrics"></div>
|
<div class="stats" id="vrMetrics"></div>
|
||||||
<div class="hint">Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Temperature is the hottest readable sensor.</div>
|
<div class="hint">Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Battery and temperature are in the battery menu at the top.</div>
|
||||||
<div class="chips">
|
<div class="chips">
|
||||||
<button class="small" id="hudStart">Open HUD in headset</button>
|
<button class="small" id="hudStart">Open HUD in headset</button>
|
||||||
<button class="small" id="hudStop">Close HUD</button>
|
<button class="small" id="hudStop">Close HUD</button>
|
||||||
@@ -1087,22 +1130,23 @@
|
|||||||
|
|
||||||
<section class="panel" id="panels">
|
<section class="panel" id="panels">
|
||||||
<div class="shelf-head"><h2>Panel switcher</h2><span class="spacer"></span>
|
<div class="shelf-head"><h2>Panel switcher</h2><span class="spacer"></span>
|
||||||
<button class="small" id="panelsRefresh">Refresh</button>
|
<button class="small" id="panelsRefresh">Refresh</button></div>
|
||||||
<button class="small action" id="panelsOpen">Open in headset</button></div>
|
|
||||||
<div class="list" id="panelList"><div class="sub">Refresh to see open panels.</div></div>
|
<div class="list" id="panelList"><div class="sub">Refresh to see open panels.</div></div>
|
||||||
|
<div class="row" style="margin-top:12px"><button class="small action" id="panelsOpen">Open in headset</button></div>
|
||||||
<div class="hint">Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard.
|
<div class="hint">Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard.
|
||||||
Saved spatial layouts are not available yet.</div>
|
Saved spatial layouts are not available yet.</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="macview" hidden>
|
<section class="panel" id="macview" hidden>
|
||||||
<div class="shelf-head"><h2>Mac in the headset</h2><span class="count" id="mvCount"></span><span class="spacer"></span>
|
<div class="shelf-head"><h2>Mac in the headset</h2><span class="count" id="mvCount"></span><span class="spacer"></span>
|
||||||
<select id="mvQuality" title="Picture quality and bandwidth">
|
|
||||||
<option value="sharp">Sharp</option><option value="balanced" selected>Balanced</option>
|
|
||||||
<option value="light">Light</option><option value="compatible">Compatible (JPEG)</option>
|
|
||||||
</select>
|
|
||||||
<button class="small" id="mvRefresh">Refresh</button></div>
|
<button class="small" id="mvRefresh">Refresh</button></div>
|
||||||
<label class="mv-opt"><input type="checkbox" id="mvSeparate" checked> Give each window its own display
|
<label class="mv-opt"><input type="checkbox" id="mvSeparate" checked> Give each window its own display
|
||||||
<span class="sub">its menus and sheets come along, nothing covers it, and it goes back when you stop</span></label>
|
<span class="sub">its menus and sheets come along, nothing covers it, and it goes back when you stop</span></label>
|
||||||
|
<div class="row" style="margin:0 0 12px"><label for="mvQuality">Picture quality</label>
|
||||||
|
<select id="mvQuality" title="Sharper pictures use more of your network">
|
||||||
|
<option value="sharp">Sharp</option><option value="balanced" selected>Balanced</option>
|
||||||
|
<option value="light">Light</option><option value="compatible">Compatible (JPEG)</option>
|
||||||
|
</select></div>
|
||||||
<div class="mv-perm" id="mvPerm" hidden></div>
|
<div class="mv-perm" id="mvPerm" hidden></div>
|
||||||
<div class="list" id="mvDisplays"></div>
|
<div class="list" id="mvDisplays"></div>
|
||||||
<div class="shelf-head" style="margin-top:16px"><h2>Windows</h2><span class="count" id="mvWinCount"></span></div>
|
<div class="shelf-head" style="margin-top:16px"><h2>Windows</h2><span class="count" id="mvWinCount"></span></div>
|
||||||
@@ -1154,6 +1198,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
|
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
|
||||||
<span class="sub">Files go to <code>~/Downloads</code>; <code>.apk</code> files install as Android apps;
|
<span class="sub">Files go to <code>~/Downloads</code>; <code>.apk</code> files install as Android apps;
|
||||||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
|
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
|
||||||
@@ -1285,18 +1330,25 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="connDlg" class="dlg" aria-labelledby="connTitle">
|
<dialog id="connDlg" class="dlg" aria-labelledby="connTitle">
|
||||||
<h2 id="connTitle">Connection</h2>
|
<h2 id="connTitle">Connection</h2>
|
||||||
<div class="sub" id="connWhy"></div>
|
<div class="conn-now"><span class="dot" id="connDot"></span><div class="grow"><b id="connState"></b>
|
||||||
<h3>This computer</h3>
|
<div class="sub" id="connWhy"></div></div></div>
|
||||||
<dl class="facts" id="connNet"></dl>
|
<div class="conn-offer" id="connOffer" hidden></div>
|
||||||
<h3>Steps</h3>
|
<h3>Addresses <span class="h3-note">Frame Control tries them all at once; the first in the list wins a tie</span></h3>
|
||||||
<ol class="stages" id="connStages"></ol>
|
<div class="list" id="connAddrs"></div>
|
||||||
<h3>Addresses</h3>
|
<form class="addr-add conn-add" id="connAdd">
|
||||||
<table class="probes"><tbody id="connProbes"></tbody></table>
|
<input type="text" id="caHost" maxlength="253" placeholder="Add an IP address or host name" aria-label="New address">
|
||||||
|
<input type="text" id="caLabel" maxlength="60" placeholder="Label, e.g. Home" aria-label="Label for the new address">
|
||||||
|
<button class="small" type="submit">Add</button></form>
|
||||||
|
<details class="conn-more" id="connMore"><summary>Connection steps</summary>
|
||||||
|
<ol class="stages" id="connStages"></ol>
|
||||||
|
<h3>This computer</h3>
|
||||||
|
<dl class="facts" id="connNet"></dl>
|
||||||
|
</details>
|
||||||
<div class="row rep-actions"><span class="sub" id="connRetry"></span><span class="spacer"></span>
|
<div class="row rep-actions"><span class="sub" id="connRetry"></span><span class="spacer"></span>
|
||||||
<button type="button" class="small desk-only" id="connDevices">Manage headsets</button>
|
<button type="button" class="small desk-only" id="connDevices">All headsets…</button>
|
||||||
<button type="button" class="small desk-only" id="connSetup">Set Up Connection…</button>
|
<button type="button" class="small desk-only" id="connSetup" hidden>Set Up Connection…</button>
|
||||||
<button type="button" class="small action" id="connRetryBtn">Retry now</button>
|
<button type="button" class="small" id="connRetryBtn" hidden>Retry now</button>
|
||||||
<button type="button" class="small" id="connClose">Close</button></div>
|
<button type="button" class="small" id="connClose" autofocus>Done</button></div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="addDevDlg" class="dlg" aria-labelledby="addDevTitle">
|
<dialog id="addDevDlg" class="dlg" aria-labelledby="addDevTitle">
|
||||||
<form method="dialog" id="addDevForm">
|
<form method="dialog" id="addDevForm">
|
||||||
@@ -1322,7 +1374,7 @@
|
|||||||
<button type="submit" class="small danger" id="rmGo">Remove</button></div>
|
<button type="submit" class="small danger" id="rmGo">Remove</button></div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast" popover="manual"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
@@ -1344,6 +1396,9 @@ function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&",
|
|||||||
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
|
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
|
||||||
function dur(s) { const d = Math.floor(s/86400), h = Math.floor(s%86400/3600), m = Math.floor(s%3600/60);
|
function dur(s) { const d = Math.floor(s/86400), h = Math.floor(s%86400/3600), m = Math.floor(s%3600/60);
|
||||||
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; }
|
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; }
|
||||||
|
// What scrolls: #scroll on a computer, where the header and Activity bar stay put; the window on phones.
|
||||||
|
function scroller() { return getComputedStyle($("scroll")).overflowY === "visible" ? document.scrollingElement : $("scroll"); }
|
||||||
|
function scrollToY(y) { scroller().scrollTo({ top: y, behavior: "instant" }); }
|
||||||
function meter(el, pct, warnAt, badAt) {
|
function meter(el, pct, warnAt, badAt) {
|
||||||
el.style.width = Math.max(0, Math.min(100, pct)) + "%";
|
el.style.width = Math.max(0, Math.min(100, pct)) + "%";
|
||||||
el.className = pct >= badAt ? "bad" : pct >= warnAt ? "warn" : "";
|
el.className = pct >= badAt ? "bad" : pct >= warnAt ? "warn" : "";
|
||||||
@@ -1362,6 +1417,15 @@ let toastTimer;
|
|||||||
function toast(msg, err = false) {
|
function toast(msg, err = false) {
|
||||||
const el = $("toast");
|
const el = $("toast");
|
||||||
el.textContent = msg.split("\n").slice(-1)[0] || msg;
|
el.textContent = msg.split("\n").slice(-1)[0] || msg;
|
||||||
|
// A popover, so it shows above an open dialog (a modal one covers everything else): opened
|
||||||
|
// again when a dialog has opened since, which puts it back on top. Opened hidden first, so
|
||||||
|
// it still fades in.
|
||||||
|
if (el.showPopover && (!el.matches(":popover-open") || document.querySelector("dialog:modal"))) {
|
||||||
|
el.className = "toast" + (err ? " err" : "");
|
||||||
|
try { el.hidePopover(); } catch {}
|
||||||
|
el.showPopover();
|
||||||
|
void el.offsetWidth; // the hidden style is in place before .show changes it
|
||||||
|
}
|
||||||
el.className = "toast show" + (err ? " err" : "");
|
el.className = "toast show" + (err ? " err" : "");
|
||||||
clearTimeout(toastTimer);
|
clearTimeout(toastTimer);
|
||||||
toastTimer = setTimeout(() => el.className = "toast" + (err ? " err" : ""), 3500);
|
toastTimer = setTimeout(() => el.className = "toast" + (err ? " err" : ""), 3500);
|
||||||
@@ -1464,7 +1528,7 @@ function setOnline(ok, why) {
|
|||||||
if (!ok) {
|
if (!ok) {
|
||||||
$("offMsg").textContent = why || "Can't reach the Frame";
|
$("offMsg").textContent = why || "Can't reach the Frame";
|
||||||
setConn(false, "Offline");
|
setConn(false, "Offline");
|
||||||
$("battChip").hidden = true;
|
$("battChip").hidden = true; closeDevPop();
|
||||||
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
|
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
|
||||||
if (window.connBanner) window.connBanner();
|
if (window.connBanner) window.connBanner();
|
||||||
} else if (was === false) {
|
} else if (was === false) {
|
||||||
@@ -1512,7 +1576,7 @@ function renderPerformance(s) {
|
|||||||
const values = [["Compositor FPS", p.compositorFps, ""], ["Compositor period", p.frameMs, " ms"],
|
const values = [["Compositor FPS", p.compositorFps, ""], ["Compositor period", p.frameMs, " ms"],
|
||||||
["Application FPS", p.appFps, ""], ["Render GPU time", p.gpuMs, " ms"],
|
["Application FPS", p.appFps, ""], ["Render GPU time", p.gpuMs, " ms"],
|
||||||
["Compositor CPU", p.compositorCpuMs, " ms"], ["System CPU", p.cpuPercent, "%"],
|
["Compositor CPU", p.compositorCpuMs, " ms"], ["System CPU", p.cpuPercent, "%"],
|
||||||
["GPU clock", p.gpuMHz, " MHz"], ["Hottest sensor", s?.temp, "°C"], ["Battery", s?.battery?.percent, "%"]];
|
["GPU clock", p.gpuMHz, " MHz"]];
|
||||||
$("vrMetrics").innerHTML = values.map(([label, v, unit]) => `<div class="stat"><div class="k">${label}</div><div class="v">${Number.isFinite(v) ? v.toFixed(1) + unit : "Unavailable"}</div></div>`).join("");
|
$("vrMetrics").innerHTML = values.map(([label, v, unit]) => `<div class="stat"><div class="k">${label}</div><div class="v">${Number.isFinite(v) ? v.toFixed(1) + unit : "Unavailable"}</div></div>`).join("");
|
||||||
$("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample";
|
$("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample";
|
||||||
}
|
}
|
||||||
@@ -1644,10 +1708,6 @@ function battery(b, power) {
|
|||||||
const draining = !full && !charging && !idle && (!power || b?.status === "Discharging" || b?.watts < 0);
|
const draining = !full && !charging && !idle && (!power || b?.status === "Discharging" || b?.watts < 0);
|
||||||
const low = !charging && !full && pct != null && pct <= 15;
|
const low = !charging && !full && pct != null && pct <= 15;
|
||||||
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf";
|
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf";
|
||||||
$("battFill").setAttribute("width", pct == null ? 0 : (55 * Math.min(100, Math.max(0, pct)) / 100).toFixed(1));
|
|
||||||
$("battFill").setAttribute("fill", color);
|
|
||||||
$("battBolt").setAttribute("visibility", charging ? "visible" : "hidden");
|
|
||||||
$("battPct").textContent = pct == null ? "—" : `${pct}%`;
|
|
||||||
|
|
||||||
let label, cls, detail = [];
|
let label, cls, detail = [];
|
||||||
if (!b) { label = "Battery unknown"; cls = "draining"; }
|
if (!b) { label = "Battery unknown"; cls = "draining"; }
|
||||||
@@ -1663,7 +1723,7 @@ function battery(b, power) {
|
|||||||
if (b?.timeToEmpty > 0 && b.timeToEmpty < 86400) detail.push(`About ${dur(b.timeToEmpty)} left`);
|
if (b?.timeToEmpty > 0 && b.timeToEmpty < 86400) detail.push(`About ${dur(b.timeToEmpty)} left`);
|
||||||
}
|
}
|
||||||
if (power) detail.push(`${/PD/.test(power.type || "") ? "USB-C PD" : power.type || "Charger"}${power.watts ? ` ${Math.round(power.watts)} W` : ""}`);
|
if (power) detail.push(`${/PD/.test(power.type || "") ? "USB-C PD" : power.type || "Charger"}${power.watts ? ` ${Math.round(power.watts)} W` : ""}`);
|
||||||
if (b?.tempC != null) detail.push(`${b.tempC.toFixed(0)}°C`);
|
if (b?.tempC != null) detail.push(`battery ${b.tempC.toFixed(0)}°C`);
|
||||||
$("battState").textContent = label;
|
$("battState").textContent = label;
|
||||||
$("battState").className = "state " + cls;
|
$("battState").className = "state " + cls;
|
||||||
$("battDetail").textContent = detail.join(" · ") || " ";
|
$("battDetail").textContent = detail.join(" · ") || " ";
|
||||||
@@ -1672,13 +1732,25 @@ function battery(b, power) {
|
|||||||
const chipPct = pct == null ? 0 : Math.min(100, Math.max(0, pct));
|
const chipPct = pct == null ? 0 : Math.min(100, Math.max(0, pct));
|
||||||
$("battChip").innerHTML = pct == null ? "—" :
|
$("battChip").innerHTML = pct == null ? "—" :
|
||||||
`<svg width="22" height="12" viewBox="0 0 22 12" aria-hidden="true"><rect x=".75" y=".75" width="18" height="10.5" rx="2" fill="none" stroke="#8f98a0" stroke-width="1.5"/><rect x="19.8" y="3.5" width="1.8" height="5" rx=".6" fill="#8f98a0"/><rect x="2.5" y="2.5" width="${(14.5 * chipPct / 100).toFixed(1)}" height="7" rx="1" fill="${color}"/></svg>${bolt}<span>${pct}%</span>`;
|
`<svg width="22" height="12" viewBox="0 0 22 12" aria-hidden="true"><rect x=".75" y=".75" width="18" height="10.5" rx="2" fill="none" stroke="#8f98a0" stroke-width="1.5"/><rect x="19.8" y="3.5" width="1.8" height="5" rx=".6" fill="#8f98a0"/><rect x="2.5" y="2.5" width="${(14.5 * chipPct / 100).toFixed(1)}" height="7" rx="1" fill="${color}"/></svg>${bolt}<span>${pct}%</span>`;
|
||||||
$("battChip").title = label;
|
$("battChip").title = `${label}. Click for the headset's details.`;
|
||||||
$("battChip").hidden = pct == null;
|
$("battChip").setAttribute("aria-label", pct == null ? "Headset details" : `Battery ${pct}%, ${label}. Headset details`);
|
||||||
|
$("battChip").hidden = false; // even without a battery reading: it's the way to the headset's details
|
||||||
}
|
}
|
||||||
|
// hidePopover throws where the menu isn't open (and doesn't exist in older browsers).
|
||||||
|
function closeDevPop() { try { $("devPop").hidePopover(); } catch {} }
|
||||||
|
// The battery chip's menu opens under the chip, right-aligned with it.
|
||||||
|
$("devPop").addEventListener("beforetoggle", e => {
|
||||||
|
if (e.newState !== "open") return;
|
||||||
|
const r = $("battChip").getBoundingClientRect();
|
||||||
|
$("devPop").style.top = `${Math.round(r.bottom + 8)}px`;
|
||||||
|
const width = Math.min(390, innerWidth - 24); // as .pop's CSS width
|
||||||
|
$("devPop").style.right = `${Math.max(12, Math.min(Math.round(innerWidth - r.right), innerWidth - width - 12))}px`;
|
||||||
|
});
|
||||||
|
|
||||||
function render(s) {
|
function render(s) {
|
||||||
renderPerformance(s);
|
renderPerformance(s);
|
||||||
battery(s.battery, s.power);
|
battery(s.battery, s.power);
|
||||||
|
$("devPopTitle").textContent = $("devPopTitle").title = window.connName || "Headset";
|
||||||
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
|
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
|
||||||
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
|
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
|
||||||
const h = s.disk.home;
|
const h = s.disk.home;
|
||||||
@@ -2695,7 +2767,7 @@ function showApkAlternatives(reason, result, dev = window.connDevice) {
|
|||||||
}, dev));
|
}, dev));
|
||||||
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
|
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
|
||||||
await loadAndroid();
|
await loadAndroid();
|
||||||
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
|
if (cat.apps) { const y = scroller().scrollTop; filterCatalog(); scrollToY(y); }
|
||||||
if (res && res.app) await offerTest(res.app);
|
if (res && res.app) await offerTest(res.app);
|
||||||
};
|
};
|
||||||
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
|
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
|
||||||
@@ -2929,7 +3001,7 @@ function renderChips() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filterGames(keepScroll) {
|
function filterGames(keepScroll) {
|
||||||
const y = window.scrollY;
|
const y = scroller().scrollTop;
|
||||||
if (gm.src === "owned") {
|
if (gm.src === "owned") {
|
||||||
if (!gm.owned) return;
|
if (!gm.owned) return;
|
||||||
const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean);
|
const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
@@ -2945,7 +3017,7 @@ function filterGames(keepScroll) {
|
|||||||
$("gmGrid").innerHTML = ""; gm.shown = 0; moreGames(n);
|
$("gmGrid").innerHTML = ""; gm.shown = 0; moreGames(n);
|
||||||
if (!gm.results.length) $("gmGrid").innerHTML = `<div class="sub">${gm.src === "store" && !gm.storeQ
|
if (!gm.results.length) $("gmGrid").innerHTML = `<div class="sub">${gm.src === "store" && !gm.storeQ
|
||||||
? "Type a name and press Enter to search the Steam store." : "Nothing matches. Try turning on more ratings above."}</div>`;
|
? "Type a name and press Enter to search the Steam store." : "Nothing matches. Try turning on more ratings above."}</div>`;
|
||||||
if (keepScroll) window.scrollTo(0, y);
|
if (keepScroll) scrollToY(y);
|
||||||
}
|
}
|
||||||
|
|
||||||
function price(p) {
|
function price(p) {
|
||||||
@@ -3300,7 +3372,7 @@ document.body.addEventListener("click", async e => {
|
|||||||
if (action === "probe") { loadCatalog(); loadReports(); }
|
if (action === "probe") { loadCatalog(); loadReports(); }
|
||||||
}
|
}
|
||||||
loadAndroid();
|
loadAndroid();
|
||||||
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
|
if (cat.apps) { const y = scroller().scrollTop; filterCatalog(); scrollToY(y); }
|
||||||
});
|
});
|
||||||
loadCatalog();
|
loadCatalog();
|
||||||
|
|
||||||
@@ -3695,7 +3767,7 @@ function showPage() {
|
|||||||
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
|
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
|
||||||
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
|
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
|
||||||
const section = !PAGES.includes(id) && id && $(id);
|
const section = !PAGES.includes(id) && id && $(id);
|
||||||
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
if (section) section.scrollIntoView(); else scrollToY(0);
|
||||||
if (page === "tools") { loadMacView(); loadPanels(); }
|
if (page === "tools") { loadMacView(); loadPanels(); }
|
||||||
}
|
}
|
||||||
window.addEventListener("hashchange", showPage);
|
window.addEventListener("hashchange", showPage);
|
||||||
@@ -4206,28 +4278,36 @@ function netLabel(s) {
|
|||||||
}
|
}
|
||||||
function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; }
|
function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; }
|
||||||
|
|
||||||
// The pill in the header: always there, and says what's happening right now.
|
// How the headset is reached, in a word: Tailscale, the network's own name, or this network.
|
||||||
|
function routeLabel(s) {
|
||||||
|
if (s.via && s.via.kind === "tailscale") return "Tailscale";
|
||||||
|
const named = s.network && dv.data && dv.data.networks.find(x => x.id === s.network.id && x.name);
|
||||||
|
return named ? named.name : "this network";
|
||||||
|
}
|
||||||
|
// The pill in the header: always there, and says plainly what's happening. Addresses are in its
|
||||||
|
// dialog, where there's room for them; the tooltip has the detail.
|
||||||
function renderPill() {
|
function renderPill() {
|
||||||
const s = link.s;
|
const s = link.s;
|
||||||
if (!link.live || !s) return false;
|
if (!link.live || !s) return false;
|
||||||
const dev = s.device ? s.device.name : "Frame";
|
const dev = s.device ? s.device.name : "Frame";
|
||||||
let dot = "wait", line;
|
let dot = "wait", word, via, detail;
|
||||||
if (s.phase === "connected") {
|
if (s.phase === "connected") {
|
||||||
dot = "on";
|
dot = "on"; word = "Connected"; via = routeLabel(s);
|
||||||
line = `${netLabel(s)} → ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? ` · ${s.via.rtt_ms} ms` : "");
|
detail = `connected via ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : "");
|
||||||
} else if (s.phase === "failed") {
|
} else if (s.phase === "failed") {
|
||||||
dot = "off";
|
dot = "off"; word = "Offline";
|
||||||
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
||||||
const st = (s.stages || []).find(x => x.id === (s.error || {}).stage);
|
via = left != null ? `retrying in ${left} s` : "";
|
||||||
line = `Offline: ${st ? st.label.toLowerCase() : "not connected"} failed` + (left != null ? ` · retry in ${left} s` : "");
|
detail = s.error ? s.error.message : "not connected";
|
||||||
} else {
|
} else {
|
||||||
|
word = "Connecting…";
|
||||||
const st = activeStage(s);
|
const st = activeStage(s);
|
||||||
const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving");
|
via = st ? st.label.toLowerCase() : "";
|
||||||
line = st ? st.label + (st.id === "find" && probing ? `: ${probing.host}` : st.detail && st.id !== "network" ? `: ${st.detail}` : "…")
|
detail = via || "connecting";
|
||||||
: "Connecting…";
|
|
||||||
}
|
}
|
||||||
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(line)}</span></span>`;
|
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(word)}` +
|
||||||
$("conn").title = `${dev}: ${line}. Click for details.`;
|
`${via ? `<span class="via"> · ${esc(via)}</span>` : ""}</span></span>`;
|
||||||
|
$("conn").title = `${dev}: ${detail}. Click to see or change its addresses.`;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
window.connPill = renderPill;
|
window.connPill = renderPill;
|
||||||
@@ -4251,17 +4331,31 @@ function renderBanner() {
|
|||||||
}
|
}
|
||||||
window.connBanner = renderBanner;
|
window.connBanner = renderBanner;
|
||||||
|
|
||||||
|
// The pill's dialog: how it's connected, and the headset's addresses, which can be added,
|
||||||
|
// edited and reordered right here. The steps it took stay folded away while all is well.
|
||||||
|
const cd = { editing: null, editDev: null, session: 0, sig: "", phase: null };
|
||||||
function renderConnDlg() {
|
function renderConnDlg() {
|
||||||
const s = link.s;
|
const s = link.s;
|
||||||
if (!s || !$("connDlg").open) return;
|
if (!s || !$("connDlg").open) return;
|
||||||
$("connTitle").textContent = s.device ? `${s.device.name} (ssh ${s.device.alias})` : "Connection";
|
$("connTitle").textContent = s.device ? s.device.name : "Connection";
|
||||||
$("connWhy").textContent = s.phase === "connected" ? `Connected via ${viaText(s.via)}${s.via && s.via.why ? " (" + s.via.why + ")" : ""}`
|
const connected = s.phase === "connected", failed = s.phase === "failed";
|
||||||
: s.phase === "failed" ? (s.error ? s.error.message : "Not connected")
|
$("connDot").className = "dot " + (connected ? "on" : failed ? "off" : "wait");
|
||||||
|
$("connState").textContent = connected ? `Connected via ${routeLabel(s)}` : failed ? "Offline" : "Connecting…";
|
||||||
|
$("connWhy").textContent = connected ? viaText(s.via) + (s.via && s.via.rtt_ms != null ? ` · answered in ${s.via.rtt_ms} ms` : "")
|
||||||
|
: failed ? (s.error ? s.error.message : "Not connected")
|
||||||
: `${s.reason || "Connecting"}…`;
|
: `${s.reason || "Connecting"}…`;
|
||||||
|
// Folded while connected; opened when something's wrong. Left as the user set it otherwise.
|
||||||
|
if (s.phase !== cd.phase) { $("connMore").open = !connected; cd.phase = s.phase; }
|
||||||
|
// Connected, it's Reconnect: the addresses are tried again in their current order, so an
|
||||||
|
// address just added or moved up gets its chance now rather than at the next connection.
|
||||||
|
$("connRetryBtn").hidden = !failed && !connected;
|
||||||
|
$("connRetryBtn").classList.toggle("action", failed);
|
||||||
|
$("connRetryBtn").textContent = failed ? "Retry now" : "Reconnect";
|
||||||
|
$("connRetryBtn").title = failed ? "" : "Try the addresses again, in order";
|
||||||
|
$("connSetup").hidden = connected;
|
||||||
const n = s.network || {}, ts = n.tailscale || {};
|
const n = s.network || {}, ts = n.tailscale || {};
|
||||||
const facts = [["Network", n.name || "—"], ["Wi-Fi", n.ssid || (n.wifi ? "Wi-Fi (name hidden by the system)" : n.wifi === false ? "Wired" : "—")],
|
const facts = [["Network", n.ssid || n.name || "—"],
|
||||||
["Router", n.gateway ? `${n.gateway}${n.gateway_mac ? " · " + n.gateway_mac : ""}` : "No default route"],
|
["This computer", n.local_ip || "—"], ["Router", n.gateway || "No default route"],
|
||||||
["This computer", n.local_ip || "—"],
|
|
||||||
["Tailscale", ts.up ? `On${ts.ip ? " · " + ts.ip : ""}` : ts.installed ? "Installed, not running" : "Not installed"]];
|
["Tailscale", ts.up ? `On${ts.ip ? " · " + ts.ip : ""}` : ts.installed ? "Installed, not running" : "Not installed"]];
|
||||||
$("connNet").innerHTML = facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("");
|
$("connNet").innerHTML = facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("");
|
||||||
$("connStages").innerHTML = (s.stages || []).map(st => `<li class="${st.state}"><span class="ic">${STAGE_ICON[st.state] || ""}</span>
|
$("connStages").innerHTML = (s.stages || []).map(st => `<li class="${st.state}"><span class="ic">${STAGE_ICON[st.state] || ""}</span>
|
||||||
@@ -4270,22 +4364,137 @@ function renderConnDlg() {
|
|||||||
${st.detail ? `<span class="dt">${esc(st.detail)}</span>` : ""}</li>`).join("") +
|
${st.detail ? `<span class="dt">${esc(st.detail)}</span>` : ""}</li>`).join("") +
|
||||||
(s.phase === "connected" ? `<li class="done"><span class="ic">✓</span><span class="lb">Connected</span><span class="tm"></span>
|
(s.phase === "connected" ? `<li class="done"><span class="ic">✓</span><span class="lb">Connected</span><span class="tm"></span>
|
||||||
<span class="dt">${esc(`via ${netLabel(s)}, ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : ""))}</span></li>` : "");
|
<span class="dt">${esc(`via ${netLabel(s)}, ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : ""))}</span></li>` : "");
|
||||||
$("connProbes").innerHTML = (s.probes || []).map(p => `<tr><td>${esc(p.host)}${p.label ? `<div class="sub">${esc(p.label)}</div>` : ""}</td>
|
renderConnAddrs();
|
||||||
<td><span class="tag">${esc(KIND_TAG[p.kind] || p.kind)}</span></td><td class="sub">${esc(p.why || "")}</td>
|
|
||||||
<td class="res-${esc(p.state)}">${esc(p.detail)}${p.ip && p.ip !== p.host ? ` <span class="sub">${esc(p.ip)}</span>` : ""}</td></tr>`).join("")
|
|
||||||
|| `<tr><td class="sub">No addresses tried yet.</td></tr>`;
|
|
||||||
const left = s.phase === "failed" && s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
const left = s.phase === "failed" && s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
||||||
$("connRetry").textContent = left != null ? `Trying again in ${left} s` : s.finished && s.phase === "connected" ? `Connected ${ago(s.finished)}` : "";
|
$("connRetry").textContent = left != null ? `Trying again in ${left} s` : s.finished && s.phase === "connected" ? `Connected ${ago(s.finished)}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PRIVATE_V4 = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)\d{1,3}\.\d{1,3}(\.\d{1,3})?$/;
|
||||||
|
const sameSubnet = (a, b) => !!a && !!b && a.split(".").slice(0, 3).join(".") === b.split(".").slice(0, 3).join(".");
|
||||||
|
// The Frame's own LAN address, when this computer is on the same network and it isn't saved yet:
|
||||||
|
// saving it lets Frame Control connect directly at home rather than over Tailscale.
|
||||||
|
function lanOffer(s, d) {
|
||||||
|
const ip = state && state.ip, n = s.network || {};
|
||||||
|
if (!d || d.transient || !ip || !PRIVATE_V4.test(ip) || ip === n.local_ip || !sameSubnet(ip, n.local_ip)) return null;
|
||||||
|
if (d.addresses.some(a => a.host === ip) || (s.probes || []).some(p => p.ip === ip && p.kind !== "tailscale")) return null;
|
||||||
|
const named = dv.data && dv.data.networks.find(x => x.id === n.id && x.name);
|
||||||
|
return { ip, label: named ? named.name : n.ssid || "" };
|
||||||
|
}
|
||||||
|
function activeDevice() { const s = link.s; return s && s.device ? dv.list.find(x => x.id === s.device.id) : null; }
|
||||||
|
function renderConnAddrs() {
|
||||||
|
const s = link.s, d = activeDevice(), list = $("connAddrs");
|
||||||
|
const offer = s && lanOffer(s, d);
|
||||||
|
$("connOffer").hidden = !offer;
|
||||||
|
if (offer) {
|
||||||
|
const key = JSON.stringify([d.id, offer.ip, offer.label]);
|
||||||
|
if ($("connOffer").dataset.key !== key) {
|
||||||
|
$("connOffer").dataset.key = key;
|
||||||
|
$("connOffer").innerHTML = `<div class="grow">The Frame is at <b>${esc(offer.ip)}</b> on this network. Add it, and
|
||||||
|
Frame Control tries it first here, reaching the headset directly.</div>
|
||||||
|
<button class="small action" id="connOfferAdd">Add ${esc(offer.ip)}</button>`;
|
||||||
|
// First in the list: on this network it then wins over the address in use now.
|
||||||
|
$("connOfferAdd").onclick = e => devAction(`Add ${offer.ip}`, { action: "address-add", id: d.id, host: offer.ip,
|
||||||
|
kind: "lan", label: offer.label, first: true }, e.currentTarget)
|
||||||
|
.then(res => res && testQuietly(d.id));
|
||||||
|
}
|
||||||
|
} else $("connOffer").dataset.key = "";
|
||||||
|
$("connAdd").hidden = !d || !!d.transient;
|
||||||
|
if (!d) { list.innerHTML = `<div class="sub">Loading…</div>`; cd.sig = ""; return; }
|
||||||
|
if (d.transient) {
|
||||||
|
list.innerHTML = `<div class="sub">This headset isn't set up in Frame Control yet, so ssh's own settings decide where it goes.
|
||||||
|
Set Up Connection adds its addresses here.</div>`;
|
||||||
|
cd.sig = ""; return;
|
||||||
|
}
|
||||||
|
const probes = new Map((s.probes || []).map(p => [p.host, p]));
|
||||||
|
const tested = new Map((((s.tests || {})[d.id] || {}).rows || []).map(r => [r.host, r]));
|
||||||
|
const inUse = s.phase === "connected" && s.via ? s.via.host : null;
|
||||||
|
const sig = JSON.stringify([d.id, d.addresses, [...probes.values(), ...tested.values()].map(p => [p.host, p.state, p.ssh, p.detail]), cd.editing, inUse]);
|
||||||
|
// Leave the rows alone when nothing has changed, and while an address is being edited: what's
|
||||||
|
// typed stays put until it's saved or cancelled, wherever the focus is.
|
||||||
|
if (sig === cd.sig || cd.editing && $("ceHost") && cd.editDev === d.id && d.addresses.some(a => a.host === cd.editing)) return;
|
||||||
|
cd.sig = sig;
|
||||||
|
// Rebuilding the rows would drop the keyboard focus: put it back on the same button of the
|
||||||
|
// same address (after an edit, on that row's Edit button).
|
||||||
|
const held = list.contains(document.activeElement) && document.activeElement.closest("[data-host]");
|
||||||
|
const refocus = cd.refocus ? [cd.refocus, "[data-ed]"]
|
||||||
|
: held ? [held.dataset.host, ["[data-mv='-1']", "[data-mv='1']", "[data-ed]", "[data-rm]"].find(q => document.activeElement.matches(q))] : null;
|
||||||
|
cd.refocus = null;
|
||||||
|
list.innerHTML = d.addresses.map((a, i) => {
|
||||||
|
if (cd.editing === a.host && cd.editDev === d.id) return `<div class="item addr"><div class="addr-edit conn-edit">
|
||||||
|
<input type="text" id="ceHost" value="${esc(a.host)}" maxlength="253" aria-label="Address">
|
||||||
|
<input type="text" id="ceLabel" value="${esc(a.label)}" maxlength="60" placeholder="Label" aria-label="Label">
|
||||||
|
<button class="small action" id="ceSave">Save</button><button class="small" id="ceCancel">Cancel</button></div></div>`;
|
||||||
|
const p = probes.get(a.host), t = tested.get(a.host);
|
||||||
|
const result = a.host === inUse ? `<span class="res-ok">In use${p && p.detail ? " · " + esc(p.detail.toLowerCase()) : ""}</span>`
|
||||||
|
: t ? `<span class="res-${esc(t.ssh || t.state)}">${esc(t.detail)}${t.ssh === "checking" ? " · checking SSH…" : ""}</span>`
|
||||||
|
: p ? `<span class="res-${esc(p.state)}">${esc(p.detail)}</span>`
|
||||||
|
: a.last_ok ? `Worked ${esc(ago(a.last_ok))}` : "Not tried on this network";
|
||||||
|
return `<div class="item addr" data-host="${esc(a.host)}">
|
||||||
|
<div class="grow"><div class="t">${esc(a.host)} <span class="tag">${esc(KIND_TAG[a.kind] || a.kind)}</span>${a.label ? ` <span class="sub">${esc(a.label)}</span>` : ""}</div>
|
||||||
|
<div class="s">${result}</div></div>
|
||||||
|
<button class="small" data-mv="-1" title="Try earlier" aria-label="Move ${esc(a.host)} up"${i ? "" : " disabled"}>↑</button>
|
||||||
|
<button class="small" data-mv="1" title="Try later" aria-label="Move ${esc(a.host)} down"${i < d.addresses.length - 1 ? "" : " disabled"}>↓</button>
|
||||||
|
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`;
|
||||||
|
}).join("") || `<div class="sub">No addresses yet. Add the Frame's IP address below.</div>`;
|
||||||
|
if (refocus) {
|
||||||
|
const row = list.querySelector(`[data-host="${CSS.escape(refocus[0])}"]`);
|
||||||
|
const again = row && refocus[1] && row.querySelector(refocus[1]);
|
||||||
|
if (row) (again && !again.disabled ? again : row.querySelector("[data-ed]")).focus();
|
||||||
|
}
|
||||||
|
list.querySelectorAll("[data-host]").forEach(row => {
|
||||||
|
const host = row.dataset.host;
|
||||||
|
row.querySelectorAll("[data-mv]").forEach(b => b.onclick = () =>
|
||||||
|
devAction("Move " + host, { action: "address-move", id: d.id, host, delta: +b.dataset.mv }, b));
|
||||||
|
row.querySelector("[data-ed]").onclick = () => {
|
||||||
|
cd.editing = host; cd.editDev = d.id; cd.session++; renderConnAddrs(); $("ceHost").focus();
|
||||||
|
};
|
||||||
|
row.querySelector("[data-rm]").onclick = e => {
|
||||||
|
if (host === inUse && !confirm(`${host} is how Frame Control is reaching the headset now. Remove it?`)) return;
|
||||||
|
devAction(`Remove ${host}`, { action: "address-remove", id: d.id, host }, e.currentTarget);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if ($("ceSave")) {
|
||||||
|
const host = cd.editing, a = d.addresses.find(x => x.host === host), session = cd.session;
|
||||||
|
let saving = false;
|
||||||
|
const save = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (saving) return; // a second Enter while the first is on its way
|
||||||
|
const newHost = $("ceHost").value.trim(), label = $("ceLabel").value;
|
||||||
|
if (!newHost) return $("ceHost").focus();
|
||||||
|
saving = true;
|
||||||
|
const res = await devAction(`Save ${host}`, { action: "address-update", id: d.id, host, newHost, kind: a.kind, label }, $("ceSave"));
|
||||||
|
saving = false;
|
||||||
|
if (res) testQuietly(d.id);
|
||||||
|
// Refused: what was typed stays, to correct. Cancelled or another address opened for
|
||||||
|
// editing while this was on its way: leave that one alone.
|
||||||
|
if (!res || cd.session !== session) return;
|
||||||
|
cd.editing = null; cd.sig = ""; cd.refocus = newHost;
|
||||||
|
renderConnAddrs();
|
||||||
|
};
|
||||||
|
$("ceSave").onclick = save;
|
||||||
|
[$("ceHost"), $("ceLabel")].forEach(i => i.onkeydown = e => { if (e.key === "Enter") save(e); if (e.key === "Escape") { e.preventDefault(); $("ceCancel").click(); } });
|
||||||
|
$("ceCancel").onclick = () => { cd.editing = null; cd.session++; cd.sig = ""; cd.refocus = host; renderConnAddrs(); };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$("connAdd").onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const d = activeDevice(), host = $("caHost").value.trim();
|
||||||
|
if (!d) return;
|
||||||
|
if (!host) return $("caHost").focus();
|
||||||
|
const res = await devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: "", label: $("caLabel").value });
|
||||||
|
if (res) { $("caHost").value = ""; $("caLabel").value = ""; testQuietly(d.id); }
|
||||||
|
};
|
||||||
|
// Try the addresses (without reconnecting), so a new one shows whether it answers.
|
||||||
|
function testQuietly(id) { api("/api/devices", { action: "test", id }).catch(() => {}); }
|
||||||
|
|
||||||
function onConnection(s) {
|
function onConnection(s) {
|
||||||
const prev = link.s;
|
const prev = link.s;
|
||||||
link.s = s; link.live = !s.local; link.lastData = Date.now();
|
link.s = s; link.live = !s.local; link.lastData = Date.now();
|
||||||
link.offset = Date.now() / 1000 - s.now;
|
link.offset = Date.now() / 1000 - s.now;
|
||||||
if (!link.live) return;
|
if (!link.live) return;
|
||||||
renderPill(); renderConnDlg();
|
|
||||||
const devId = s.device && s.device.id;
|
const devId = s.device && s.device.id;
|
||||||
window.connDevice = devId || null;
|
window.connDevice = devId || null;
|
||||||
|
window.connName = s.device ? s.device.name : null;
|
||||||
if (link.device !== null && devId !== link.device) {
|
if (link.device !== null && devId !== link.device) {
|
||||||
log(`Now using ${s.device.name}`, "ok");
|
log(`Now using ${s.device.name}`, "ok");
|
||||||
state = null;
|
state = null;
|
||||||
@@ -4315,8 +4524,10 @@ function onConnection(s) {
|
|||||||
if ($("wiDlg").open && !wi.job && !wi.starting) $("wiDlg").close();
|
if ($("wiDlg").open && !wi.job && !wi.starting) $("wiDlg").close();
|
||||||
$("dispSel").innerHTML = "<option>Loading…</option>"; $("dispSel").disabled = true; $("dispCtl").hidden = true;
|
$("dispSel").innerHTML = "<option>Loading…</option>"; $("dispSel").disabled = true; $("dispCtl").hidden = true;
|
||||||
link.reload = true; // everything on the page was the other headset's
|
link.reload = true; // everything on the page was the other headset's
|
||||||
$("battChip").hidden = true;
|
$("battChip").hidden = true; closeDevPop();
|
||||||
}
|
}
|
||||||
|
// After the other headset's status is cleared: the dialog's offer reads it.
|
||||||
|
renderPill(); renderConnDlg();
|
||||||
if (s.phase !== link.phase || devId !== link.device) {
|
if (s.phase !== link.phase || devId !== link.device) {
|
||||||
if (s.phase === "connected") {
|
if (s.phase === "connected") {
|
||||||
log(`Connected to ${s.device.name} via ${viaText(s.via)} on ${netLabel(s)}`, "ok");
|
log(`Connected to ${s.device.name} via ${viaText(s.via)} on ${netLabel(s)}`, "ok");
|
||||||
@@ -4373,7 +4584,9 @@ setInterval(() => { if (link.live && link.s && link.s.phase === "failed") { rend
|
|||||||
|
|
||||||
$("conn").onclick = () => {
|
$("conn").onclick = () => {
|
||||||
if (!link.live) { if (online === false) refresh(); return; }
|
if (!link.live) { if (online === false) refresh(); return; }
|
||||||
|
cd.editing = null; cd.sig = ""; cd.phase = null;
|
||||||
$("connDlg").showModal(); renderConnDlg();
|
$("connDlg").showModal(); renderConnDlg();
|
||||||
|
if (!dv.data) loadDevices();
|
||||||
};
|
};
|
||||||
$("offDetails").onclick = () => $("conn").click();
|
$("offDetails").onclick = () => $("conn").click();
|
||||||
$("connClose").onclick = () => $("connDlg").close();
|
$("connClose").onclick = () => $("connDlg").close();
|
||||||
@@ -4432,7 +4645,7 @@ async function devAction(label, body, btn) {
|
|||||||
function deviceStatus(d) {
|
function deviceStatus(d) {
|
||||||
const s = link.s;
|
const s = link.s;
|
||||||
if (d.active && s) {
|
if (d.active && s) {
|
||||||
return s.phase === "connected" ? ["on", `Connected via ${viaText(s.via)}`]
|
return s.phase === "connected" ? ["on", `Connected via ${routeLabel(s)}`]
|
||||||
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"];
|
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"];
|
||||||
}
|
}
|
||||||
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
|
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
|
||||||
@@ -4446,7 +4659,7 @@ function renderDevices() {
|
|||||||
const [dot, text] = deviceStatus(d);
|
const [dot, text] = deviceStatus(d);
|
||||||
return `<div class="item dev-item${d.id === dv.sel ? " sel" : ""}" data-dev="${esc(d.id)}">
|
return `<div class="item dev-item${d.id === dv.sel ? " sel" : ""}" data-dev="${esc(d.id)}">
|
||||||
<span class="dot ${dot}"></span><div class="grow"><div class="t">${esc(d.name)} ${d.active ? '<span class="tag">In use</span>' : ""}</div>
|
<span class="dot ${dot}"></span><div class="grow"><div class="t">${esc(d.name)} ${d.active ? '<span class="tag">In use</span>' : ""}</div>
|
||||||
<div class="s">ssh ${esc(d.alias)} · ${esc(text)}</div></div>
|
<div class="s">${esc(text)}</div></div>
|
||||||
${d.active ? "" : `<button class="small" data-use="${esc(d.id)}">Use this headset</button>`}</div>`;
|
${d.active ? "" : `<button class="small" data-use="${esc(d.id)}">Use this headset</button>`}</div>`;
|
||||||
}).join("") || `<div class="sub">No headsets yet. Add one to get started.</div>`;
|
}).join("") || `<div class="sub">No headsets yet. Add one to get started.</div>`;
|
||||||
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
|
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
|
||||||
@@ -4461,6 +4674,7 @@ function renderDevices() {
|
|||||||
$("netName").disabled = $("netSave").disabled = !(n && n.id);
|
$("netName").disabled = $("netSave").disabled = !(n && n.id);
|
||||||
}
|
}
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
renderConnDlg(); // the pill's dialog lists the same addresses
|
||||||
}
|
}
|
||||||
$("netSave").onclick = e => {
|
$("netSave").onclick = e => {
|
||||||
const n = dv.data && dv.data.network;
|
const n = dv.data && dv.data.network;
|
||||||
@@ -4498,19 +4712,13 @@ function renderDetail() {
|
|||||||
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
|
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
|
||||||
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2>${d.active ? '<span class="count">In use</span>' : ""}<span class="spacer"></span>
|
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2>${d.active ? '<span class="count">In use</span>' : ""}<span class="spacer"></span>
|
||||||
${d.active ? "" : `<button class="small action" id="dvUse">Use this headset</button>`}</div>
|
${d.active ? "" : `<button class="small action" id="dvUse">Use this headset</button>`}</div>
|
||||||
<form class="dev-form" id="dvForm">
|
<form class="dev-name" id="dvNameForm">
|
||||||
<label>Name<input type="text" id="dvName" value="${esc(d.name)}" maxlength="60"></label>
|
<label>Name<input type="text" id="dvName" value="${esc(d.name)}" maxlength="60"></label>
|
||||||
<label>SSH alias<input type="text" value="${esc(d.alias)}" readonly title="Terminal: ssh ${esc(d.alias)}"></label>
|
<button class="small" type="submit">Rename</button></form>
|
||||||
<label>User<input type="text" id="dvUser" value="${esc(d.user)}" maxlength="64"></label>
|
<h3>Addresses <span class="h3-note">tried all at once; the first in the list wins a tie</span></h3>
|
||||||
<label>Port<input type="text" id="dvPort" value="${esc(d.port)}" maxlength="5" inputmode="numeric"></label>
|
|
||||||
</form>
|
|
||||||
<div class="row" style="margin-top:10px"><button class="small" id="dvSave">Save</button>
|
|
||||||
<span class="sub">User and port are written to its block in <code>~/.ssh/config</code> too.</span></div>
|
|
||||||
<h3>Addresses <span class="sub" style="text-transform:none; letter-spacing:0; font-weight:400">tried all at once; this order breaks ties</span></h3>
|
|
||||||
<div class="list" id="dvAddrs">${rows || '<div class="sub">No addresses yet: add one, or find it below.</div>'}</div>
|
<div class="list" id="dvAddrs">${rows || '<div class="sub">No addresses yet: add one, or find it below.</div>'}</div>
|
||||||
<form class="addr-add" id="dvAdd">
|
<form class="addr-add" id="dvAdd">
|
||||||
<input type="text" id="adHost" maxlength="253" placeholder="IP address or host name" aria-label="New address">
|
<input type="text" id="adHost" maxlength="253" placeholder="IP address or host name" aria-label="New address">
|
||||||
${kindSel("adKind", "")}
|
|
||||||
<input type="text" id="adLabel" maxlength="60" placeholder="Label, e.g. Office" aria-label="Label">
|
<input type="text" id="adLabel" maxlength="60" placeholder="Label, e.g. Office" aria-label="Label">
|
||||||
<button class="small" type="submit">Add</button></form>
|
<button class="small" type="submit">Add</button></form>
|
||||||
<div class="row" style="margin-top:14px">
|
<div class="row" style="margin-top:14px">
|
||||||
@@ -4518,22 +4726,33 @@ function renderDetail() {
|
|||||||
<button class="small" id="dvTs">Find on Tailscale</button>
|
<button class="small" id="dvTs">Find on Tailscale</button>
|
||||||
<button class="small" id="dvMdns">Find on this network</button>
|
<button class="small" id="dvMdns">Find on this network</button>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="small" id="dvForget" title="After reinstalling SteamOS the headset shows a new identity">Forget identity</button>
|
<button class="small danger" id="dvRemove">Remove headset…</button></div>
|
||||||
<button class="small danger" id="dvRemove">Remove…</button></div>
|
|
||||||
<div class="found" id="dvFound"></div>
|
<div class="found" id="dvFound"></div>
|
||||||
<div class="hint">Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
|
<details class="adv"${dv.adv ? " open" : ""}><summary>Advanced</summary>
|
||||||
: "not saved yet; the next connection saves it."} Kind "auto" is picked from the address:
|
<form class="dev-form" id="dvForm">
|
||||||
<code>.local</code> names are mDNS, <code>100.x</code> and <code>.ts.net</code> are Tailscale.</div>`;
|
<label>SSH alias<input type="text" value="${esc(d.alias)}" readonly title="Terminal: ssh ${esc(d.alias)}"></label>
|
||||||
$("adKind").insertAdjacentHTML("afterbegin", `<option value="" selected>Kind: auto</option>`);
|
<label>User<input type="text" id="dvUser" value="${esc(d.user)}" maxlength="64"></label>
|
||||||
$("adKind").value = "";
|
<label>Port<input type="text" id="dvPort" value="${esc(d.port)}" maxlength="5" inputmode="numeric"></label>
|
||||||
|
</form>
|
||||||
|
<div class="row" style="margin-top:10px"><button class="small" id="dvSave">Save</button>
|
||||||
|
<span class="sub">In Terminal it's <code>ssh ${esc(d.alias)}</code>. User and port are written to its block in <code>~/.ssh/config</code> too.</span></div>
|
||||||
|
<div class="row" style="margin-top:14px"><button class="small" id="dvForget" title="After reinstalling SteamOS the headset shows a new identity">Forget identity</button>
|
||||||
|
<span class="sub">Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
|
||||||
|
: "not saved yet; the next connection saves it."}</span></div>
|
||||||
|
<div class="hint">An address's kind is worked out from it: <code>.local</code> names are mDNS, <code>100.x</code> and
|
||||||
|
<code>.ts.net</code> are Tailscale, and other IPs are this network. Edit an address to set it yourself.</div>
|
||||||
|
</details>`;
|
||||||
|
el.querySelector(".adv").ontoggle = e => { dv.adv = e.target.open; };
|
||||||
if ($("dvUse")) $("dvUse").onclick = () => useDevice(d.id);
|
if ($("dvUse")) $("dvUse").onclick = () => useDevice(d.id);
|
||||||
$("dvSave").onclick = e => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value,
|
const saveDev = btn => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value,
|
||||||
user: $("dvUser").value, port: $("dvPort").value }, e.currentTarget);
|
user: $("dvUser").value, port: $("dvPort").value }, btn);
|
||||||
|
$("dvSave").onclick = e => saveDev(e.currentTarget);
|
||||||
|
$("dvNameForm").onsubmit = e => { e.preventDefault(); saveDev(e.submitter); };
|
||||||
$("dvAdd").onsubmit = e => {
|
$("dvAdd").onsubmit = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const host = $("adHost").value.trim();
|
const host = $("adHost").value.trim();
|
||||||
if (!host) return $("adHost").focus();
|
if (!host) return $("adHost").focus();
|
||||||
devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: $("adKind").value, label: $("adLabel").value });
|
devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: "", label: $("adLabel").value });
|
||||||
};
|
};
|
||||||
el.querySelectorAll("[data-host]").forEach(row => {
|
el.querySelectorAll("[data-host]").forEach(row => {
|
||||||
const host = row.dataset.host;
|
const host = row.dataset.host;
|
||||||
|
|||||||
Reference in new issue
Block a user