Compare commits

..
21 changed files with 451 additions and 11 deletions

No files matched your search

+21
View File
@@ -173,3 +173,24 @@ jobs:
app/dist/*.AppImage
app/dist/*.deb
if-no-files-found: ignore
checksums:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Attach SHA256SUMS after all installers upload
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq '.[]' > releases.jsonl
python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" < releases.jsonl > SHA256SUMS
# Find the same draft using its exact tag, or its version title if a
# draft edit made GitHub temporarily show an untagged-* tag name.
id=$(python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" --id < releases.jsonl)
old=$(gh api "repos/$GH_REPO/releases/$id" --jq '.assets[] | select(.name == "SHA256SUMS") | .id')
for asset in $old; do gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset" >/dev/null; done
gh api -X POST "https://uploads.github.com/repos/$GH_REPO/releases/$id/assets?name=SHA256SUMS" \
-H "Content-Type: text/plain" --input SHA256SUMS >/dev/null
+15
View File
@@ -2,6 +2,21 @@
## Unreleased
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
Highlight exact PnP instances and known state changes after a dropout;
incomplete inventories stay unknown. A Forget button clears the comparison.
No adapter setting changes or report uploads are made.
- Add recovery steps to the Windows Wireless Adapter doctor for streams that
drop as they start and dongles that will not reconnect. Compare exact PnP IDs
and test one connection condition at a time. In-app disable/re-enable remains
deferred until an active Steam adapter instance can be identified safely.
[Guide and limits](docs/windows-wireless-doctor.md).
- Add an explicit host window-switch button to whole-display panels: Alt-Tab
on Windows and Command-Tab on macOS. Single-window panels keep their own
source, so the button is hidden there.
- Enable the in-app OBS source Start/Stop control on Windows. A control pipe
lets the Python relay close its SSH child on Stop, app quit or parent exit;
add a short [Discord guide](docs/streaming-to-discord.md).
+19 -1
View File
@@ -130,6 +130,24 @@ Choose the operating system and processor of the computer you will use.
| **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
| **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
### Is it safe? Check your download
Starting with **v0.4.26**, get the installer and [SHA256SUMS](https://github.com/saphid/frame-control/releases/latest/download/SHA256SUMS) from the **same release**. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Use the command for your downloaded file, then compare all 64 characters with its line in `SHA256SUMS`:
```powershell
# Windows PowerShell
Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256
```
```sh
# macOS: shasum -a 256 Frame-Control-mac-arm64.dmg
# Linux: sha256sum Frame-Control-linux-x86_64.AppImage
```
The [source](https://github.com/saphid/frame-control) and [release build workflow](.github/workflows/release.yml) are public. The Windows installer is **not code-signed**, so SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.
日本語: v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
**Which build?** On a Mac, check Apple menu → About This Mac: the download is
for Apple Silicon (M-series); no Intel Mac build is published. On Windows,
check Settings → System → About → System type: the release is x64, not a native
@@ -187,7 +205,7 @@ or run it with `--appimage-extract-and-run`.
## Open the app
- **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then
open it from Applications. For the unsigned-app warning, use the macOS steps
open it from Applications. For the Gatekeeper warning, use the macOS steps
above. Allow local network access and control of Terminal when prompted.
- **Windows:** run the installer, then open **Frame Control** from Start. For
the portable download, extract the entire `.zip` first and run
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.23",
"version": "0.4.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.23",
"version": "0.4.25",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.23",
"version": "0.4.25",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+6
View File
@@ -7,6 +7,12 @@ the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
only). On Windows there's an experimental port,
[PC in the headset](pc-in-headset.md).
On a **whole Mac display** panel, click **Switch Mac app (⌘Tab)** to send one
Command-Tab chord to the Mac. The button is absent from single-window panels;
those already keep their own source. It requires the same Mac Accessibility
permission as other viewer keyboard input. The chord and button have automated
checks, but a worn-headset click has not been verified.
The confidence labels are the same as in [ssh.md](ssh.md).
## Why this design
+6
View File
@@ -13,6 +13,12 @@ whole screens. Place the panel with the dashboard's Float
in World, Move and Size, point and click with the laser, scroll with the
thumbstick, and type on the PC's keyboard.
On a whole-screen panel, click **Switch PC window (Alt+Tab)** to switch the
foreground Windows window. It sends one explicit Alt-Tab chord through the
viewer's existing input channel. The button is absent from single-window panels
when a WGC-enabled build supports them. A real Windows/Frame button click has
not been verified; use the PC keyboard if focus does not move as expected.
**It's experimental.** A Windows 11 VM has captured its real desktop with
Desktop Duplication and encoded it with Media Foundation and JPEG. Physical PCs,
hardware encoding and physical laser input remain unverified. See
+6 -2
View File
@@ -13,7 +13,9 @@ count. So a build reaches people only when you publish it, after testing it.
```
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
attaches everything to a **draft** release for that tag. Nobody is
attaches everything to a **draft** release for that tag. After all eight
installers upload, the workflow also attaches `SHA256SUMS` built from
GitHub's SHA-256 digests of those uploaded bytes. Nobody is
offered a draft. Editing a draft while installers are uploading can make
GitHub temporarily show its tag as `untagged-…`. The workflow finds that
draft by its exact version title, restores the tag association and retries
@@ -32,7 +34,9 @@ count. So a build reaches people only when you publish it, after testing it.
```
The script checks that the tag is on GitHub and that all eight installers
are attached, each with the SHA-256 digest GitHub records. It attaches
are attached, each with the SHA-256 digest GitHub records. It also checks
that the uploaded `SHA256SUMS` digest matches a fresh list of those eight
installer digests. It attaches
`update.json` (the version, the notes, the release page and each
installer's digest), then publishes the release and marks it latest. It
uses only the REST API: `gh release view` can't find a draft whose
+40
View File
@@ -48,6 +48,46 @@ this report. A future disable feature needs a verified Steam adapter/session
mapping, fresh protection for primary/active instances, explicit confirmation
for one exact instance, and a tested recovery path that re-enables that same ID.
## When a stream drops immediately or the adapter will not reconnect
Read PC adapters **before** starting a stream, then refresh immediately after a
drop. Compare the *exact* PnP instance IDs, presence and problem codes. Note
whether the PC's own Wi-Fi drops too. A new `#2` name is not proof that this
instance caused the failure. Keep the time of the drop and the two snapshots
for Steam Support; the doctor does not save or upload them.
The app shows **Changes since previous read** after the second click. It
compares exact PnP IDs, known status fields and reported channels/bands; an
unknown value is never treated as a state change. If the PnP source was
unavailable in either read, it declines to compare devices. Each successful
read becomes the baseline for the next one. **Forget previous read** clears the
baseline and comparison; closing the app clears them too. The before/after
comparison stays in local memory and is not uploaded or added to telemetry.
Reported changes can reflect sequential query timing or a service recovering,
not a fault or permission to disable any instance.
To narrow a drop at stream start, try one condition at a time: wired PC
networking instead of PC Wi-Fi, wired headphones instead of 2.4 GHz audio, and
spacing the USB radio dongles apart. Recheck the stream after each change. The
PC Wi-Fi channel in this report is not the Steam dongle's channel, so a matching
or different number does not establish interference.
If the adapter will not reconnect, stop the failed stream and close Steam's
streaming session before touching its USB connection. Refresh the inventory. If
the dongle is absent, reconnect it once at the PC, understanding that this
interrupts any connection using it, then refresh again. If the **same exact**
PnP instance now reports problem code 22 (disabled), review that instance in
Windows Device Manager and its normal enable action. Do not enable or disable a
different numbered instance by guesswork. For a missing device or another
problem code, record the before/after ID and code for Steam Support; do not
guess a driver or change security settings.
These are user-controlled checks, not an automatic repair. We have no physical
Steam Wireless Adapter dropout to reproduce and no verified mapping from a
Wi-Fi Direct virtual instance to Steam's active session. An in-app disable and
re-enable control remains deferred until that mapping and a recovery path are
tested on real hardware.
## Checking a 2.4 GHz audio conflict
[Deko's report](https://x.com/DekoDrak/status/2113412044644897023)
+34 -1
View File
@@ -70,6 +70,38 @@ print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent
done
[ -z "$missing" ] || { echo "not publishing: fix the assets above" >&2; exit 1; }
# The workflow uploads this only after all eight platform builds complete.
# Regenerate it from GitHub's installer digests and compare its own GitHub
# digest, so a stale or partial checksum file cannot pass the release gate.
python3 "$(dirname "$0")/release-checksums.py" < "$tmp/release.json" > "$tmp/SHA256SUMS"
expected_sum=$(sha256sum "$tmp/SHA256SUMS" | cut -d ' ' -f 1)
checksum_digest=$(python3 -c 'import json,sys
d=json.load(open(sys.argv[1])); matches=[a.get("digest") for a in d["assets"] if a["name"]=="SHA256SUMS"]
print(matches[0] if len(matches)==1 else "absent")' "$tmp/release.json")
[ "$checksum_digest" = "sha256:$expected_sum" ] || {
echo "not publishing: SHA256SUMS is absent or does not match the eight installer digests" >&2; exit 1;
}
echo "ok SHA256SUMS"
# Every published release carries the same concrete verification instructions.
# Keep the release's own notes, and append this section only once. The tag URL
# is stable even while a draft temporarily has an untagged-* API name.
python3 - "$tmp/release.json" "$(dirname "$0")/release-safety-note.md" "$tag" > "$tmp/release-with-note.json" <<'EOF'
import json, pathlib, sys
d = json.load(open(sys.argv[1]))
body = (d.get("body") or "").rstrip()
if "## Is it safe?" not in body:
note = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8").replace("{tag}", sys.argv[3]).strip()
body = (body + "\n\n" if body else "") + note
d["body"] = body
json.dump(d, sys.stdout)
EOF
mv "$tmp/release-with-note.json" "$tmp/release.json"
python3 - "$tmp/release.json" > "$tmp/release-body.txt" <<'EOF'
import json, sys
sys.stdout.buffer.write(json.load(open(sys.argv[1]))["body"].encode("utf-8"))
EOF
# update.json: what running copies read (app/updater.js), from github.com's
# latest/download link rather than the rate-limited REST API.
python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF'
@@ -87,6 +119,7 @@ print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))'
if [ -n "$dry" ]; then
echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:"
cat "$tmp/update.json"
echo "dry run: would include the Is it safe? section in the release body"
echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true"
exit 0
fi
@@ -98,6 +131,6 @@ gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
echo "ok update.json"
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -f body="$(cat "$tmp/release-body.txt")" -F draft=false -F prerelease=false \
-f make_latest=true --jq '"published " + .tag_name + " at " + .html_url'
echo "running copies will offer $tag at their next check"
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Print SHA256SUMS for the eight release installers from GitHub asset metadata.
GitHub computes each asset digest from its uploaded bytes. The workflow runs
this only after all platform builds finish; publish-release.sh checks that the
uploaded SHA256SUMS bytes still match these digests before publishing.
"""
import argparse
import json
import re
import sys
INSTALLERS = (
"Frame-Control-mac-arm64.dmg",
"Frame-Control-mac-arm64.zip",
"Frame-Control-Setup-x64.exe",
"Frame-Control-win-x64.zip",
"Frame-Control-linux-x86_64.AppImage",
"Frame-Control-linux-arm64.AppImage",
"Frame-Control-linux-amd64.deb",
"Frame-Control-linux-arm64.deb",
)
def find_release(lines, tag):
releases = [json.loads(line) for line in lines if line.strip()]
hits = [r for r in releases if r.get("tag_name") == tag]
if not hits:
title = re.compile(r"Frame Control " + re.escape(tag.removeprefix("v")) + r"(: .*)?")
hits = [r for r in releases if r.get("draft")
and str(r.get("tag_name") or "").startswith("untagged-")
and title.fullmatch(r.get("name") or "")]
if len(hits) != 1:
raise ValueError(f"expected one release for {tag}; found {len(hits)}")
return hits[0]
def checksums(release):
assets = release.get("assets") or []
lines = []
for name in INSTALLERS:
matches = [a for a in assets if a.get("name") == name]
if len(matches) != 1:
raise ValueError(f"expected one asset named {name}; found {len(matches)}")
digest = matches[0].get("digest") or ""
if not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
raise ValueError(f"missing SHA-256 digest for {name}")
lines.append(f"{digest[7:].lower()} {name}")
return "\n".join(lines) + "\n"
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--tag", help="find exact release in JSON-lines input")
parser.add_argument("--id", action="store_true", help="print the selected release id")
args = parser.parse_args()
data = sys.stdin.read()
release = find_release(data.splitlines(), args.tag) if args.tag else json.loads(data)
if args.id:
print(int(release["id"]))
else:
# Windows text stdout changes LF to CRLF. SHA256SUMS must have the same
# bytes on every runner, including publish-release.sh's verification.
sys.stdout.buffer.write(checksums(release).encode("ascii"))
if __name__ == "__main__":
try:
main()
except ValueError as exc:
sys.exit(str(exc))
+11
View File
@@ -0,0 +1,11 @@
## Is it safe?
Download only from this GitHub release. Check your installer against [SHA256SUMS](https://github.com/saphid/frame-control/releases/download/{tag}/SHA256SUMS). A matching hash proves that your file matches the file in this release. It does not prove that any program is free of malware.
- Windows PowerShell: `Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256`
- macOS Terminal: `shasum -a 256 Frame-Control-mac-arm64.dmg`
- Linux terminal: `sha256sum Frame-Control-linux-x86_64.AppImage`
Find the same filename in `SHA256SUMS` and compare all 64 hex characters. [Read the source](https://github.com/saphid/frame-control) and [see the build workflow](https://github.com/saphid/frame-control/blob/main/.github/workflows/release.yml). The Windows installer is **not code-signed**; SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**. Gatekeeper may warn or block it. Follow the opening steps in the README only if you trust this download.
日本語: 公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
+7
View File
@@ -257,6 +257,13 @@
<h2>Good questions</h2>
</div>
<div class="faq">
<details id="verify-download">
<summary>Is it safe? How do I check my download?</summary>
<div><p>Starting with <b>v0.4.26</b>, download Frame Control and <a href="https://github.com/saphid/frame-control/releases/latest/download/SHA256SUMS">SHA256SUMS</a> from the same GitHub release. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Compare all 64 characters with the line for your filename.</p>
<p>Windows PowerShell: <code>Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256</code><br>macOS Terminal: <code>shasum -a 256 Frame-Control-mac-arm64.dmg</code><br>Linux terminal: <code>sha256sum Frame-Control-linux-x86_64.AppImage</code></p>
<p>The <a href="https://github.com/saphid/frame-control">source</a> and <a href="https://github.com/saphid/frame-control/blob/main/.github/workflows/release.yml">build workflow</a> are public. The Windows installer is not code-signed, so SmartScreen may warn. The macOS app has only an ad hoc signature, not an Apple Developer ID signature; the DMG is unsigned and not notarized, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.</p>
<p lang="ja">日本語: v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。</p></div>
</details>
<details id="mac-opening">
<summary>macOS says the app is damaged or can't be checked</summary>
<div><p>The app isn't notarized, because there's no paid Apple developer account behind it. Only for a download you trust from the releases linked above: drag it to Applications, then clear the download quarantine once in Terminal:</p>
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import vm from 'node:vm';
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const page = readFileSync(join(root, 'ui', 'mac-view.html'), 'utf8');
const match = page.match(/function windowSwitchEvents\(hostName\) \{[\s\S]*?\n\}/);
assert.ok(match, 'viewer exposes a bounded host-specific switch sequence');
const events = vm.runInNewContext(`(${match[0]})`);
for (const [host, modifier, mod] of [
['mac', 'MetaLeft', 'meta'], ['windows', 'AltLeft', 'alt'],
]) {
const sequence = JSON.parse(JSON.stringify(events(host)));
assert.deepEqual(sequence.map(x => [x.e, x.code]),
[['down', modifier], ['down', 'Tab'], ['up', 'Tab'], ['up', modifier]]);
assert.deepEqual(sequence[1].mods, [mod]);
assert.deepEqual(sequence[2].mods, [mod]);
}
assert.deepEqual(JSON.parse(JSON.stringify(events('linux'))), []);
assert.match(page, /switchButton\.hidden = !src\.startsWith\("display:"\)/);
console.log('window switch sequences passed');
+14
View File
@@ -7,6 +7,7 @@ Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import hashlib
import os
import subprocess
import tempfile
@@ -26,6 +27,9 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
if assets is None:
assets = [{"id": 100 + i, "name": n, "size": 1000 + i, "digest": "sha256:" + "%064x" % i}
for i, n in enumerate(INSTALLERS)]
body = "".join(f"{i:064x} {n}\n" for i, n in enumerate(INSTALLERS)).encode()
assets.append({"id": 200, "name": "SHA256SUMS", "size": len(body),
"digest": "sha256:" + hashlib.sha256(body).hexdigest()})
return {"id": rid, "tag_name": tag_name, "name": name, "draft": True, "prerelease": False,
"body": "notes", "html_url": "https://github.com/saphid/frame-control/releases/tag/" + tag_name,
"assets": assets}
@@ -79,6 +83,16 @@ class PublishRelease(unittest.TestCase):
self.assertIsNone(upload)
self.assertFalse(any(c[1:3] == ["-X", "PATCH"] for c in log))
def test_refuses_missing_or_stale_checksums(self):
for assets in (draft()["assets"][:-1],
draft()["assets"][:-1] + [{"id": 200, "name": "SHA256SUMS",
"digest": "sha256:" + "0" * 64}]):
p, log, upload = self.run_script([draft(assets=assets)], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("SHA256SUMS is absent or does not match", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("PATCH" in c for c in log))
def test_refuses_ambiguous_or_absent_drafts_and_missing_tags(self):
p, _, _ = self.run_script([draft(rid=1), draft(rid=2)], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
+38
View File
@@ -0,0 +1,38 @@
"""Checksum generator uses only complete, unambiguous GitHub asset metadata."""
import sandbox # noqa: F401
import hashlib
import importlib.util
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "release-checksums.py"
spec = importlib.util.spec_from_file_location("release_checksums", SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class Checksums(unittest.TestCase):
def test_complete_list_matches_bytes(self):
assets = []
for i, name in enumerate(module.INSTALLERS):
assets.append({"name": name, "digest": "sha256:" + hashlib.sha256(str(i).encode()).hexdigest()})
actual = module.checksums({"assets": assets})
self.assertEqual(len(actual.splitlines()), 8)
self.assertEqual(actual.splitlines()[0], hashlib.sha256(b"0").hexdigest() + " " + module.INSTALLERS[0])
def test_missing_duplicate_and_bad_digest_fail(self):
assets = [{"name": name, "digest": "sha256:" + "0" * 64} for name in module.INSTALLERS]
for changed in (assets[:-1], assets + [assets[0]],
[dict(assets[0], digest="sha256:bad"), *assets[1:]]):
with self.assertRaises(ValueError):
module.checksums({"assets": changed})
def test_tag_or_exact_untagged_draft(self):
lines = ['{"id": 1, "tag_name": "untagged-a", "draft": true, "name": "Frame Control 9.8.7"}',
'{"id": 2, "tag_name": "v9.8.8", "draft": false, "name": "Frame Control 9.8.8"}']
self.assertEqual(module.find_release(lines, "v9.8.7")["id"], 1)
self.assertEqual(module.find_release(lines, "v9.8.8")["id"], 2)
if __name__ == "__main__":
unittest.main()
+17
View File
@@ -0,0 +1,17 @@
"""The viewer sends one explicit native window-switch chord per click."""
from pathlib import Path
import shutil
import subprocess
import unittest
class WindowSwitch(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is required for the viewer script check')
def test_host_chords_and_display_only_guard(self):
script = Path(__file__).parent / 'page' / 'window_switch.mjs'
result = subprocess.run(['node', str(script)], capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == '__main__':
unittest.main()
+27
View File
@@ -37,6 +37,33 @@ test('click reads once, disables repeated clicks and renders partial inventory',
assert.match(f.el('wirelessResult').innerHTML,/distinct Wi-Fi Direct/);
assert.match(f.el('wirelessStatus').textContent,/Partial PC snapshot/);
});
test('two explicit reads compare exact identities and known state changes without claiming a repair',async()=>{
const f=fixture();
const first={...sample,sources:{pnp:'ok'},devices:[{...sample.devices[0],id:'SWD\\WIFIDIRECT\\one',
name:'Adapter <one>',present:true,problemCode:0}]};
const second={...first,observedAt:'2026-10-24T12:01:00Z',devices:[
{...first.devices[0],present:false,problemCode:22},
{...first.devices[0],id:'SWD\\WIFIDIRECT\\two',name:'Adapter <two>'}]};
let calls=0; f.context.api=async()=> ++calls===1 ? first : second;
await f.el('wirelessRead').onclick();
assert.match(f.el('wirelessComparison').innerHTML,/Read PC adapters again/);
assert.equal(f.el('wirelessForget').disabled,false);
await f.el('wirelessRead').onclick();
const change=f.el('wirelessComparison').innerHTML;
for(const phrase of ['Changes since previous read','Newly listed','presence Yes → No',
'problem code 0 → 22','not a safe disable target','&lt;one&gt;','&lt;two&gt;']) assert.ok(change.includes(phrase),phrase);
assert.doesNotMatch(change,/<one>|<two>/);
f.el('wirelessForget').onclick();
assert.equal(f.el('wirelessComparison').textContent,'');
assert.equal(f.el('wirelessForget').disabled,true);
});
test('incomplete PnP reads cannot imply a device appeared or vanished',()=>{
const f=fixture(); f.context.before={...sample,sources:{pnp:'unavailable'}};
f.context.after={...sample,sources:{pnp:'ok'},devices:[]};
const out=f.run('compareWireless(before,after)');
assert.match(out,/comparison unavailable/);
assert.doesNotMatch(out,/No longer listed|Newly listed/);
});
test('unsupported, busy and failed probes leave no stale inventory and allow retry',async()=>{
for(const result of [{state:'unsupported',message:'Windows only'},{state:'busy',message:'Try again'},new Error('Denied')]) {
const f=fixture();f.el('wirelessResult').textContent='stale inventory';
+2 -1
View File
@@ -411,7 +411,8 @@ class MacView:
# A single-use ticket for this source, not Frame Control's key: the URL
# is visible in the Frame's process list.
ticket = self.call("/ticket", method="POST", src=src)["ticket"]
params = {"src": src, "t": ticket, "tag": tag, "codec": q["codec"], "max": q["max"], "fps": q["fps"],
params = {"src": src, "t": ticket, "tag": tag, "host": self.host,
"codec": q["codec"], "max": q["max"], "fps": q["fps"],
"bpp": q["bpp"]}
url = f"http://127.0.0.1:{self.remote_port}/view?{urlencode(params)}"
w, h = fit(width or 1280, height or 720)
+52 -2
View File
@@ -1441,8 +1441,9 @@
<section class="panel" id="wireless" hidden>
<h2>Wireless Adapter doctor</h2>
<p class="sub">Read-only snapshot of this Windows PC. Works without a headset connection. Nothing on the Frame is read or changed.</p>
<button class="action" id="wirelessRead">Read PC adapters</button>
<div class="row"><button class="action" id="wirelessRead">Read PC adapters</button><button id="wirelessForget" disabled>Forget previous read</button></div>
<p id="wirelessStatus" role="status" aria-live="polite">Choose Read PC adapters to start. No automatic polling.</p>
<div id="wirelessComparison" aria-live="polite"></div>
<div id="wirelessResult"></div>
<div class="card" style="margin-top:16px"><h3>Before changing an adapter</h3>
<p>Multiple Microsoft Wi-Fi Direct Virtual Adapter instances can be normal. A number such as #2, a disconnected link or a non-present device does not establish which instance is extra or safe to disable. This tool cannot reliably identify the Steam dongle's primary or active instance, so it offers no disable action.</p>
@@ -1452,6 +1453,14 @@
<p>A Razer or other 2.4 GHz audio dongle can share spectrum with Wi-Fi. This inventory cannot measure interference, detect the audio dongle's channel or prove what caused a dropout or PC shutdown.</p>
<p>Try wired headphones briefly and compare. Space USB radio dongles apart with an extension lead; if your PC uses 2.4 GHz Wi-Fi, compare Ethernet or an existing 5/6 GHz network. Change one thing at a time and keep notes. No network or security settings are changed here.</p>
</div>
<div class="card" style="margin-top:16px"><h3>If streaming drops as it starts</h3>
<p>Read PC adapters before starting, then refresh immediately after a drop. Compare exact PnP instance IDs, presence and problem codes; a new numbered name alone does not identify a fault. Note whether the PC's own Wi-Fi also drops.</p>
<p>Retry once with wired PC networking or wired audio if available, then with the radio dongles spaced apart. Change one condition at a time. The PC Wi-Fi channel shown above is not the Steam dongle channel, and this check cannot prove RF interference.</p>
</div>
<div class="card" style="margin-top:16px"><h3>If the adapter will not reconnect</h3>
<p>Stop the failed stream, close Steam's streaming session, and check that the adapter still appears in the refreshed PC snapshot. If it is absent, reconnect the USB dongle once at the PC and read again; this will interrupt any connection using it.</p>
<p>If the same exact PnP instance shows problem code 22 (disabled), use Windows Device Manager to review that instance and its enable action. Do not enable or disable a different numbered instance by guesswork. If it stays missing or reports another problem code, keep the before/after IDs and error for Steam Support. Frame Control does not change the driver, radio or network settings.</p>
</div>
</section>
<section class="panel" id="powerReport">
@@ -5662,7 +5671,34 @@ document.querySelectorAll('[data-power-format]').forEach(b => b.onclick = async
// ---- Read-only PC wireless doctor ----
let wirelessSeq = 0;
let wirelessPrevious = null; // memory only; never sent or saved
const wirelessKnown = value => value === true ? 'Yes' : value === false ? 'No' : 'Unknown';
function compareWireless(previous, current) {
if (previous.sources?.pnp !== 'ok' || current.sources?.pnp !== 'ok')
return '<p class="hint">Exact device comparison unavailable: the PnP inventory was incomplete in one read. The current snapshot is shown below.</p>';
const keyed = snapshot => new Map((snapshot.devices || [])
.filter(d => typeof d.id === 'string' && d.id)
.map(d => [d.id.toLowerCase(), d]));
const before = keyed(previous), after = keyed(current), changes = [];
const describe = d => `${esc(d.name || 'Unnamed device')} (<code>${esc(d.id)}</code>)`;
for (const [id, device] of after) {
const old = before.get(id);
if (!old) { changes.push(`Newly listed: ${describe(device)}`); continue; }
for (const [field, label] of [['present','presence'], ['pnpStatus','PnP status'],
['problemCode','problem code'], ['operational','operation'], ['media','media'],
['defaultRoute','default route'], ['channel','reported channel'], ['band','reported band']]) {
if (old[field] == null || device[field] == null || old[field] === device[field]) continue;
const value = v => typeof v === 'boolean' ? wirelessKnown(v) : esc(v);
changes.push(`${describe(device)}: ${label} ${value(old[field])} → ${value(device[field])}`);
}
}
for (const [id, device] of before) if (!after.has(id)) changes.push(`No longer listed: ${describe(device)}`);
const shown = changes.slice(0, 25);
return `<div class="card"><h3>Changes since previous read</h3>
<p class="sub">${esc(previous.observedAt || 'Earlier read')} → ${esc(current.observedAt || 'Current read')}. These are reported changes, not proof of a new fault or an unused adapter. A newly listed numbered Wi-Fi Direct instance is not a safe disable target.</p>
${shown.length ? `<ul>${shown.map(line => `<li>${line}</li>`).join('')}</ul>` : '<p>No reported device changes in the available fields.</p>'}
${changes.length > shown.length ? `<p>${changes.length - shown.length} more changes omitted; inspect the current snapshot below.</p>` : ''}</div>`;
}
function renderWireless(r) {
const unknown = value => value == null ? 'Unknown' : esc(value);
const states = (value, known) => known[value] || (value == null ? 'Unknown' : `Other (${esc(value)})`);
@@ -5681,6 +5717,8 @@ function renderWireless(r) {
$('wirelessRead').onclick = async () => {
const seq = ++wirelessSeq;
$('wirelessRead').disabled = true;
$('wirelessForget').disabled = true;
$('wirelessComparison').textContent = '';
$('wirelessResult').textContent = '';
$('wirelessStatus').textContent = 'Reading this PC’s adapters…';
try {
@@ -5690,14 +5728,26 @@ $('wirelessRead').onclick = async () => {
$('wirelessStatus').textContent = r.message || 'PC wireless inventory unavailable.';
return;
}
$('wirelessComparison').innerHTML = wirelessPrevious ? compareWireless(wirelessPrevious, r) :
'<p class="hint">Read PC adapters again after a dropout to compare exact device reports. The previous read stays only in this open app.</p>';
wirelessPrevious = r;
$('wirelessResult').innerHTML = renderWireless(r);
$('wirelessStatus').textContent = r.state === 'partial' ? 'Partial PC snapshot. Some sources are unavailable. No settings changed.' : 'PC snapshot received. Unknown channel/band fields may remain. No settings changed.';
} catch (e) {
if (seq === wirelessSeq) $('wirelessStatus').textContent = `PC wireless inventory unavailable: ${e.message}`;
} finally {
if (seq === wirelessSeq) $('wirelessRead').disabled = false;
if (seq === wirelessSeq) {
$('wirelessRead').disabled = false;
$('wirelessForget').disabled = !wirelessPrevious;
}
}
};
$('wirelessForget').onclick = () => {
wirelessPrevious = null;
$('wirelessForget').disabled = true;
$('wirelessComparison').textContent = '';
$('wirelessStatus').textContent = 'Previous adapter read forgotten. Read again to start a new comparison.';
};
// ---- Read-only game storage ----
let storageSeq = 0;
+39 -1
View File
@@ -18,10 +18,16 @@
#note[hidden] { display: block; opacity: 0; }
#hud { position: fixed; left: 8px; top: 8px; font: 12px/1.35 ui-monospace, monospace; color: #dfe;
background: rgba(0,0,0,.72); padding: 6px 9px; border-radius: 6px; pointer-events: none; white-space: pre; }
#switchWindow { position: fixed; right: 12px; bottom: 12px; z-index: 2; min-height: 44px;
padding: 8px 14px; border: 1px solid #a8c8d9; border-radius: 7px;
background: rgba(19,35,48,.9); color: #fff; font: 15px system-ui, sans-serif; cursor: pointer; }
#switchWindow:focus-visible { outline: 3px solid #66c0f4; outline-offset: 3px; }
#switchWindow:disabled { opacity: .5; cursor: default; }
</style>
</head>
<body>
<canvas id="c" width="16" height="9"></canvas>
<button id="switchWindow" type="button" hidden>Switch window</button>
<div id="note">Connecting to your computer…</div>
<div id="hud" hidden></div>
<script>
@@ -29,10 +35,14 @@
const q = new URLSearchParams(location.search);
// t: a single-use ticket from Frame Control. After the first connection the
// Mac sends a reconnect key, kept only in memory.
const src = q.get("src") || "test", ticket = q.get("t") || "", tag = q.get("tag") || "";
const src = q.get("src") || "test", ticket = q.get("t") || "", tag = q.get("tag") || "", host = q.get("host") || "";
document.title = host === "windows" ? "PC" : "Mac";
let reconnectKey = "";
const c = document.getElementById("c"), ctx = c.getContext("2d", { alpha: false, desynchronized: true });
const note = document.getElementById("note");
const switchButton = document.getElementById("switchWindow");
switchButton.hidden = !src.startsWith("display:") || !["mac", "windows"].includes(host);
if (!switchButton.hidden) switchButton.textContent = host === "mac" ? "Switch Mac app (⌘Tab)" : "Switch PC window (Alt+Tab)";
// Counters for Frame Control's tests (read over DevTools).
const stats = window.stats = { frames: 0, keyFrames: 0, bytes: 0, dropped: 0, codec: "", errors: [], info: null };
@@ -127,6 +137,29 @@ function show(text, ms) {
}
function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
function windowSwitchEvents(hostName) {
const code = hostName === "mac" ? "MetaLeft" : hostName === "windows" ? "AltLeft" : "";
if (!code) return [];
const key = hostName === "mac" ? "Meta" : "Alt", mod = hostName === "mac" ? "meta" : "alt";
return [
{ t: "k", e: "down", code, key, mods: [] },
{ t: "k", e: "down", code: "Tab", key: "Tab", mods: [mod] },
{ t: "k", e: "up", code: "Tab", key: "Tab", mods: [mod] },
{ t: "k", e: "up", code, key, mods: [] },
];
}
switchButton.addEventListener("click", () => {
if (!ws || ws.readyState !== 1) return show("Connect to your computer before switching windows.", 4000);
const events = windowSwitchEvents(host);
if (!events.length) return;
switchButton.disabled = true;
send(stamp(events[0]));
setTimeout(() => {
send(stamp(events[1]));
send(events[2]);
setTimeout(() => { send(events[3]); switchButton.disabled = false; }, 60);
}, 40);
});
let lastKeyAsk = 0;
function askKeyFrame() {
const now = performance.now();
@@ -321,6 +354,7 @@ function flushMove() {
pendingMove = null;
}
addEventListener("pointermove", e => {
if (e.target === switchButton) return;
const p = toPicture(e);
if (!p.inside && !e.buttons) return;
pendingMove = { t: "m", e: "move", x: p.x, y: p.y };
@@ -331,6 +365,7 @@ addEventListener("pointermove", e => {
setTimeout(flushMove, wait);
});
addEventListener("pointerdown", e => {
if (e.target === switchButton) return;
const p = toPicture(e);
if (!p.inside) return;
if (e.pointerId !== undefined) try { c.setPointerCapture(e.pointerId); } catch (err) {}
@@ -339,6 +374,7 @@ addEventListener("pointerdown", e => {
e.preventDefault();
});
addEventListener("pointerup", e => {
if (e.target === switchButton) return;
const p = toPicture(e);
send({ t: "m", e: "up", b: e.button < 0 ? 0 : e.button, x: p.x, y: p.y });
});
@@ -346,6 +382,7 @@ addEventListener("pointerup", e => {
addEventListener("pointercancel", () => send({ t: "release" }));
addEventListener("contextmenu", e => e.preventDefault());
addEventListener("wheel", e => {
if (e.target === switchButton) return;
const p = toPicture(e), unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? innerHeight : 1;
send(stamp({ t: "wheel", dx: e.deltaX * unit, dy: e.deltaY * unit, x: p.x, y: p.y }, e));
e.preventDefault();
@@ -354,6 +391,7 @@ function mods(e) {
return ["shift", "ctrl", "alt", "meta"].filter(m => e[m + "Key"]);
}
function onKey(e, down) {
if (e.target === switchButton) return; // let Enter/Space activate the button
// Ctrl+Alt+Shift+S shows the numbers; it isn't sent to the Mac.
if (e.code === "KeyS" && e.ctrlKey && e.altKey && e.shiftKey) {
if (down) showHud(hud.hidden);