mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 08:01:44 +01:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61f576ebab | ||
|
|
c9852f5762 | ||
|
|
f6ecf9b1f4 | ||
|
|
630cdd7b34 | ||
|
|
74089fb2f1 | ||
|
|
7f828cbcf8 | ||
|
|
42665d14db | ||
|
|
8dd6eb3fe9 |
No files matched your search
@@ -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
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.24",
|
||||
"version": "0.4.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.24",
|
||||
"version": "0.4.25",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.24",
|
||||
"version": "0.4.25",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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 による署名・公証がありません。
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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','<one>','<two>']) 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';
|
||||
|
||||
+52
-2
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user