mirror of
https://github.com/miyu0-bit/frameeye-helper.git
synced 2026-10-04 22:00:09 +02:00
first version
This commit is contained in:
commit
3d43bfe5ca
47 files changed
+25167
No files matched your search
+11
@@ -0,0 +1,11 @@
|
||||
# build output
|
||||
target/
|
||||
target-*/
|
||||
/build/cargo-home/
|
||||
/build/target-upstream/
|
||||
/build/target-fake/
|
||||
/build/panel-preview/
|
||||
/build/.*.log
|
||||
/dist/
|
||||
/preview/bin/
|
||||
/tests/fakeframe/opt/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "upstream/frameeyeosc"]
|
||||
path = upstream/frameeyeosc
|
||||
url = https://github.com/sasaken1102r/frameeyeosc.git
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 miyu0-bit
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,106 @@
|
||||
# FrameEye Helper
|
||||
|
||||
Steam Frame eye tracking in VRChat, without touching a terminal.
|
||||
|
||||

|
||||
|
||||
FrameEye Helper installs [frameeyeosc](https://github.com/sasaken1102r/frameeyeosc) on your Steam Frame,
|
||||
so the headset's eye tracking (where you look, and your blinks) shows up on your avatar in VRChat.
|
||||
Normally that means SSHing into the headset and running Linux commands. Here you type the headset's
|
||||
password and click Install. After that frameeyeosc starts by itself whenever SteamVR runs, so you won't
|
||||
need the app again.
|
||||
|
||||
## Download
|
||||
|
||||
Get `FrameEyeHelper-Windows.zip` from the [latest release](https://github.com/miyu0-bit/frameeye-helper/releases/latest),
|
||||
unzip it and run `FrameEyeHelper.exe`. Nothing gets installed on your PC.
|
||||
|
||||
Windows will probably show a blue "Windows protected your PC" box because the exe isn't signed. Click
|
||||
**More info**, then **Run anyway**.
|
||||
|
||||
There's a Linux build in the release too.
|
||||
|
||||
## How to use it
|
||||
|
||||
First, Developer Mode on the headset:
|
||||
|
||||
1. On the Frame, go to **Settings > System** and turn on **Developer Mode**.
|
||||
2. Set a password in the new **Developer** section (and turn on SSH if there's a switch for it). Pick a
|
||||
real password, anyone on your network who knows it can log into the headset.
|
||||
|
||||
Then in the app:
|
||||
|
||||
1. **Connect**: click *Find my headset* (or type its IP), enter the password, hit Connect.
|
||||
2. **Install**: one button. It copies frameeyeosc 0.5.3 over (it's built into the app, nothing gets
|
||||
downloaded), checks it arrived intact and runs frameeyeosc's own installer.
|
||||
3. **Check it works**: put the headset on and stream from your PC like you normally do. The last page
|
||||
shows live whether eye data is going out, and whether it's going to this PC.
|
||||
|
||||
<img src="docs/images/helper-connect.png" width="480" alt="The connect page">
|
||||
|
||||
A few things on the PC side:
|
||||
|
||||
- Turn **off** Steam Link's own OSC (SteamVR > Settings > Steam Link > OSC), otherwise the two fight over
|
||||
your avatar's eyes.
|
||||
- Turn **on** OSC in VRChat (Action Menu > Options > OSC > Enabled).
|
||||
- Use an avatar with VRCFaceTracking eye parameters.
|
||||
|
||||
If the eyes still don't move, there's a button that adds a Windows Firewall rule for VRChat (only UDP
|
||||
port 9000, only from your local network).
|
||||
|
||||
Once it's running you get an **Eye** panel on the SteamVR dashboard. Do the eye fit there once (Eye fit
|
||||
tab, *Fit my eyes*, close the dashboard and follow the dot). Takes about 20 seconds and makes a big
|
||||
difference.
|
||||
|
||||
<img src="docs/images/eye-panel.png" width="600" alt="The Eye panel on the SteamVR dashboard">
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Couldn't reach the headset**: is it on and awake, on the same network as the PC, and is the IP right?
|
||||
- **The headset isn't accepting logins**: Developer Mode or SSH isn't on yet, or there's no password set.
|
||||
- **Waiting for eye data**: you have to actually wear it, it only tracks while it's on your face.
|
||||
- **Avatar's eyes don't move**: check VRChat's OSC is on, try another avatar, try setting
|
||||
*EyeTrackingActive* to Float on the Eye panel's Basic tab, then try the firewall button.
|
||||
- **Stopped working after a SteamOS update**: the Frame's eye data format probably changed. Update
|
||||
frameeyeosc from the Eye panel (Advanced > Check now).
|
||||
|
||||
The last page of the app also has a *Show headset log* section that shows what frameeyeosc is up to.
|
||||
|
||||
## Trying it without a headset
|
||||
|
||||
There's a demo mode (link on the first page) that fakes a headset so you can click through everything.
|
||||
|
||||
On Linux you also get a preview of the in-headset Eye panel. It runs the real frameeyeosc and its panel
|
||||
in a window, fed by fake eyes that look around and blink. Space closes the fake dashboard so you can do
|
||||
the eye fit and watch the dot. frameeyeosc runs in its own network namespace there, so nothing reaches
|
||||
VRChat.
|
||||
|
||||
## Building
|
||||
|
||||
Everything builds inside a Fedora container, so all you need is podman:
|
||||
|
||||
```sh
|
||||
git clone --recursive https://github.com/miyu0-bit/frameeye-helper
|
||||
cd frameeye-helper
|
||||
build/build-helper.sh # tests, then FrameEyeHelper.exe and the Linux binary
|
||||
build/build-preview.sh # the Linux Eye panel preview
|
||||
build/package.sh # puts it all together in dist/
|
||||
```
|
||||
|
||||
It's Rust, with egui for the window and russh for SSH. Every command it runs on the headset is in
|
||||
[helper/src/remote.rs](helper/src/remote.rs) if you want to see exactly what it does. There's also a fake
|
||||
Steam Frame for testing, see [docs/testing.md](docs/testing.md).
|
||||
|
||||
## Credits
|
||||
|
||||
The actual eye tracking is all [frameeyeosc](https://github.com/sasaken1102r/frameeyeosc) by sasaken1102r,
|
||||
which builds on [konsti219's original](https://github.com/konsti219/frameeyeosc) (the one that figured out
|
||||
where the Frame keeps its eye data). This app just installs it. The Eye icon comes from frameeyeosc's panel.
|
||||
|
||||
Not affiliated with Valve, VRChat, VRCFaceTracking or the frameeyeosc authors. frameeyeosc reads data the
|
||||
Frame doesn't officially expose, so have a look at the disclaimer in its README before using it.
|
||||
|
||||
## License
|
||||
|
||||
MIT, see [LICENSE](LICENSE). frameeyeosc is MIT too. Licenses of everything built into the binaries are in
|
||||
[THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md).
|
||||
+13918
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,16 @@
|
||||
# Build environment for FrameEye Helper (Windows + Linux) and the Linux panel preview.
|
||||
# Everything is built inside this container so nothing has to be installed on the host.
|
||||
# podman build -t frameeye-build -f build/Containerfile build/
|
||||
FROM registry.fedoraproject.org/fedora:44
|
||||
RUN dnf -y install --setopt=install_weak_deps=False \
|
||||
rust cargo rust-std-static-x86_64-pc-windows-gnu \
|
||||
mingw64-gcc mingw64-binutils mingw64-winpthreads-static \
|
||||
gcc gcc-c++ cmake ninja-build pkgconf-pkg-config make \
|
||||
cairo-devel freetype-devel sdl2-compat-devel vulkan-loader-devel vulkan-headers \
|
||||
file python3 \
|
||||
&& dnf clean all
|
||||
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc \
|
||||
CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc \
|
||||
AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar \
|
||||
CARGO_HOME=/cargo-home
|
||||
WORKDIR /src
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds FrameEye Helper for Windows (x86_64, .exe) and Linux inside the frameeye-build container,
|
||||
# runs the unit tests, and copies the results to dist/.
|
||||
# build/build-helper.sh (from the project root, or anywhere)
|
||||
set -euo pipefail
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
image=frameeye-build
|
||||
if ! podman image exists "$image"; then
|
||||
podman build -t "$image" -f "$root/build/Containerfile" "$root/build"
|
||||
fi
|
||||
mkdir -p "$root/build/cargo-home" "$root/dist"
|
||||
podman run --rm --userns=keep-id \
|
||||
-v "$root/helper:/src:Z" \
|
||||
-v "$root/build/cargo-home:/cargo-home:Z" \
|
||||
-e CARGO_TARGET_DIR=/src/target-container \
|
||||
"$image" bash -c '
|
||||
set -euo pipefail
|
||||
cargo test --release --locked
|
||||
cargo build --release --locked --target x86_64-unknown-linux-gnu
|
||||
cargo build --release --locked --target x86_64-pc-windows-gnu
|
||||
'
|
||||
cp "$root/helper/target-container/x86_64-pc-windows-gnu/release/FrameEyeHelper.exe" "$root/dist/FrameEyeHelper.exe"
|
||||
cp "$root/helper/target-container/x86_64-unknown-linux-gnu/release/FrameEyeHelper" "$root/dist/FrameEyeHelper-linux"
|
||||
ls -la "$root/dist"
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the Linux preview of the headset's Eye panel inside the frameeye-build container:
|
||||
# frameeyeosc (the headset program, for this PC), the pretend eye tracker, and the panel as a window.
|
||||
# Results go to preview/bin/.
|
||||
set -euo pipefail
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
image=frameeye-build
|
||||
if ! podman image exists "$image"; then
|
||||
podman build -t "$image" -f "$root/build/Containerfile" "$root/build"
|
||||
fi
|
||||
mkdir -p "$root/build/cargo-home" "$root/preview/bin"
|
||||
podman run --rm --userns=keep-id \
|
||||
-v "$root:/w:Z" \
|
||||
-v "$root/build/cargo-home:/cargo-home:Z" \
|
||||
"$image" bash -c '
|
||||
set -euo pipefail
|
||||
cd /w/upstream/frameeyeosc && cargo build --release --locked --target-dir /w/build/target-upstream
|
||||
cd /w/preview/fake-eye-server && cargo build --release --target-dir /w/build/target-fake
|
||||
cmake -G Ninja -S /w/preview/panel-desktop -B /w/build/panel-preview -DCMAKE_BUILD_TYPE=Release >/dev/null
|
||||
ninja -C /w/build/panel-preview
|
||||
'
|
||||
cp "$root/build/target-upstream/release/frameeyeosc" "$root/preview/bin/"
|
||||
cp "$root/build/target-fake/release/fake-eye-server" "$root/preview/bin/"
|
||||
cp "$root/build/panel-preview/frameeyeosc-panel-preview" "$root/preview/bin/"
|
||||
chmod +x "$root/preview/run-panel-preview.sh"
|
||||
ls -la "$root/preview/bin"
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# Puts the finished downloads together in dist/ (run build-helper.sh and build-preview.sh first):
|
||||
# dist/FrameEyeHelper.exe the Windows app
|
||||
# dist/FrameEyeHelper-Windows.zip the same, with a short "read me first", for sending
|
||||
# dist/linux/ the same app for Linux, plus the Eye panel preview
|
||||
# dist/FrameEyeHelper-Linux.tar.gz dist/linux/ packed up for the release page
|
||||
set -euo pipefail
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
dist="$root/dist"
|
||||
if [[ -f "$dist/linux/FrameEyeHelper" && ! -f "$dist/FrameEyeHelper-linux" ]]; then
|
||||
mv "$dist/linux/FrameEyeHelper" "$dist/FrameEyeHelper-linux" # packaging again
|
||||
fi
|
||||
test -f "$dist/FrameEyeHelper.exe" && test -f "$dist/FrameEyeHelper-linux" || { echo "run build/build-helper.sh first" >&2; exit 1; }
|
||||
test -x "$root/preview/bin/frameeyeosc-panel-preview" || { echo "run build/build-preview.sh first" >&2; exit 1; }
|
||||
|
||||
rm -rf "$dist/linux" "$dist/FrameEyeHelper-Windows.zip"
|
||||
mkdir -p "$dist/linux/FrameEye-Panel-Preview/bin"
|
||||
mv "$dist/FrameEyeHelper-linux" "$dist/linux/FrameEyeHelper"
|
||||
cp "$root/preview/run-panel-preview.sh" "$dist/linux/FrameEye-Panel-Preview/"
|
||||
cp "$root/preview/bin/"* "$dist/linux/FrameEye-Panel-Preview/bin/"
|
||||
cp "$root/helper/assets/FRAMEEYEOSC_LICENSE.txt" "$dist/linux/FrameEye-Panel-Preview/FRAMEEYEOSC_LICENSE.txt"
|
||||
cp "$root/upstream/frameeyeosc/THIRD_PARTY_LICENSES.md" "$dist/linux/FrameEye-Panel-Preview/FRAMEEYEOSC_THIRD_PARTY_LICENSES.md"
|
||||
cp "$root/LICENSE" "$root/THIRD_PARTY_LICENSES.md" "$dist/linux/"
|
||||
|
||||
cat > "$dist/linux/README.txt" <<'EOF'
|
||||
FrameEye Helper for Linux: the same app as FrameEyeHelper.exe, plus a preview of the headset's Eye panel.
|
||||
|
||||
FrameEyeHelper
|
||||
Double-click it (or run ./FrameEyeHelper). "Try the demo" on the first page walks through every
|
||||
step with a pretend headset. The "Open the Eye panel preview" button (Linux only) opens the
|
||||
in-headset Eye panel in a window.
|
||||
|
||||
FrameEye-Panel-Preview/run-panel-preview.sh
|
||||
The same preview from a terminal. It runs the real frameeyeosc and its Eye panel, fed by a pretend
|
||||
Steam Frame. Space (or the button at the top) closes and opens the pretend dashboard; with it closed
|
||||
you see what the headset shows, like the eye fit's dot. --reset starts over with default settings.
|
||||
|
||||
Nothing here sends anything to VRChat: the preview's frameeyeosc runs in its own empty network, and its
|
||||
settings live in a scratch folder ($XDG_RUNTIME_DIR/frameeye-preview) that is gone after logging out.
|
||||
Needs SDL2, cairo, freetype and the Noto Sans CJK fonts (all standard on Fedora).
|
||||
EOF
|
||||
|
||||
readme="$(mktemp)"
|
||||
cat > "$readme" <<'EOF'
|
||||
FrameEye Helper - Steam Frame eye tracking for VRChat
|
||||
======================================================
|
||||
|
||||
1. Double-click FrameEyeHelper.exe.
|
||||
If Windows says "Windows protected your PC", click "More info", then "Run anyway".
|
||||
(The app isn't signed by a company, so Windows doesn't know it yet. Nothing gets installed on this PC.)
|
||||
|
||||
2. Follow the 4 steps in the window:
|
||||
Prepare headset -> Connect -> Install -> Check it works
|
||||
|
||||
You only need it once. After that, eye tracking starts by itself whenever SteamVR runs on the headset.
|
||||
EOF
|
||||
python3 - "$dist/FrameEyeHelper-Windows.zip" "$dist/FrameEyeHelper.exe" "$readme" "$root/LICENSE" "$root/THIRD_PARTY_LICENSES.md" <<'EOF'
|
||||
import sys, zipfile
|
||||
out, exe, readme, license_file, third_party = sys.argv[1:6]
|
||||
crlf = lambda path: open(path, encoding="utf-8").read().replace("\n", "\r\n")
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
|
||||
z.write(exe, "FrameEyeHelper/FrameEyeHelper.exe")
|
||||
z.writestr("FrameEyeHelper/READ ME FIRST.txt", crlf(readme))
|
||||
z.writestr("FrameEyeHelper/LICENSE.txt", crlf(license_file))
|
||||
z.writestr("FrameEyeHelper/THIRD_PARTY_LICENSES.md", crlf(third_party))
|
||||
EOF
|
||||
rm -f "$readme"
|
||||
rm -f "$dist/FrameEyeHelper-Linux.tar.gz"
|
||||
tar -C "$dist" --transform 's,^linux,FrameEyeHelper-Linux,' -czf "$dist/FrameEyeHelper-Linux.tar.gz" linux
|
||||
(cd "$dist" && sha256sum FrameEyeHelper-Windows.zip FrameEyeHelper-Linux.tar.gz FrameEyeHelper.exe > SHA256SUMS)
|
||||
find "$dist" -type f -printf '%10s %P\n' | sort -k2
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Writes THIRD_PARTY_LICENSES.md: the licenses of the Rust crates built into FrameEyeHelper (Windows and
|
||||
Linux) and the preview's fake-eye-server. Crates with the same license text are listed together.
|
||||
|
||||
CARGO_HOME=build/cargo-home python3 build/third-party-licenses.py > THIRD_PARTY_LICENSES.md
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PROJECTS = [
|
||||
("helper", ["x86_64-pc-windows-gnu", "x86_64-unknown-linux-gnu"]),
|
||||
("preview/fake-eye-server", ["x86_64-unknown-linux-gnu"]),
|
||||
]
|
||||
LICENSE_NAMES = ("license", "licence", "copying", "notice", "unlicense")
|
||||
|
||||
|
||||
def linked_packages(project, target):
|
||||
"""Packages reachable from the project through normal dependencies (build scripts' own deps are left out)."""
|
||||
out = subprocess.run(
|
||||
["cargo", "metadata", "--format-version", "1", "--filter-platform", target],
|
||||
cwd=os.path.join(ROOT, project), check=True, capture_output=True, text=True,
|
||||
).stdout
|
||||
meta = json.loads(out)
|
||||
packages = {p["id"]: p for p in meta["packages"]}
|
||||
nodes = {n["id"]: n for n in meta["resolve"]["nodes"]}
|
||||
root = meta["resolve"]["root"]
|
||||
seen, stack = set(), [root]
|
||||
while stack:
|
||||
node = nodes[stack.pop()]
|
||||
for dep in node["deps"]:
|
||||
if any(kind["kind"] is None for kind in dep["dep_kinds"]) and dep["pkg"] not in seen:
|
||||
seen.add(dep["pkg"])
|
||||
stack.append(dep["pkg"])
|
||||
return [packages[i] for i in seen]
|
||||
|
||||
|
||||
def license_files(package):
|
||||
folder = os.path.dirname(package["manifest_path"])
|
||||
candidates = [(name, os.path.join(folder, name)) for name in sorted(os.listdir(folder))]
|
||||
# egui's font crate keeps the licenses of the fonts it embeds next to them
|
||||
fonts = os.path.join(folder, "fonts")
|
||||
if os.path.isdir(fonts):
|
||||
candidates += [(f"fonts/{name}", os.path.join(fonts, name)) for name in sorted(os.listdir(fonts)) if name.endswith(".txt")]
|
||||
found = []
|
||||
for name, path in candidates:
|
||||
base = os.path.basename(name).lower()
|
||||
if os.path.isfile(path) and (base.startswith(LICENSE_NAMES) or name.startswith("fonts/")):
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
found.append((name, f.read().strip()))
|
||||
return found
|
||||
|
||||
|
||||
def main():
|
||||
crates = {}
|
||||
for project, targets in PROJECTS:
|
||||
for target in targets:
|
||||
for package in linked_packages(project, target):
|
||||
if package["source"] is None: # our own crates
|
||||
continue
|
||||
crates[(package["name"], package["version"])] = package
|
||||
groups = {} # text hash -> (text, [crate labels])
|
||||
no_file = []
|
||||
for (name, version), package in sorted(crates.items()):
|
||||
label = f"{name} {version}"
|
||||
files = license_files(package)
|
||||
if not files:
|
||||
authors = ", ".join(package.get("authors") or []) or "its authors"
|
||||
source = package.get("repository") or package.get("homepage") or ""
|
||||
no_file.append(f"{label}: {package.get('license') or 'no license field'}, (c) {authors} {source}".rstrip())
|
||||
continue
|
||||
for filename, text in files:
|
||||
key = hashlib.sha256(text.encode()).hexdigest()
|
||||
groups.setdefault(key, (filename, text, []))[2].append(label)
|
||||
|
||||
print("# Third-party licenses\n")
|
||||
print("FrameEye Helper is MIT licensed (see LICENSE). Its binaries contain the Rust crates below; each")
|
||||
print("crate's license text follows, crates with the same text grouped together.\n")
|
||||
print("frameeyeosc itself (bundled in the helper, and built into the Linux preview) is MIT, (c) konsti219 and")
|
||||
print("sasaken1102r; its own third-party licenses ship inside its release and in its repository.\n")
|
||||
print(f"{len(crates)} crates.\n")
|
||||
for filename, text, labels in sorted(groups.values(), key=lambda g: g[2][0]):
|
||||
print("---\n")
|
||||
print(f"**{', '.join(labels)}** ({filename})\n")
|
||||
print("```")
|
||||
print(text.replace("```", "'''"))
|
||||
print("```\n")
|
||||
if no_file:
|
||||
print("---\n")
|
||||
print("These crates publish no license file; their license comes from their manifest. The standard")
|
||||
print("texts (MIT, Apache-2.0, ...) appear above with other crates, and at https://spdx.org/licenses/.\n")
|
||||
for label in no_file:
|
||||
print(f"- {label}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,43 @@
|
||||
# Testing without a headset
|
||||
|
||||
There's a fake Steam Frame here, so the app can be tested without a headset.
|
||||
|
||||
## The fake Frame
|
||||
|
||||
`tests/fakeframe` builds a container that acts like the headset as far as the app can tell: a real
|
||||
OpenSSH server with a `steamos` user, the fake eye tracker from the preview, and frameeyeosc built for
|
||||
x86_64. The bits of SteamOS a container doesn't have are faked with small scripts: `uname -m` says
|
||||
aarch64 (frameeyeosc's installer checks it) and a tiny `systemctl --user` stand-in starts frameeyeosc.
|
||||
|
||||
```sh
|
||||
build/build-preview.sh # the fake eye tracker and frameeyeosc for x86_64
|
||||
tests/fakeframe/build-image.sh
|
||||
podman run -d --name fakeframe-test -p 127.0.0.1:2222:22 fakeframe
|
||||
```
|
||||
|
||||
Password is `frame-test-pw`. Start it with `-e KBD_INTERACTIVE=1` to test a server that only does
|
||||
keyboard-interactive logins.
|
||||
|
||||
## Self-test
|
||||
|
||||
Build the app with the `selftest` feature and point it at the container. It goes through the same code the
|
||||
buttons use: wrong password, changed host key, connect, install (the real `install.sh`), live status,
|
||||
sending to this PC, pause and resume, logs, restart and uninstall.
|
||||
|
||||
```sh
|
||||
cd helper
|
||||
cargo build --release --features selftest --target-dir target-selftest
|
||||
SELFTEST_PC_ADDRESS=169.254.1.2 ./target-selftest/release/FrameEyeHelper --selftest 127.0.0.1 2222 steamos frame-test-pw
|
||||
```
|
||||
|
||||
`SELFTEST_PC_ADDRESS` is only there because rootless podman gives the container the PC's own IP, so the
|
||||
test has to use podman's address for the host (`host.containers.internal`) to see the eye data arrive. It
|
||||
listens on UDP 9011, not 9000, so a VRChat running on the same PC doesn't get fake eyes.
|
||||
|
||||
The Windows build can run the same test under Wine.
|
||||
|
||||
## Screenshots
|
||||
|
||||
`FrameEyeHelper --screenshots DIR` clicks through the demo and saves each page. The panel preview takes a
|
||||
little script in `PREVIEW_SCRIPT`, for example
|
||||
`PREVIEW_SCRIPT="wait 3; click 796,52; key space; shot a.png; quit" preview/run-panel-preview.sh`.
|
||||
Generated
+5771
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "frameeye-helper"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
description = "One-click setup for Steam Frame eye tracking in VRChat: installs frameeyeosc on the headset over SSH"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "FrameEyeHelper"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
eframe = { version = "0.36", features = ["glow"] }
|
||||
egui = "0.36"
|
||||
russh = { version = "0.63", default-features = false, features = ["ring", "flate2", "rsa"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "sync", "macros", "io-util"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.11"
|
||||
if-addrs = "0.15"
|
||||
directories = "6"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.55"
|
||||
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_Foundation"] }
|
||||
|
||||
[features]
|
||||
# `--selftest`: drive the SSH worker against a test headset (tests/fakeframe). Not in release builds.
|
||||
selftest = []
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "3"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
opt-level = 3
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 konsti219
|
||||
Copyright (c) 2026 sasaken1102r (changes in this fork)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
553abc2b8f2842f2a4bb5ed9e3452e5bdbe1a511dba47439f058a93d2368cf1c frameeyeosc-0.5.3-steamframe-aarch64.tar.gz
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity type="win32" name="FrameEyeHelper" version="1.0.0.0"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,29 @@
|
||||
#include <winver.h>
|
||||
|
||||
1 ICON "app.ico"
|
||||
1 24 "app.manifest"
|
||||
|
||||
VS_VERSION_INFO VERSIONINFO
|
||||
FILEVERSION 1,0,0,0
|
||||
PRODUCTVERSION 1,0,0,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904B0"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "FrameEye Helper"
|
||||
VALUE "FileDescription", "FrameEye Helper - Steam Frame eye tracking for VRChat"
|
||||
VALUE "FileVersion", "1.0.0"
|
||||
VALUE "InternalName", "FrameEyeHelper"
|
||||
VALUE "OriginalFilename", "FrameEyeHelper.exe"
|
||||
VALUE "ProductName", "FrameEye Helper"
|
||||
VALUE "ProductVersion", "1.0.0"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1200
|
||||
END
|
||||
END
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,11 @@
|
||||
// Windows builds get the Eye icon and a manifest (runs as the normal user, DPI aware) embedded.
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=assets/app.rc");
|
||||
println!("cargo:rerun-if-changed=assets/app.ico");
|
||||
println!("cargo:rerun-if-changed=assets/app.manifest");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
embed_resource::compile("assets/app.rc", embed_resource::NONE)
|
||||
.manifest_optional()
|
||||
.expect("embedding the Windows icon and manifest failed");
|
||||
}
|
||||
}
|
||||
+1700
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,60 @@
|
||||
//! The frameeyeosc release that ships inside this program, so installing needs no download.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// The bundled release (github.com/sasaken1102r/frameeyeosc, tag v0.5.3).
|
||||
pub const VERSION: &str = "0.5.3";
|
||||
pub const TARBALL: &[u8] = include_bytes!("../assets/frameeyeosc-0.5.3-steamframe-aarch64.tar.gz");
|
||||
/// From the release's own SHA256SUMS file.
|
||||
pub const TARBALL_SHA256: &str = "553abc2b8f2842f2a4bb5ed9e3452e5bdbe1a511dba47439f058a93d2368cf1c";
|
||||
/// The `frameeyeosc` binary inside it, to recognise this version when the panel isn't installed.
|
||||
pub const DAEMON_SHA256: &str = "a021fda0fb83cf4760201ded03f4158c644f95e1438cc1de1e48e93e452cdfd5";
|
||||
|
||||
pub const PROJECT_URL: &str = "https://github.com/miyu0-bit/frameeye-helper";
|
||||
pub const UPSTREAM_URL: &str = "https://github.com/sasaken1102r/frameeyeosc";
|
||||
pub const ORIGINAL_URL: &str = "https://github.com/konsti219/frameeyeosc";
|
||||
pub const LICENSE: &str = include_str!("../assets/FRAMEEYEOSC_LICENSE.txt");
|
||||
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// True if the embedded tarball is intact.
|
||||
pub fn verify() -> bool {
|
||||
sha256_hex(TARBALL) == TARBALL_SHA256
|
||||
}
|
||||
|
||||
/// Compare dotted versions ("0.5.3" vs "0.10.0"); non-numeric parts count as 0.
|
||||
pub fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let parse = |v: &str| -> Vec<u64> {
|
||||
v.trim()
|
||||
.trim_start_matches('v')
|
||||
.split(['.', '-', '+'])
|
||||
.take(3)
|
||||
.map(|part| part.parse().unwrap_or(0))
|
||||
.collect()
|
||||
};
|
||||
let (mut a, mut b) = (parse(a), parse(b));
|
||||
a.resize(3, 0);
|
||||
b.resize(3, 0);
|
||||
a.cmp(&b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
#[test]
|
||||
fn bundled_tarball_matches_release_checksum() {
|
||||
assert!(verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_compare_numerically() {
|
||||
assert_eq!(compare_versions("0.5.3", "0.5.3"), Ordering::Equal);
|
||||
assert_eq!(compare_versions("0.4.0", "0.5.3"), Ordering::Less);
|
||||
assert_eq!(compare_versions("0.10.0", "0.9.9"), Ordering::Greater);
|
||||
assert_eq!(compare_versions("v0.6", "0.5.3"), Ordering::Greater);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Windows only: let VRChat receive OSC (UDP 9000) from the headset through Windows Defender Firewall.
|
||||
//! VRChat's own firewall rule is often only allowed on "Public" networks, so OSC arriving over a
|
||||
//! "Private" home network is dropped. The rule added here is narrow: inbound UDP 9000, from the local
|
||||
//! network only, and only for VRChat.exe when it can be found.
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Where VRChat.exe is, from Steam's library list.
|
||||
#[cfg(windows)]
|
||||
pub fn find_vrchat() -> Option<PathBuf> {
|
||||
use winreg::RegKey;
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
|
||||
let mut roots: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(key) = RegKey::predef(HKEY_CURRENT_USER).open_subkey(r"Software\Valve\Steam") {
|
||||
if let Ok(path) = key.get_value::<String, _>("SteamPath") {
|
||||
roots.push(PathBuf::from(path.replace('/', "\\")));
|
||||
}
|
||||
}
|
||||
if let Ok(key) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey(r"SOFTWARE\WOW6432Node\Valve\Steam") {
|
||||
if let Ok(path) = key.get_value::<String, _>("InstallPath") {
|
||||
roots.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
roots.push(PathBuf::from(r"C:\Program Files (x86)\Steam"));
|
||||
|
||||
let mut libraries = roots.clone();
|
||||
for root in &roots {
|
||||
let Ok(text) = std::fs::read_to_string(root.join("steamapps").join("libraryfolders.vdf")) else {
|
||||
continue;
|
||||
};
|
||||
for line in text.lines() {
|
||||
// "path" "D:\\SteamLibrary"
|
||||
if let Some(rest) = line.trim().strip_prefix("\"path\"") {
|
||||
libraries.push(PathBuf::from(rest.trim().trim_matches('"').replace("\\\\", "\\")));
|
||||
}
|
||||
}
|
||||
}
|
||||
libraries
|
||||
.into_iter()
|
||||
.map(|library| library.join("steamapps").join("common").join("VRChat").join("VRChat.exe"))
|
||||
.find(|exe| exe.is_file())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn script(vrchat: Option<&std::path::Path>) -> String {
|
||||
let program = vrchat.map(|path| path.display().to_string().replace('\'', "''")).unwrap_or_default();
|
||||
format!(
|
||||
r#"$ErrorActionPreference = 'Stop'
|
||||
$Host.UI.RawUI.WindowTitle = 'FrameEye Helper - Windows Firewall'
|
||||
Write-Host 'FrameEye Helper: letting VRChat receive eye tracking (OSC, UDP port 9000) from your Steam Frame.'
|
||||
Write-Host ''
|
||||
$name = 'VRChat OSC from Steam Frame (FrameEye Helper)'
|
||||
$program = '{program}'
|
||||
try {{
|
||||
Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue | Remove-NetFirewallRule
|
||||
$rule = @{{ DisplayName = $name; Direction = 'Inbound'; Action = 'Allow'; Protocol = 'UDP'; LocalPort = 9000; RemoteAddress = 'LocalSubnet'; Profile = 'Any' }}
|
||||
if ($program) {{ $rule.Program = $program }}
|
||||
New-NetFirewallRule @rule | Out-Null
|
||||
Write-Host "Added the firewall rule '$name'." -ForegroundColor Green
|
||||
if ($program) {{ Write-Host " It lets only VRChat ($program) receive UDP port 9000, and only from your local network." }}
|
||||
else {{ Write-Host ' It opens only UDP port 9000, and only to your local network (VRChat.exe was not found).' }}
|
||||
Write-Host ''
|
||||
Write-Host 'Checking for rules that block VRChat...'
|
||||
$blocks = @(Get-NetFirewallRule -Direction Inbound -Action Block -Enabled True -ErrorAction SilentlyContinue |
|
||||
Where-Object {{ (($_ | Get-NetFirewallApplicationFilter).Program -like '*\VRChat.exe') }})
|
||||
if ($blocks.Count -gt 0) {{
|
||||
Write-Host 'Windows also has rules that BLOCK VRChat. A block rule wins over an allow rule:' -ForegroundColor Yellow
|
||||
$blocks | ForEach-Object {{ Write-Host (' - ' + $_.DisplayName + ' (profile: ' + $_.Profile + ')') }}
|
||||
Write-Host '(These usually come from pressing "Cancel" on the firewall question when VRChat first started.)'
|
||||
$answer = Read-Host 'Turn these block rules off? Type y and press Enter for yes'
|
||||
if ($answer -match '^[yY]') {{ $blocks | Disable-NetFirewallRule; Write-Host 'Turned them off.' -ForegroundColor Green }}
|
||||
}} else {{
|
||||
Write-Host 'None found. Good.' -ForegroundColor Green
|
||||
}}
|
||||
}} catch {{
|
||||
Write-Host "Something went wrong: $_" -ForegroundColor Red
|
||||
}}
|
||||
Write-Host ''
|
||||
Read-Host 'Done. Press Enter to close this window'
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Opens an elevated PowerShell window (Windows asks for permission first) that adds the rule.
|
||||
#[cfg(windows)]
|
||||
pub fn allow_vrchat_osc() -> Result<String, String> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::UI::Shell::ShellExecuteW;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
|
||||
|
||||
let vrchat = find_vrchat();
|
||||
let path = std::env::temp_dir().join("frameeye-helper-firewall.ps1");
|
||||
// Windows PowerShell 5.1 reads a file without a BOM as ANSI, which garbles non-ASCII paths.
|
||||
let mut bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
bytes.extend_from_slice(script(vrchat.as_deref()).as_bytes());
|
||||
std::fs::write(&path, bytes).map_err(|error| format!("Couldn't write {}: {error}", path.display()))?;
|
||||
|
||||
let wide = |text: &str| -> Vec<u16> { std::ffi::OsStr::new(text).encode_wide().chain(Some(0)).collect() };
|
||||
let parameters = format!("-NoProfile -ExecutionPolicy Bypass -File \"{}\"", path.display());
|
||||
let (verb, file, parameters) = (wide("runas"), wide("powershell.exe"), wide(¶meters));
|
||||
let result = unsafe {
|
||||
ShellExecuteW(
|
||||
std::ptr::null_mut(),
|
||||
verb.as_ptr(),
|
||||
file.as_ptr(),
|
||||
parameters.as_ptr(),
|
||||
std::ptr::null(),
|
||||
SW_SHOWNORMAL,
|
||||
)
|
||||
};
|
||||
// ShellExecute returns a value above 32 on success
|
||||
if result as usize > 32 {
|
||||
Ok(match vrchat {
|
||||
Some(exe) => format!("A PowerShell window is adding the rule for {}. Follow it there.", exe.display()),
|
||||
None => "VRChat.exe wasn't found, so the rule opens UDP 9000 to the local network only. Follow the PowerShell window.".into(),
|
||||
})
|
||||
} else {
|
||||
Err("Windows didn't run it (the permission question was probably answered with No).".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn allow_vrchat_osc() -> Result<String, String> {
|
||||
Err("The firewall fix is only needed (and only works) on Windows.".into())
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! FrameEye Helper: installs frameeyeosc (Steam Frame eye tracking → VRChat over OSC) on the headset
|
||||
//! with a few clicks, and shows whether it's working.
|
||||
|
||||
// No console window behind the app on Windows (release builds)
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
mod app;
|
||||
mod bundle;
|
||||
mod firewall;
|
||||
mod net;
|
||||
mod remote;
|
||||
#[cfg(feature = "selftest")]
|
||||
mod selftest;
|
||||
mod settings;
|
||||
mod status;
|
||||
mod worker;
|
||||
|
||||
/// The Eye icon (the same one the headset's dashboard shows), as RGBA.
|
||||
pub fn icon_rgba() -> Option<(Vec<u8>, u32, u32)> {
|
||||
let image = image::load_from_memory(include_bytes!("../assets/icon-256.png")).ok()?.into_rgba8();
|
||||
let (width, height) = image.dimensions();
|
||||
Some((image.into_raw(), width, height))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.iter().any(|arg| arg == "--version") {
|
||||
println!("FrameEye Helper {} (frameeyeosc {} inside)", env!("CARGO_PKG_VERSION"), bundle::VERSION);
|
||||
return;
|
||||
}
|
||||
#[cfg(feature = "selftest")]
|
||||
if args.get(1).map(String::as_str) == Some("--selftest") {
|
||||
std::process::exit(selftest::run(&args[2..]));
|
||||
}
|
||||
let flags = app::Flags::parse(&args);
|
||||
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
.with_title("FrameEye Helper")
|
||||
.with_app_id("frameeye-helper")
|
||||
.with_inner_size([960.0, 760.0])
|
||||
.with_min_inner_size([760.0, 560.0]);
|
||||
if let Some((rgba, width, height)) = icon_rgba() {
|
||||
viewport = viewport.with_icon(egui::IconData { rgba, width, height });
|
||||
}
|
||||
let options = |renderer| eframe::NativeOptions {
|
||||
viewport: viewport.clone(),
|
||||
renderer,
|
||||
centered: true,
|
||||
..Default::default()
|
||||
};
|
||||
let run = |renderer| {
|
||||
let flags = flags.clone();
|
||||
eframe::run_native(
|
||||
"FrameEye Helper",
|
||||
options(renderer),
|
||||
Box::new(move |cc| Ok(Box::new(app::App::new(cc, flags)))),
|
||||
)
|
||||
};
|
||||
|
||||
// wgpu (DirectX 12 / Vulkan) first, then OpenGL for PCs where that fails; "--opengl" skips wgpu.
|
||||
let first = if args.iter().any(|arg| arg == "--opengl") {
|
||||
Err(eframe::Error::AppCreation("OpenGL requested".into()))
|
||||
} else {
|
||||
run(eframe::Renderer::Wgpu)
|
||||
};
|
||||
if let Err(error) = first {
|
||||
eprintln!("Could not start with wgpu ({error}); trying OpenGL");
|
||||
if let Err(error) = run(eframe::Renderer::Glow) {
|
||||
report_fatal(&format!("FrameEye Helper couldn't open its window.\n\n{error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Last resort when no window could be opened: a message box on Windows, stderr elsewhere.
|
||||
fn report_fatal(message: &str) {
|
||||
eprintln!("{message}");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{MB_ICONERROR, MB_OK, MessageBoxW};
|
||||
let wide = |text: &str| -> Vec<u16> { std::ffi::OsStr::new(text).encode_wide().chain(Some(0)).collect() };
|
||||
let (text, title) = (wide(message), wide("FrameEye Helper"));
|
||||
unsafe { MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), MB_OK | MB_ICONERROR) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! This PC's own addresses, and the "Find my headset" scan of the local network for SSH servers.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// Every address of this PC (including loopback).
|
||||
pub fn local_ips() -> Vec<IpAddr> {
|
||||
if_addrs::get_if_addrs()
|
||||
.map(|interfaces| interfaces.into_iter().map(|interface| interface.ip()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether frameeyeosc's destination is this PC: one of its addresses (`local`, from `local_ips`), or
|
||||
/// the address the headset sees this PC's SSH connection come from.
|
||||
pub fn is_this_pc(target: &str, client_ip: Option<IpAddr>, local: &[IpAddr]) -> Option<bool> {
|
||||
let ip = target
|
||||
.parse::<SocketAddr>()
|
||||
.map(|addr| addr.ip())
|
||||
.or_else(|_| target.parse::<IpAddr>())
|
||||
.ok()?;
|
||||
let ip = match ip {
|
||||
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(IpAddr::V6(v6), IpAddr::V4),
|
||||
v4 => v4,
|
||||
};
|
||||
Some(Some(ip) == client_ip || local.contains(&ip))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ScanHit {
|
||||
pub ip: Ipv4Addr,
|
||||
/// The SSH server's greeting, e.g. "SSH-2.0-OpenSSH_9.9".
|
||||
pub banner: String,
|
||||
}
|
||||
|
||||
/// The /24 networks this PC is on (private IPv4 only, no link-local), with this PC's own address.
|
||||
fn networks() -> Vec<(Ipv4Addr, Ipv4Addr)> {
|
||||
let mut networks = Vec::new();
|
||||
for interface in if_addrs::get_if_addrs().unwrap_or_default() {
|
||||
let IpAddr::V4(ip) = interface.ip() else { continue };
|
||||
if ip.is_loopback() || ip.is_link_local() || !ip.is_private() {
|
||||
continue;
|
||||
}
|
||||
let [a, b, c, _] = ip.octets();
|
||||
let base = Ipv4Addr::new(a, b, c, 0);
|
||||
if !networks.iter().any(|(known, _)| *known == base) {
|
||||
networks.push((base, ip));
|
||||
}
|
||||
}
|
||||
networks
|
||||
}
|
||||
|
||||
/// Look for SSH servers on the local /24 networks. `progress` gets a 0..1 fraction now and then.
|
||||
pub async fn scan(port: u16, progress: impl Fn(f32) + Send + Sync + 'static) -> (Vec<ScanHit>, usize) {
|
||||
let networks = networks();
|
||||
let targets: Vec<Ipv4Addr> = networks
|
||||
.iter()
|
||||
.flat_map(|(base, own)| {
|
||||
let [a, b, c, _] = base.octets();
|
||||
(1..=254u8).map(move |d| Ipv4Addr::new(a, b, c, d)).filter(move |ip| ip != own)
|
||||
})
|
||||
.collect();
|
||||
let total = targets.len();
|
||||
let limit = Arc::new(Semaphore::new(96));
|
||||
let progress = Arc::new(progress);
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let mut tasks = Vec::with_capacity(total);
|
||||
for ip in targets {
|
||||
let limit = limit.clone();
|
||||
let progress = progress.clone();
|
||||
let done = done.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = limit.acquire().await.ok()?;
|
||||
let hit = probe(ip, port).await;
|
||||
let finished = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
if finished % 16 == 0 || finished == total {
|
||||
progress(finished as f32 / total.max(1) as f32);
|
||||
}
|
||||
hit
|
||||
}));
|
||||
}
|
||||
let mut hits = Vec::new();
|
||||
for task in tasks {
|
||||
if let Ok(Some(hit)) = task.await {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
hits.sort_by_key(|hit| (!hit.banner.contains("OpenSSH"), hit.ip));
|
||||
(hits, networks.len())
|
||||
}
|
||||
|
||||
async fn probe(ip: Ipv4Addr, port: u16) -> Option<ScanHit> {
|
||||
let addr = SocketAddr::new(IpAddr::V4(ip), port);
|
||||
let mut stream = tokio::time::timeout(Duration::from_millis(700), TcpStream::connect(addr))
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
let mut buffer = [0u8; 128];
|
||||
let read = tokio::time::timeout(Duration::from_millis(1500), stream.read(&mut buffer)).await;
|
||||
let banner = match read {
|
||||
Ok(Ok(n)) if n > 0 => String::from_utf8_lossy(&buffer[..n]).lines().next().unwrap_or("").trim().to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
// Only SSH servers (they greet first with "SSH-"); anything else on this port is not a headset.
|
||||
banner.starts_with("SSH-").then_some(ScanHit { ip, banner })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A local server that greets like `greeting` and then closes.
|
||||
fn server(greeting: &'static [u8]) -> u16 {
|
||||
use std::io::Write;
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
let _ = stream.write_all(greeting);
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_finds_ssh_servers_only() {
|
||||
let ssh = server(b"SSH-2.0-OpenSSH_9.9\r\n");
|
||||
let hit = probe(Ipv4Addr::LOCALHOST, ssh).await.expect("an SSH server is found");
|
||||
assert_eq!(hit.banner, "SSH-2.0-OpenSSH_9.9");
|
||||
let web = server(b"HTTP/1.1 400 Bad Request\r\n");
|
||||
assert!(probe(Ipv4Addr::LOCALHOST, web).await.is_none());
|
||||
// Nothing listening: refused quickly, no hit
|
||||
let closed = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
assert!(probe(Ipv4Addr::LOCALHOST, closed).await.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_targets_are_this_pc() {
|
||||
let local = local_ips();
|
||||
assert_eq!(is_this_pc("127.0.0.1:9000", None, &local), Some(true));
|
||||
assert_eq!(is_this_pc("[::ffff:127.0.0.1]:9000", None, &local), Some(true));
|
||||
assert_eq!(is_this_pc("203.0.113.9:9000", None, &local), Some(false));
|
||||
assert_eq!(is_this_pc("203.0.113.9:9000", Some("203.0.113.9".parse().unwrap()), &local), Some(true));
|
||||
assert_eq!(is_this_pc("not an address", None, &local), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Every command the helper runs on the headset, in one place so they are easy to review.
|
||||
//! They only touch the user's own home folder and user services (no sudo), and only through
|
||||
//! frameeyeosc's own install.sh.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::bundle;
|
||||
|
||||
/// Where the release is unpacked while installing (removed afterwards).
|
||||
const STAGE: &str = r#"d="$HOME/.cache/frameeye-helper""#;
|
||||
/// `systemctl --user` needs the user's runtime dir, which some SSH setups don't export.
|
||||
const RUNTIME: &str = r#"export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}""#;
|
||||
|
||||
pub fn info() -> String {
|
||||
format!(
|
||||
r#"{RUNTIME}
|
||||
b="$HOME/.local/bin"
|
||||
echo "arch=$(uname -m)"
|
||||
echo "user=$(id -un)"
|
||||
if [ -e /dev/shm/eye-server.mmap ]; then echo "eyeserver=yes"; else echo "eyeserver=no"; fi
|
||||
if [ -x "$b/frameeyeosc" ]; then echo "daemon=yes"; echo "daemon_sha=$(sha256sum "$b/frameeyeosc" 2>/dev/null | cut -d' ' -f1)"; else echo "daemon=no"; fi
|
||||
if [ -x "$b/frameeyeosc-panel" ]; then echo "panel=yes"; echo "panel_version=$("$b/frameeyeosc-panel" --version 2>/dev/null | head -n 1)"; else echo "panel=no"; fi
|
||||
echo "active=$(systemctl --user is-active frameeyeosc.service 2>/dev/null)"
|
||||
echo "enabled=$(systemctl --user is-enabled frameeyeosc.service 2>/dev/null)"
|
||||
echo "panel_active=$(systemctl --user is-active frameeyeosc-panel.service 2>/dev/null)"
|
||||
echo "steamvr=$(systemctl --user is-active steamvr.service 2>/dev/null)"
|
||||
if [ -r /etc/os-release ]; then . /etc/os-release; echo "os=${{PRETTY_NAME:-${{NAME:-}}}} ${{VERSION_ID:-}}"; fi
|
||||
echo "ssh=${{SSH_CONNECTION:-}}"
|
||||
missing=""; for tool in tar sha256sum systemctl; do command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"; done
|
||||
echo "missing=$missing"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads the release from stdin into the staging folder and prints its SHA-256.
|
||||
pub fn upload() -> String {
|
||||
format!(
|
||||
r#"set -e
|
||||
{STAGE}
|
||||
mkdir -p "$d"
|
||||
cat > "$d/package.tar.gz.part"
|
||||
mv -f "$d/package.tar.gz.part" "$d/package.tar.gz"
|
||||
sha256sum "$d/package.tar.gz" | cut -d' ' -f1
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Unpacks the uploaded release and runs its install.sh; with the panel, also starts it right away
|
||||
/// (install.sh would otherwise only start it with the next SteamVR start).
|
||||
pub fn install(with_panel: bool) -> String {
|
||||
let (args, start_panel) = if with_panel {
|
||||
("--with-panel", "systemctl --user start frameeyeosc-panel.service >/dev/null 2>&1 || true")
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
format!(
|
||||
r#"set -e
|
||||
{RUNTIME}
|
||||
{STAGE}
|
||||
rm -rf "$d/frameeyeosc"
|
||||
tar xzf "$d/package.tar.gz" -C "$d"
|
||||
cd "$d/frameeyeosc"
|
||||
./install.sh {args}
|
||||
{start_panel}
|
||||
cd "$HOME"
|
||||
rm -rf "$d"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Removes frameeyeosc and the panel with the release's own install.sh (settings stay unless `purge`).
|
||||
pub fn uninstall(purge: bool) -> String {
|
||||
let purge = if purge { " --purge" } else { "" };
|
||||
format!(
|
||||
r#"set -e
|
||||
{RUNTIME}
|
||||
{STAGE}
|
||||
rm -rf "$d/frameeyeosc"
|
||||
tar xzf "$d/package.tar.gz" -C "$d"
|
||||
cd "$d/frameeyeosc"
|
||||
./install.sh --uninstall{purge}
|
||||
cd "$HOME"
|
||||
rm -rf "$d"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Prints the service state and status.json five times a second until the channel closes. Each
|
||||
/// frame ends with a `--end--` line.
|
||||
pub fn status_stream() -> String {
|
||||
format!(
|
||||
r#"{RUNTIME}
|
||||
f="$XDG_RUNTIME_DIR/frameeyeosc/status.json"
|
||||
i=0
|
||||
while :; do
|
||||
if [ $((i % 5)) -eq 0 ]; then
|
||||
echo "active=$(systemctl --user is-active frameeyeosc.service 2>/dev/null)"
|
||||
echo "panel_active=$(systemctl --user is-active frameeyeosc-panel.service 2>/dev/null)"
|
||||
echo "steamvr=$(systemctl --user is-active steamvr.service 2>/dev/null)"
|
||||
if [ -e /dev/shm/eye-server.mmap ]; then echo "eyeserver=yes"; else echo "eyeserver=no"; fi
|
||||
fi
|
||||
echo "now=$(date +%s.%N)"
|
||||
if [ -r "$f" ]; then printf 'status='; tr -d '\n' < "$f"; echo; else echo "status="; fi
|
||||
echo "--end--"
|
||||
i=$((i + 1))
|
||||
sleep 0.2
|
||||
done
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
pub fn logs() -> String {
|
||||
format!(
|
||||
r#"{RUNTIME}
|
||||
echo "== frameeyeosc (newest last) =="
|
||||
journalctl --user -u frameeyeosc.service -n 60 --no-pager -o short 2>&1 || true
|
||||
echo
|
||||
echo "== frameeyeosc-panel =="
|
||||
journalctl --user -u frameeyeosc-panel.service -n 25 --no-pager -o short 2>&1 || true
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
pub fn restart_service() -> String {
|
||||
format!(
|
||||
r#"{RUNTIME}
|
||||
systemctl --user enable frameeyeosc.service 2>&1
|
||||
systemctl --user restart frameeyeosc.service 2>&1
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Starts the panel service if needed, then asks the running panel to open itself on the dashboard
|
||||
/// (a second launch signals the resident one and exits at once).
|
||||
pub fn show_panel() -> String {
|
||||
format!(
|
||||
r#"{RUNTIME}
|
||||
systemctl --user start frameeyeosc-panel.service 2>&1 || true
|
||||
sleep 2
|
||||
"$HOME/.local/bin/frameeyeosc-panel" 2>&1 | tail -n 1
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
pub fn read_config() -> String {
|
||||
r#"cat "${XDG_CONFIG_HOME:-$HOME/.config}/frameeyeosc/config.json" 2>/dev/null || true"#.to_string()
|
||||
}
|
||||
|
||||
/// Replaces config.json with stdin (atomically, like the panel does). frameeyeosc picks it up within 0.1 s.
|
||||
pub fn write_config() -> String {
|
||||
r#"set -e
|
||||
c="${XDG_CONFIG_HOME:-$HOME/.config}/frameeyeosc"
|
||||
mkdir -p "$c"
|
||||
cat > "$c/config.json.tmp"
|
||||
mv -f "$c/config.json.tmp" "$c/config.json"
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// What `info()` found on the headset.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct HeadsetInfo {
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub arch: String,
|
||||
pub eye_server: bool,
|
||||
pub daemon_installed: bool,
|
||||
pub daemon_sha: String,
|
||||
pub panel_installed: bool,
|
||||
/// "0.5.3", from `frameeyeosc-panel --version`.
|
||||
pub panel_version: Option<String>,
|
||||
pub service_active: String,
|
||||
pub service_enabled: String,
|
||||
pub panel_active: String,
|
||||
pub steamvr: String,
|
||||
pub os: String,
|
||||
/// This PC's address as the headset sees it (from SSH_CONNECTION).
|
||||
pub client_ip: Option<IpAddr>,
|
||||
pub missing_tools: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Installed {
|
||||
No,
|
||||
Version(String),
|
||||
/// Installed, but the version can't be told (a daemon-only install of another version).
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl HeadsetInfo {
|
||||
pub fn parse(text: &str, host: &str, user: &str) -> Self {
|
||||
let mut info = HeadsetInfo {
|
||||
host: host.to_string(),
|
||||
user: user.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once('=') else { continue };
|
||||
let value = value.trim();
|
||||
match key.trim() {
|
||||
"arch" => info.arch = value.into(),
|
||||
"eyeserver" => info.eye_server = value == "yes",
|
||||
"daemon" => info.daemon_installed = value == "yes",
|
||||
"daemon_sha" => info.daemon_sha = value.into(),
|
||||
"panel" => info.panel_installed = value == "yes",
|
||||
"panel_version" => {
|
||||
info.panel_version = value
|
||||
.split_whitespace()
|
||||
.last()
|
||||
.filter(|version| version.chars().next().is_some_and(|c| c.is_ascii_digit()))
|
||||
.map(str::to_string)
|
||||
}
|
||||
"active" => info.service_active = value.into(),
|
||||
"enabled" => info.service_enabled = value.into(),
|
||||
"panel_active" => info.panel_active = value.into(),
|
||||
"steamvr" => info.steamvr = value.into(),
|
||||
"os" => info.os = value.into(),
|
||||
"ssh" => info.client_ip = value.split_whitespace().next().and_then(|ip| ip.parse().ok()),
|
||||
"missing" => info.missing_tools = value.into(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
pub fn is_frame(&self) -> bool {
|
||||
self.arch == "aarch64"
|
||||
}
|
||||
|
||||
pub fn installed(&self) -> Installed {
|
||||
if !self.daemon_installed {
|
||||
Installed::No
|
||||
} else if let Some(version) = &self.panel_version {
|
||||
Installed::Version(version.clone())
|
||||
} else if self.daemon_sha == bundle::DAEMON_SHA256 {
|
||||
Installed::Version(bundle::VERSION.into())
|
||||
} else {
|
||||
Installed::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn info_output_is_parsed() {
|
||||
let text = "arch=aarch64\nuser=steamos\neyeserver=yes\ndaemon=yes\ndaemon_sha=abc\npanel=yes\n\
|
||||
panel_version=frameeyeosc-panel 0.5.3\nactive=active\nenabled=enabled\npanel_active=active\n\
|
||||
steamvr=active\nos=SteamOS 3.8\nssh=192.168.1.20 51234 192.168.1.42 22\nmissing=\n";
|
||||
let info = HeadsetInfo::parse(text, "192.168.1.42", "steamos");
|
||||
assert!(info.is_frame());
|
||||
assert!(info.eye_server);
|
||||
assert_eq!(info.installed(), Installed::Version("0.5.3".into()));
|
||||
assert_eq!(info.client_ip, Some("192.168.1.20".parse().unwrap()));
|
||||
assert_eq!(info.os, "SteamOS 3.8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_only_installs_are_recognised_by_hash() {
|
||||
let text = format!("arch=aarch64\ndaemon=yes\ndaemon_sha={}\npanel=no\n", bundle::DAEMON_SHA256);
|
||||
let info = HeadsetInfo::parse(&text, "h", "u");
|
||||
assert_eq!(info.installed(), Installed::Version(bundle::VERSION.into()));
|
||||
let other = HeadsetInfo::parse("daemon=yes\ndaemon_sha=ffff\npanel=no\n", "h", "u");
|
||||
assert_eq!(other.installed(), Installed::Unknown);
|
||||
assert_eq!(HeadsetInfo::parse("daemon=no\n", "h", "u").installed(), Installed::No);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_plain_posix_sh() {
|
||||
// Braces meant for the shell must survive format!().
|
||||
assert!(info().contains("${PRETTY_NAME:-${NAME:-}}"));
|
||||
assert!(install(true).contains("./install.sh --with-panel"));
|
||||
assert!(!install(false).contains("--with-panel"));
|
||||
assert!(uninstall(true).contains("--uninstall --purge"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! `--selftest HOST PORT USER PASSWORD` (only with the "selftest" feature): drives the real worker, i.e.
|
||||
//! the same SSH code the buttons use, through the whole flow against a test headset (tests/fakeframe),
|
||||
//! checking each step. Also listens on UDP 9011 to see the eye data actually arrive. (Never VRChat's
|
||||
//! 9000: a VRChat running on the test PC would get the made-up eye data.)
|
||||
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::remote::Installed;
|
||||
use crate::status::Health;
|
||||
use crate::worker::{Cmd, Event, Problem, TaskKind, Worker};
|
||||
|
||||
const TEST_PORT: u16 = 9011;
|
||||
|
||||
struct Run {
|
||||
worker: Worker,
|
||||
failures: usize,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// Wait for an event `want` accepts (logging the others), up to `seconds`.
|
||||
fn expect(&mut self, what: &str, seconds: u64, mut want: impl FnMut(&Event) -> bool) -> Option<Event> {
|
||||
let deadline = Instant::now() + Duration::from_secs(seconds);
|
||||
while Instant::now() < deadline {
|
||||
for event in self.worker.poll() {
|
||||
if want(&event) {
|
||||
println!(" PASS {what}");
|
||||
return Some(event);
|
||||
}
|
||||
match &event {
|
||||
Event::TaskLine(line) => println!(" | {line}"),
|
||||
Event::ConnectFailed { problem, detail, .. } => println!(" (connect failed: {problem:?}: {detail})"),
|
||||
Event::TaskDone { ok, message, .. } => println!(" (task done ok={ok}: {message})"),
|
||||
Event::ActionDone { label, ok, message } => println!(" ({label} ok={ok}: {message})"),
|
||||
Event::Disconnected { reason } => println!(" (disconnected: {reason})"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
println!(" FAIL {what} (nothing within {seconds} s)");
|
||||
self.failures += 1;
|
||||
None
|
||||
}
|
||||
|
||||
fn check(&mut self, what: &str, ok: bool) {
|
||||
println!(" {} {what}", if ok { "PASS" } else { "FAIL" });
|
||||
if !ok {
|
||||
self.failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> i32 {
|
||||
let [host, port, user, password] = args else {
|
||||
eprintln!("usage: FrameEyeHelper --selftest HOST PORT USER PASSWORD");
|
||||
return 2;
|
||||
};
|
||||
let port: u16 = port.parse().unwrap_or(22);
|
||||
let connect = |password: &str, known: Option<String>| Cmd::Connect {
|
||||
host: host.clone(),
|
||||
port,
|
||||
user: user.clone(),
|
||||
password: password.to_string(),
|
||||
known_fingerprint: known,
|
||||
};
|
||||
// Count OSC packets that reach this PC, on a test port instead of VRChat's 9000
|
||||
let received = Arc::new(AtomicUsize::new(0));
|
||||
match UdpSocket::bind(format!("0.0.0.0:{TEST_PORT}")) {
|
||||
Ok(socket) => {
|
||||
let received = received.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut buffer = [0u8; 1024];
|
||||
while let Ok(n) = socket.recv(&mut buffer) {
|
||||
if buffer[..n].starts_with(b"/avatar/parameters/") {
|
||||
received.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(error) => println!(" (UDP {TEST_PORT} is busy, so OSC arrival isn't checked: {error})"),
|
||||
}
|
||||
|
||||
let mut run = Run {
|
||||
worker: Worker::start(egui::Context::default()),
|
||||
failures: 0,
|
||||
};
|
||||
|
||||
println!("1. Wrong password is reported as such");
|
||||
run.worker.send(connect("not-the-password", None));
|
||||
run.expect("WrongPassword", 20, |e| matches!(e, Event::ConnectFailed { problem: Problem::WrongPassword, .. }));
|
||||
|
||||
println!("2. A changed host key is caught");
|
||||
run.worker.send(connect(password, Some("SHA256:somethingElse".into())));
|
||||
run.expect("HostKeyChanged", 20, |e| {
|
||||
matches!(e, Event::ConnectFailed { problem: Problem::HostKeyChanged, fingerprint: Some(_), .. })
|
||||
});
|
||||
|
||||
println!("3. Connect and read the headset");
|
||||
run.worker.send(connect(password, None));
|
||||
let Some(Event::Connected { info, fingerprint, .. }) = run.expect("Connected", 30, |e| matches!(e, Event::Connected { .. })) else {
|
||||
println!("cannot continue without a connection");
|
||||
return 1;
|
||||
};
|
||||
println!(" fingerprint {fingerprint}, arch {}, os {}, this PC as seen by the headset: {:?}", info.arch, info.os, info.client_ip);
|
||||
run.check("looks like a Steam Frame (aarch64)", info.is_frame());
|
||||
run.check("eye tracker present", info.eye_server);
|
||||
let fresh = info.installed() == Installed::No;
|
||||
println!(" installed before: {:?}", info.installed());
|
||||
|
||||
println!("4. Install with the panel");
|
||||
run.worker.send(Cmd::Install { with_panel: true });
|
||||
run.expect("install finished OK", 120, |e| matches!(e, Event::TaskDone { kind: TaskKind::Install, ok: true, .. }));
|
||||
if let Some(Event::Info(info)) = run.expect("fresh headset info", 30, |e| matches!(e, Event::Info(_))) {
|
||||
run.check(&format!("installed version is 0.5.3 ({:?})", info.installed()), info.installed() == Installed::Version("0.5.3".into()));
|
||||
run.check("service active", info.service_active == "active");
|
||||
}
|
||||
|
||||
println!("5. Live status");
|
||||
run.worker.send(Cmd::StartStatus);
|
||||
let mut last = None;
|
||||
run.expect("status with a running frameeyeosc and eye data", 20, |e| match e {
|
||||
Event::Status(live) if live.health() != Health::Unknown && live.health() != Health::NotRunning => {
|
||||
last = Some(live.clone());
|
||||
live.fresh_status().is_some_and(|status| status.tracking)
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if let Some(live) = &last {
|
||||
let status = live.fresh_status();
|
||||
println!(
|
||||
" health {:?}, target {:?}, rate {:?}, tracker {:?}",
|
||||
live.health(),
|
||||
status.and_then(|s| s.target.clone()),
|
||||
status.map(|s| s.rate),
|
||||
status.and_then(|s| s.tracker_rate)
|
||||
);
|
||||
}
|
||||
|
||||
println!("6. Send to this PC");
|
||||
// A real headset reaches the PC at the address it sees the SSH login come from. Rootless podman's
|
||||
// default networking gives the test container the PC's own address, so the test may name another.
|
||||
let this_pc = std::env::var("SELFTEST_PC_ADDRESS")
|
||||
.ok()
|
||||
.or_else(|| info.client_ip.map(|ip| ip.to_string()))
|
||||
.unwrap_or_else(|| "127.0.0.1".into());
|
||||
run.worker.send(Cmd::SetConfig {
|
||||
changes: vec![
|
||||
("host".into(), serde_json::Value::String(this_pc.clone())),
|
||||
("port".into(), serde_json::Value::from(TEST_PORT)),
|
||||
],
|
||||
label: "host".into(),
|
||||
});
|
||||
run.expect("config written", 20, |e| matches!(e, Event::ActionDone { ok: true, .. }));
|
||||
let expected = format!("{this_pc}:{TEST_PORT}");
|
||||
run.expect(&format!("status now sends to {expected}"), 20, |e| match e {
|
||||
Event::Status(live) => live.health() == Health::Sending && live.fresh_status().and_then(|s| s.target.as_deref()) == Some(expected.as_str()),
|
||||
_ => false,
|
||||
});
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
let packets = received.load(Ordering::Relaxed);
|
||||
println!(" OSC packets that reached this PC's UDP {TEST_PORT}: {packets}");
|
||||
run.check("eye data arrived at this PC", packets > 100);
|
||||
|
||||
println!("7. Pause and resume");
|
||||
run.worker.send(Cmd::SetConfig { changes: vec![("sending".into(), false.into())], label: "pause".into() });
|
||||
run.expect("paused", 20, |e| matches!(e, Event::Status(live) if live.health() == Health::Paused));
|
||||
run.worker.send(Cmd::SetConfig { changes: vec![("sending".into(), true.into())], label: "resume".into() });
|
||||
run.expect("sending again", 20, |e| matches!(e, Event::Status(live) if live.health() == Health::Sending));
|
||||
run.worker.send(Cmd::StopStatus);
|
||||
|
||||
println!("8. Logs and restart");
|
||||
run.worker.send(Cmd::FetchLogs);
|
||||
if let Some(Event::Logs(text)) = run.expect("logs", 20, |e| matches!(e, Event::Logs(_))) {
|
||||
run.check("log mentions sending", text.contains("Sending OSC to"));
|
||||
}
|
||||
run.worker.send(Cmd::RestartService);
|
||||
run.expect("restart OK", 30, |e| matches!(e, Event::ActionDone { ok: true, .. }));
|
||||
|
||||
println!("9. Uninstall (with --purge)");
|
||||
run.worker.send(Cmd::Uninstall { purge: true });
|
||||
run.expect("uninstall finished OK", 120, |e| matches!(e, Event::TaskDone { kind: TaskKind::Uninstall, ok: true, .. }));
|
||||
if let Some(Event::Info(info)) = run.expect("headset info after", 30, |e| matches!(e, Event::Info(_))) {
|
||||
run.check("nothing installed any more", info.installed() == Installed::No);
|
||||
}
|
||||
run.worker.send(Cmd::Disconnect);
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"{} (fresh headset: {fresh}, OSC packets received: {packets})",
|
||||
if run.failures == 0 { "ALL PASSED".to_string() } else { format!("{} FAILED", run.failures) }
|
||||
);
|
||||
i32::from(run.failures > 0)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! What the helper remembers between runs. The password is never stored.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub port: u16,
|
||||
pub with_panel: bool,
|
||||
/// "host:port" -> SHA256 fingerprint of the headset's SSH host key (trusted on first use).
|
||||
pub known_hosts: BTreeMap<String, String>,
|
||||
pub check_steamlink_osc_off: bool,
|
||||
pub check_vrchat_osc_on: bool,
|
||||
pub check_avatar: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: String::new(),
|
||||
user: "steamos".into(),
|
||||
port: 22,
|
||||
with_panel: true,
|
||||
known_hosts: BTreeMap::new(),
|
||||
check_steamlink_osc_off: false,
|
||||
check_vrchat_osc_on: false,
|
||||
check_avatar: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path() -> Option<PathBuf> {
|
||||
let dirs = directories::ProjectDirs::from("", "", "FrameEyeHelper")?;
|
||||
Some(dirs.config_dir().join("settings.json"))
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn load() -> Self {
|
||||
path()
|
||||
.and_then(|path| std::fs::read(path).ok())
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Best effort: a read-only profile just means nothing is remembered.
|
||||
pub fn save(&self) {
|
||||
let Some(path) = path() else { return };
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
if let Ok(json) = serde_json::to_vec_pretty(self) {
|
||||
let temporary = path.with_extension("json.tmp");
|
||||
if std::fs::write(&temporary, json).is_ok() {
|
||||
let _ = std::fs::rename(temporary, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn host_key_id(host: &str, port: u16) -> String {
|
||||
format!("{}:{}", host.trim(), port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! frameeyeosc's status.json (written ten times a second on the headset) and what the helper makes of it.
|
||||
//! Only the fields the helper shows are read; everything is optional so newer versions still parse.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct StatusJson {
|
||||
pub pid: u32,
|
||||
/// Unix seconds when the file was written (headset clock).
|
||||
pub time: f64,
|
||||
pub started: f64,
|
||||
pub sending: bool,
|
||||
/// "vrchat" or "etvr"
|
||||
pub output: String,
|
||||
/// "auto" or "fixed"
|
||||
pub target_mode: String,
|
||||
/// "192.168.1.20:9000" once a destination is known.
|
||||
pub target: Option<String>,
|
||||
/// OSC samples sent in the last second.
|
||||
pub rate: f32,
|
||||
/// Samples the eye tracker delivered in the last second.
|
||||
pub tracker_rate: Option<f32>,
|
||||
pub tracking: bool,
|
||||
pub raw: Option<RawValues>,
|
||||
pub sent: Option<SentValues>,
|
||||
pub config_error: Option<String>,
|
||||
pub effective: Option<Effective>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RawValues {
|
||||
pub openness: [f32; 2],
|
||||
pub gaze: [f32; 2],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SentValues {
|
||||
/// Eyelids on the VRCFT scale: 0 closed, 0.75 relaxed, 1 widened.
|
||||
pub lids_vrcft: [f32; 2],
|
||||
pub gaze: [f32; 2],
|
||||
pub gaze_left: [f32; 2],
|
||||
pub gaze_right: [f32; 2],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Effective {
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
pub prefix: String,
|
||||
pub independent_eyes: bool,
|
||||
}
|
||||
|
||||
/// One reading of the headset's state, as the status stream reports it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LiveStatus {
|
||||
/// `systemctl --user is-active` of frameeyeosc: "active", "inactive", "failed", "activating", ...
|
||||
pub service: String,
|
||||
pub panel_service: String,
|
||||
pub steamvr: String,
|
||||
pub eye_server: bool,
|
||||
/// The headset's clock when this was read, to judge how fresh status.json is.
|
||||
pub headset_now: f64,
|
||||
pub status: Option<StatusJson>,
|
||||
}
|
||||
|
||||
/// What the "Check it works" page shows as the headline.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Health {
|
||||
/// Nothing heard from the headset yet.
|
||||
Unknown,
|
||||
/// The service isn't running (or its status file is stale).
|
||||
NotRunning,
|
||||
/// Running, but eye tracking isn't delivering (headset not worn, SteamVR not running).
|
||||
NoEyeData,
|
||||
/// Tracking, but auto mode hasn't found the Steam Link PC yet.
|
||||
NoTarget,
|
||||
/// Sending was switched off in the panel.
|
||||
Paused,
|
||||
/// Sending eye data.
|
||||
Sending,
|
||||
}
|
||||
|
||||
impl LiveStatus {
|
||||
/// The status file counts as current if it was written in the last 3 seconds by a running service.
|
||||
pub fn fresh_status(&self) -> Option<&StatusJson> {
|
||||
let status = self.status.as_ref()?;
|
||||
let age = self.headset_now - status.time;
|
||||
(self.service == "active" && (-2.0..3.0).contains(&age)).then_some(status)
|
||||
}
|
||||
|
||||
pub fn health(&self) -> Health {
|
||||
if self.service.is_empty() {
|
||||
return Health::Unknown;
|
||||
}
|
||||
let Some(status) = self.fresh_status() else {
|
||||
return Health::NotRunning;
|
||||
};
|
||||
if !status.sending {
|
||||
Health::Paused
|
||||
} else if !status.tracking {
|
||||
Health::NoEyeData
|
||||
} else if status.target.is_none() {
|
||||
Health::NoTarget
|
||||
} else {
|
||||
Health::Sending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse one frame of the status stream: `key=value` lines, the status JSON after `status=`.
|
||||
pub fn parse_frame(lines: &[String], previous: &LiveStatus) -> LiveStatus {
|
||||
let mut live = previous.clone();
|
||||
for line in lines {
|
||||
let Some((key, value)) = line.split_once('=') else { continue };
|
||||
match key {
|
||||
"active" => live.service = value.trim().to_string(),
|
||||
"panel_active" => live.panel_service = value.trim().to_string(),
|
||||
"steamvr" => live.steamvr = value.trim().to_string(),
|
||||
"eyeserver" => live.eye_server = value.trim() == "yes",
|
||||
"now" => live.headset_now = value.trim().parse().unwrap_or(live.headset_now),
|
||||
"status" => {
|
||||
live.status = if value.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
// A half-read file is simply skipped; the next frame comes 0.2 s later.
|
||||
serde_json::from_str(value).ok().or(live.status)
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
live
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"{"version":1,"pid":4242,"time":1000.5,"started":900.0,"sending":true,"output":"vrchat","target_mode":"auto","target":"192.168.1.20:9000","rate":90.0,"tracker_rate":120.0,"tracking":true,"raw":{"openness":[0.8,0.79],"openness_scaled":[0.8,0.8],"gaze":[0.1,-0.05],"gaze_left":[0.1,-0.05],"gaze_right":[0.1,-0.05]},"sent":{"lids":[0.75,0.75],"lids_vrcft":[0.75,0.74],"gaze":[0.09,-0.04],"gaze_left":[0.09,-0.04],"gaze_right":[0.09,-0.04]},"calibration":{"enabled":true,"relaxed":[0.8,0.8],"scales":[1.0,1.0],"fitted":[false,false],"learning":false},"config_path":"/home/steamos/.config/frameeyeosc/config.json","calibration_path":null,"config_error":null,"locked":[],"effective":{"sending":true,"output":"vrchat","host":"auto","port":null,"prefix":"/FT","independent_eyes":false},"gaze_capture":null}"#;
|
||||
|
||||
fn frame(status: &str) -> Vec<String> {
|
||||
vec![
|
||||
"active=active".into(),
|
||||
"panel_active=inactive".into(),
|
||||
"eyeserver=yes".into(),
|
||||
"now=1001.0".into(),
|
||||
format!("status={status}"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_sender_is_healthy() {
|
||||
let live = parse_frame(&frame(SAMPLE), &LiveStatus::default());
|
||||
assert_eq!(live.health(), Health::Sending);
|
||||
let status = live.fresh_status().unwrap();
|
||||
assert_eq!(status.target.as_deref(), Some("192.168.1.20:9000"));
|
||||
assert_eq!(status.sent.as_ref().unwrap().lids_vrcft, [0.75, 0.74]);
|
||||
assert_eq!(status.effective.as_ref().unwrap().prefix, "/FT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_status_file_means_not_running() {
|
||||
let mut lines = frame(SAMPLE);
|
||||
lines[3] = "now=1010.0".into();
|
||||
assert_eq!(parse_frame(&lines, &LiveStatus::default()).health(), Health::NotRunning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_follow_the_flags() {
|
||||
let paused = SAMPLE.replace(r#""sending":true,"output""#, r#""sending":false,"output""#);
|
||||
assert_eq!(parse_frame(&frame(&paused), &LiveStatus::default()).health(), Health::Paused);
|
||||
let untracked = SAMPLE.replace(r#""tracking":true"#, r#""tracking":false"#);
|
||||
assert_eq!(parse_frame(&frame(&untracked), &LiveStatus::default()).health(), Health::NoEyeData);
|
||||
let no_target = SAMPLE.replace(r#""target":"192.168.1.20:9000""#, r#""target":null"#);
|
||||
assert_eq!(parse_frame(&frame(&no_target), &LiveStatus::default()).health(), Health::NoTarget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_line_keeps_the_last_good_status() {
|
||||
let good = parse_frame(&frame(SAMPLE), &LiveStatus::default());
|
||||
let broken = parse_frame(&frame("{\"pid\":"), &good);
|
||||
assert_eq!(broken.status.unwrap().pid, 4242);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
//! A background thread that talks to the headset over SSH (or pretends to, in demo mode), so the
|
||||
//! window never freezes. The UI sends `Cmd`s and gets `Event`s back.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
use russh::client::{self, Handle, KeyboardInteractiveAuthResponse};
|
||||
use russh::keys::{HashAlg, PublicKeyOrCertificate};
|
||||
use russh::{ChannelMsg, Disconnect, MethodKind};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc as tmpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::bundle;
|
||||
use crate::net::{self, ScanHit};
|
||||
use crate::remote::{self, HeadsetInfo};
|
||||
use crate::status::{self, LiveStatus, StatusJson};
|
||||
|
||||
pub enum Cmd {
|
||||
Connect { host: String, port: u16, user: String, password: String, known_fingerprint: Option<String> },
|
||||
ConnectDemo,
|
||||
Disconnect,
|
||||
RefreshInfo,
|
||||
Install { with_panel: bool },
|
||||
Uninstall { purge: bool },
|
||||
StartStatus,
|
||||
StopStatus,
|
||||
FetchLogs,
|
||||
RestartService,
|
||||
ShowPanel,
|
||||
/// Change keys in frameeyeosc's config.json (kept: every other key).
|
||||
SetConfig { changes: Vec<(String, serde_json::Value)>, label: String },
|
||||
Scan { port: u16 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Problem {
|
||||
BadAddress,
|
||||
Unreachable,
|
||||
Refused,
|
||||
Timeout,
|
||||
Handshake,
|
||||
HostKeyChanged,
|
||||
WrongPassword,
|
||||
NoPasswordLogin,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TaskKind {
|
||||
Install,
|
||||
Uninstall,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum TaskStep {
|
||||
Uploading,
|
||||
Verifying,
|
||||
Running,
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
Connected { info: HeadsetInfo, fingerprint: String, demo: bool },
|
||||
ConnectFailed { problem: Problem, detail: String, fingerprint: Option<String> },
|
||||
Info(HeadsetInfo),
|
||||
TaskStep(TaskStep),
|
||||
TaskLine(String),
|
||||
UploadProgress(f32),
|
||||
TaskDone { kind: TaskKind, ok: bool, message: String },
|
||||
Status(LiveStatus),
|
||||
StatusProblem(String),
|
||||
Logs(String),
|
||||
ActionDone { label: String, ok: bool, message: String },
|
||||
ScanProgress(f32),
|
||||
ScanDone { hits: Vec<ScanHit>, networks: usize },
|
||||
Disconnected { reason: String },
|
||||
}
|
||||
|
||||
pub struct Worker {
|
||||
commands: tmpsc::UnboundedSender<Cmd>,
|
||||
events: mpsc::Receiver<Event>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub fn start(ctx: egui::Context) -> Self {
|
||||
let (commands, command_rx) = tmpsc::unbounded_channel();
|
||||
let (event_tx, events) = mpsc::channel();
|
||||
let out = Emitter { tx: event_tx, ctx };
|
||||
std::thread::Builder::new()
|
||||
.name("headset".into())
|
||||
.spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("could not start the async runtime");
|
||||
runtime.block_on(run(command_rx, out));
|
||||
})
|
||||
.expect("could not start the worker thread");
|
||||
Self { commands, events }
|
||||
}
|
||||
|
||||
pub fn send(&self, cmd: Cmd) {
|
||||
let _ = self.commands.send(cmd);
|
||||
}
|
||||
|
||||
pub fn poll(&self) -> Vec<Event> {
|
||||
self.events.try_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Emitter {
|
||||
tx: mpsc::Sender<Event>,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
impl Emitter {
|
||||
fn emit(&self, event: Event) {
|
||||
let _ = self.tx.send(event);
|
||||
self.ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// SSH
|
||||
|
||||
/// Remembers the host key it was shown, and accepts it if it is the one trusted before (or if
|
||||
/// there is none yet: trust on first use, like `ssh -o StrictHostKeyChecking=accept-new`).
|
||||
struct Client {
|
||||
expected: Option<String>,
|
||||
seen: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl client::Handler for Client {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
|
||||
let fingerprint = match key {
|
||||
PublicKeyOrCertificate::PublicKey { key, .. } => key.fingerprint(HashAlg::Sha256).to_string(),
|
||||
PublicKeyOrCertificate::Certificate(certificate) => {
|
||||
certificate.public_key().fingerprint(HashAlg::Sha256).to_string()
|
||||
}
|
||||
};
|
||||
let accepted = self.expected.as_ref().is_none_or(|expected| *expected == fingerprint);
|
||||
*self.seen.lock().unwrap() = Some(fingerprint);
|
||||
Ok(accepted)
|
||||
}
|
||||
}
|
||||
|
||||
struct Conn {
|
||||
handle: Arc<Handle<Client>>,
|
||||
host: String,
|
||||
user: String,
|
||||
}
|
||||
|
||||
struct Output {
|
||||
stdout: String,
|
||||
code: Option<u32>,
|
||||
}
|
||||
|
||||
/// Run one command. `stdin` is sent first (with `on_upload` progress); each stdout line goes to
|
||||
/// `on_line` as it arrives.
|
||||
async fn exec(
|
||||
handle: &Handle<Client>,
|
||||
command: &str,
|
||||
stdin: Option<&[u8]>,
|
||||
mut on_upload: impl FnMut(f32),
|
||||
mut on_line: impl FnMut(&str),
|
||||
) -> Result<Output, russh::Error> {
|
||||
let mut channel = handle.channel_open_session().await?;
|
||||
channel.exec(true, command).await?;
|
||||
if let Some(bytes) = stdin {
|
||||
let mut sent = 0usize;
|
||||
for chunk in bytes.chunks(64 * 1024) {
|
||||
channel.data(chunk).await?;
|
||||
sent += chunk.len();
|
||||
on_upload(sent as f32 / bytes.len().max(1) as f32);
|
||||
}
|
||||
}
|
||||
channel.eof().await?;
|
||||
let mut stdout = Vec::new();
|
||||
let mut line_start = 0usize;
|
||||
let mut code = None;
|
||||
while let Some(message) = channel.wait().await {
|
||||
match message {
|
||||
ChannelMsg::Data { data } => {
|
||||
stdout.extend_from_slice(&data);
|
||||
while let Some(end) = stdout[line_start..].iter().position(|byte| *byte == b'\n') {
|
||||
on_line(String::from_utf8_lossy(&stdout[line_start..line_start + end]).trim_end());
|
||||
line_start += end + 1;
|
||||
}
|
||||
}
|
||||
// stderr: the scripts send everything that matters to stdout
|
||||
ChannelMsg::ExtendedData { .. } => {}
|
||||
ChannelMsg::ExitStatus { exit_status } => code = Some(exit_status),
|
||||
ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if line_start < stdout.len() {
|
||||
on_line(String::from_utf8_lossy(&stdout[line_start..]).trim_end());
|
||||
}
|
||||
Ok(Output {
|
||||
stdout: String::from_utf8_lossy(&stdout).into_owned(),
|
||||
code,
|
||||
})
|
||||
}
|
||||
|
||||
async fn exec_simple(handle: &Handle<Client>, command: &str, limit: Duration) -> Result<Output, String> {
|
||||
match timeout(limit, exec(handle, command, None, |_| {}, |_| {})).await {
|
||||
Ok(Ok(output)) => Ok(output),
|
||||
Ok(Err(error)) => Err(error.to_string()),
|
||||
Err(_) => Err(format!("the headset didn't answer within {} seconds", limit.as_secs())),
|
||||
}
|
||||
}
|
||||
|
||||
fn io_problem(error: &std::io::Error, addr: SocketAddr) -> (Problem, String) {
|
||||
use std::io::ErrorKind::*;
|
||||
match error.kind() {
|
||||
ConnectionRefused => (Problem::Refused, format!("{addr} refused the connection ({error}).")),
|
||||
TimedOut => (Problem::Timeout, format!("No answer from {addr} ({error}).")),
|
||||
HostUnreachable | NetworkUnreachable | AddrNotAvailable => {
|
||||
(Problem::Unreachable, format!("{addr} can't be reached ({error})."))
|
||||
}
|
||||
_ => (Problem::Other, format!("Connecting to {addr} failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectError = (Problem, String, Option<String>);
|
||||
|
||||
async fn connect(
|
||||
host: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
password: &str,
|
||||
known: Option<String>,
|
||||
) -> Result<(Conn, HeadsetInfo, String), ConnectError> {
|
||||
let host = host.trim();
|
||||
if host.is_empty() {
|
||||
return Err((Problem::BadAddress, "Type the headset's IP address first.".into(), None));
|
||||
}
|
||||
let addrs: Vec<SocketAddr> = match timeout(Duration::from_secs(6), tokio::net::lookup_host((host, port))).await {
|
||||
Ok(Ok(addrs)) => addrs.collect(),
|
||||
Ok(Err(error)) => return Err((Problem::BadAddress, format!("\"{host}\" isn't an address this PC can find ({error})."), None)),
|
||||
Err(_) => return Err((Problem::BadAddress, format!("Looking up \"{host}\" took too long."), None)),
|
||||
};
|
||||
let mut stream = None;
|
||||
let mut last = (Problem::BadAddress, format!("\"{host}\" didn't give any address."));
|
||||
for addr in addrs {
|
||||
match timeout(Duration::from_secs(6), TcpStream::connect(addr)).await {
|
||||
Ok(Ok(connected)) => {
|
||||
stream = Some(connected);
|
||||
break;
|
||||
}
|
||||
Ok(Err(error)) => last = io_problem(&error, addr),
|
||||
Err(_) => last = (Problem::Timeout, format!("No answer from {addr} within 6 seconds.")),
|
||||
}
|
||||
}
|
||||
let Some(stream) = stream else {
|
||||
return Err((last.0, last.1, None));
|
||||
};
|
||||
let _ = stream.set_nodelay(true);
|
||||
|
||||
let seen = Arc::new(Mutex::new(None));
|
||||
let handler = Client {
|
||||
expected: known.clone(),
|
||||
seen: seen.clone(),
|
||||
};
|
||||
let config = Arc::new(client::Config {
|
||||
keepalive_interval: Some(Duration::from_secs(10)),
|
||||
keepalive_max: 3,
|
||||
nodelay: true,
|
||||
..Default::default()
|
||||
});
|
||||
let seen_key = || seen.lock().unwrap().clone();
|
||||
let mut handle = match timeout(Duration::from_secs(15), client::connect_stream(config, stream, handler)).await {
|
||||
Ok(Ok(handle)) => handle,
|
||||
Ok(Err(error)) => {
|
||||
let fingerprint = seen_key();
|
||||
if let (Some(expected), Some(shown)) = (&known, &fingerprint) {
|
||||
if expected != shown {
|
||||
return Err((
|
||||
Problem::HostKeyChanged,
|
||||
format!("Expected {expected}, but the headset showed {shown}."),
|
||||
fingerprint,
|
||||
));
|
||||
}
|
||||
}
|
||||
return Err((Problem::Handshake, format!("The secure connection couldn't be set up: {error}"), fingerprint));
|
||||
}
|
||||
Err(_) => return Err((Problem::Timeout, "The headset didn't finish setting up the secure connection.".into(), seen_key())),
|
||||
};
|
||||
let fingerprint = seen_key().unwrap_or_default();
|
||||
|
||||
authenticate(&mut handle, user, password)
|
||||
.await
|
||||
.map_err(|(problem, detail)| (problem, detail, Some(fingerprint.clone())))?;
|
||||
|
||||
let handle = Arc::new(handle);
|
||||
let info = fetch_info(&handle, host, user)
|
||||
.await
|
||||
.map_err(|detail| (Problem::Other, format!("Logged in, but reading the headset's state failed: {detail}"), Some(fingerprint.clone())))?;
|
||||
Ok((
|
||||
Conn {
|
||||
handle,
|
||||
host: host.to_string(),
|
||||
user: user.to_string(),
|
||||
},
|
||||
info,
|
||||
fingerprint,
|
||||
))
|
||||
}
|
||||
|
||||
/// Password login, falling back to keyboard-interactive (the same password) where the server
|
||||
/// only offers that.
|
||||
async fn authenticate(handle: &mut Handle<Client>, user: &str, password: &str) -> Result<(), (Problem, String)> {
|
||||
let other = |error: russh::Error| (Problem::Other, format!("Logging in failed: {error}"));
|
||||
let remaining = match handle.authenticate_password(user, password).await.map_err(other)? {
|
||||
client::AuthResult::Success => return Ok(()),
|
||||
client::AuthResult::Failure { remaining_methods, .. } => remaining_methods,
|
||||
};
|
||||
if remaining.contains(&MethodKind::KeyboardInteractive) {
|
||||
let mut response = handle
|
||||
.authenticate_keyboard_interactive_start(user, None)
|
||||
.await
|
||||
.map_err(other)?;
|
||||
for _ in 0..6 {
|
||||
match response {
|
||||
KeyboardInteractiveAuthResponse::Success => return Ok(()),
|
||||
KeyboardInteractiveAuthResponse::Failure { .. } => break,
|
||||
KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => {
|
||||
let answers = prompts
|
||||
.iter()
|
||||
.map(|prompt| if prompt.echo { String::new() } else { password.to_string() })
|
||||
.collect();
|
||||
response = handle
|
||||
.authenticate_keyboard_interactive_respond(answers)
|
||||
.await
|
||||
.map_err(other)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if remaining.contains(&MethodKind::Password) || remaining.contains(&MethodKind::KeyboardInteractive) {
|
||||
Err((Problem::WrongPassword, format!("The headset didn't accept the password for user \"{user}\".")))
|
||||
} else {
|
||||
let methods: Vec<&str> = remaining.iter().map(<&str>::from).collect();
|
||||
Err((
|
||||
Problem::NoPasswordLogin,
|
||||
format!("The headset doesn't allow password login (it offers: {}).", methods.join(", ")),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_info(handle: &Handle<Client>, host: &str, user: &str) -> Result<HeadsetInfo, String> {
|
||||
let output = exec_simple(handle, &remote::info(), Duration::from_secs(20)).await?;
|
||||
Ok(HeadsetInfo::parse(&output.stdout, host, user))
|
||||
}
|
||||
|
||||
/// Upload the bundled release, check it arrived intact, then run install.sh (or its uninstall).
|
||||
async fn run_task(conn: &Conn, kind: TaskKind, with_panel: bool, purge: bool, out: &Emitter) -> Result<String, String> {
|
||||
if !bundle::verify() {
|
||||
return Err("The copy of frameeyeosc inside this program is damaged. Download the helper again.".into());
|
||||
}
|
||||
out.emit(Event::TaskStep(TaskStep::Uploading));
|
||||
let upload = timeout(
|
||||
Duration::from_secs(120),
|
||||
exec(&conn.handle, &remote::upload(), Some(bundle::TARBALL), |fraction| out.emit(Event::UploadProgress(fraction)), |_| {}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Sending the files to the headset took too long.".to_string())?
|
||||
.map_err(|error| format!("Sending the files to the headset failed: {error}"))?;
|
||||
out.emit(Event::TaskStep(TaskStep::Verifying));
|
||||
let arrived = upload.stdout.trim();
|
||||
if upload.code != Some(0) || arrived != bundle::TARBALL_SHA256 {
|
||||
return Err(format!(
|
||||
"The files didn't arrive intact (checksum {}). Try again.",
|
||||
if arrived.is_empty() { "missing" } else { arrived }
|
||||
));
|
||||
}
|
||||
out.emit(Event::TaskLine(format!("Upload verified (SHA-256 {}…)", &arrived[..16])));
|
||||
out.emit(Event::TaskStep(TaskStep::Running));
|
||||
let script = match kind {
|
||||
TaskKind::Install => remote::install(with_panel),
|
||||
TaskKind::Uninstall => remote::uninstall(purge),
|
||||
};
|
||||
let script = format!("exec 2>&1\n{script}");
|
||||
let result = timeout(
|
||||
Duration::from_secs(180),
|
||||
exec(&conn.handle, &script, None, |_| {}, |line| out.emit(Event::TaskLine(line.to_string()))),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "install.sh didn't finish within 3 minutes.".to_string())?
|
||||
.map_err(|error| format!("Running install.sh failed: {error}"))?;
|
||||
match result.code {
|
||||
Some(0) => Ok(match kind {
|
||||
TaskKind::Install => "Installed.".into(),
|
||||
TaskKind::Uninstall => "Removed.".into(),
|
||||
}),
|
||||
Some(code) => Err(format!("install.sh stopped with exit code {code}. The details above say why.")),
|
||||
None => Err("install.sh ended without an exit code (the connection may have dropped).".into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_config(conn: &Conn, changes: &[(String, serde_json::Value)]) -> Result<(), String> {
|
||||
let current = exec_simple(&conn.handle, &remote::read_config(), Duration::from_secs(15)).await?;
|
||||
let text = current.stdout.trim();
|
||||
let mut config: serde_json::Value = if text.is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::from_str(text).map_err(|_| {
|
||||
"The headset's settings file is damaged. Open the Eye panel in the headset and use \"Reset all\" on the Basic tab."
|
||||
.to_string()
|
||||
})?
|
||||
};
|
||||
let Some(object) = config.as_object_mut() else {
|
||||
return Err("The headset's settings file isn't a JSON object.".into());
|
||||
};
|
||||
for (key, value) in changes {
|
||||
object.insert(key.clone(), value.clone());
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(&config).map_err(|error| error.to_string())?;
|
||||
let written = timeout(
|
||||
Duration::from_secs(15),
|
||||
exec(&conn.handle, &remote::write_config(), Some(&json), |_| {}, |_| {}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Writing the settings took too long.".to_string())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
if written.code == Some(0) { Ok(()) } else { Err("Writing the settings on the headset failed.".into()) }
|
||||
}
|
||||
|
||||
/// Stream status.json until the connection goes away; restarts the stream if only it fails.
|
||||
async fn stream_status(handle: Arc<Handle<Client>>, out: Emitter) {
|
||||
let mut last = LiveStatus::default();
|
||||
loop {
|
||||
let result: Result<(), russh::Error> = async {
|
||||
let mut channel = handle.channel_open_session().await?;
|
||||
channel.exec(true, remote::status_stream()).await?;
|
||||
channel.eof().await?;
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut frame: Vec<String> = Vec::new();
|
||||
while let Some(message) = channel.wait().await {
|
||||
let ChannelMsg::Data { data } = message else { continue };
|
||||
buffer.extend_from_slice(&data);
|
||||
while let Some(end) = buffer.iter().position(|byte| *byte == b'\n') {
|
||||
let line: Vec<u8> = buffer.drain(..=end).collect();
|
||||
let line = String::from_utf8_lossy(&line).trim_end().to_string();
|
||||
if line == "--end--" {
|
||||
last = status::parse_frame(&frame, &last);
|
||||
frame.clear();
|
||||
out.emit(Event::Status(last.clone()));
|
||||
} else {
|
||||
frame.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if handle.is_closed() {
|
||||
out.emit(Event::Disconnected {
|
||||
reason: "The connection to the headset was lost (was it turned off or did it leave the network?).".into(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Err(error) = result {
|
||||
out.emit(Event::StatusProblem(error.to_string()));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Demo mode: a pretend headset, so the whole flow can be tried (and shown) without a Steam Frame.
|
||||
|
||||
struct Demo {
|
||||
installed: bool,
|
||||
with_panel: bool,
|
||||
shared: Arc<Mutex<DemoState>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DemoState {
|
||||
sending: bool,
|
||||
host: String,
|
||||
}
|
||||
|
||||
const DEMO_PC: &str = "192.168.1.20";
|
||||
|
||||
fn demo_info(demo: &Demo) -> HeadsetInfo {
|
||||
HeadsetInfo {
|
||||
host: "192.168.1.42".into(),
|
||||
user: "steamos".into(),
|
||||
arch: "aarch64".into(),
|
||||
eye_server: true,
|
||||
daemon_installed: demo.installed,
|
||||
daemon_sha: if demo.installed { bundle::DAEMON_SHA256.into() } else { String::new() },
|
||||
panel_installed: demo.installed && demo.with_panel,
|
||||
panel_version: (demo.installed && demo.with_panel).then(|| bundle::VERSION.to_string()),
|
||||
service_active: if demo.installed { "active" } else { "inactive" }.into(),
|
||||
service_enabled: if demo.installed { "enabled" } else { "not-found" }.into(),
|
||||
panel_active: if demo.installed && demo.with_panel { "active" } else { "inactive" }.into(),
|
||||
steamvr: "active".into(),
|
||||
os: "SteamOS (demo)".into(),
|
||||
client_ip: DEMO_PC.parse().ok(),
|
||||
missing_tools: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Eyes that look around, blink every few seconds and now and then look far to one side.
|
||||
fn demo_status(t: f64, state: &DemoState) -> LiveStatus {
|
||||
let fixation = (t / 1.4).floor();
|
||||
let hash = |n: f64| ((n * 12.9898).sin() * 43758.5453).fract().abs() as f32;
|
||||
let settle = (((t / 1.4).fract() as f32) * 8.0).min(1.0);
|
||||
let target = |n: f64| [hash(n) * 1.1 - 0.55, hash(n + 7.0) * 0.7 - 0.35];
|
||||
let (from, to) = (target(fixation - 1.0), target(fixation));
|
||||
let gaze = [from[0] + (to[0] - from[0]) * settle, from[1] + (to[1] - from[1]) * settle];
|
||||
let blink_phase = (t % 4.3) as f32;
|
||||
let lid = if blink_phase < 0.12 { 0.0 } else if blink_phase < 0.25 { (blink_phase - 0.12) / 0.13 * 0.75 } else { 0.75 };
|
||||
let wink = (t % 17.0) > 15.0 && (t % 17.0) < 15.5;
|
||||
let lids = [lid, if wink { 0.0 } else { lid }];
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0.0, |elapsed| elapsed.as_secs_f64());
|
||||
let target = if state.host == "auto" { format!("{DEMO_PC}:9000") } else { format!("{}:9000", state.host) };
|
||||
LiveStatus {
|
||||
service: "active".into(),
|
||||
panel_service: "active".into(),
|
||||
steamvr: "active".into(),
|
||||
eye_server: true,
|
||||
headset_now: now,
|
||||
status: Some(StatusJson {
|
||||
pid: 4242,
|
||||
time: now,
|
||||
started: now - 600.0,
|
||||
sending: state.sending,
|
||||
output: "vrchat".into(),
|
||||
target_mode: if state.host == "auto" { "auto" } else { "fixed" }.into(),
|
||||
target: Some(target),
|
||||
rate: if state.sending { 118.0 } else { 0.0 },
|
||||
tracker_rate: Some(118.0 + (t * 3.0).sin() as f32 * 2.0),
|
||||
tracking: true,
|
||||
raw: Some(status::RawValues {
|
||||
openness: [0.3 + lids[0] * 0.66, 0.3 + lids[1] * 0.66],
|
||||
gaze: [gaze[0] + 0.02, gaze[1] - 0.01],
|
||||
}),
|
||||
sent: Some(status::SentValues {
|
||||
lids_vrcft: lids,
|
||||
gaze,
|
||||
gaze_left: gaze,
|
||||
gaze_right: gaze,
|
||||
}),
|
||||
config_error: None,
|
||||
effective: Some(status::Effective {
|
||||
host: state.host.clone(),
|
||||
port: None,
|
||||
prefix: "/FT".into(),
|
||||
independent_eyes: false,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const DEMO_INSTALL_OUTPUT: &[&str] = &[
|
||||
"Created symlink '/home/steamos/.config/systemd/user/steamvr.service.wants/frameeyeosc.service' → '/home/steamos/.config/systemd/user/frameeyeosc.service'.",
|
||||
"Created symlink '/home/steamos/.config/systemd/user/steamvr.service.wants/frameeyeosc-panel.service' → '/home/steamos/.config/systemd/user/frameeyeosc-panel.service'.",
|
||||
"● frameeyeosc.service - Send Steam Frame eye tracking to VRChat over OSC",
|
||||
" Loaded: loaded (/home/steamos/.config/systemd/user/frameeyeosc.service; enabled; preset: enabled)",
|
||||
" Active: active (running)",
|
||||
"",
|
||||
"frameeyeosc is installed and starts together with SteamVR.",
|
||||
" Settings: /home/steamos/.config/frameeyeosc/config.json",
|
||||
" Logs: journalctl --user -u frameeyeosc -f",
|
||||
"",
|
||||
"The panel starts with SteamVR from the next SteamVR start (\"Eye\" on the dashboard).",
|
||||
"",
|
||||
"On your PC, turn off Steam Link's own OSC output (SteamVR settings > Steam Link > OSC),",
|
||||
"otherwise it drives the avatar's eyes too with unsmoothed data.",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
enum Link {
|
||||
None,
|
||||
Ssh(Conn),
|
||||
Demo(Demo),
|
||||
}
|
||||
|
||||
async fn run(mut commands: tmpsc::UnboundedReceiver<Cmd>, out: Emitter) {
|
||||
let mut link = Link::None;
|
||||
let mut status_task: Option<JoinHandle<()>> = None;
|
||||
let stop_status = |task: &mut Option<JoinHandle<()>>| {
|
||||
if let Some(task) = task.take() {
|
||||
task.abort();
|
||||
}
|
||||
};
|
||||
while let Some(cmd) = commands.recv().await {
|
||||
match cmd {
|
||||
Cmd::Scan { port } => {
|
||||
let out = out.clone();
|
||||
tokio::spawn(async move {
|
||||
let progress = out.clone();
|
||||
let (hits, networks) = net::scan(port, move |fraction| progress.emit(Event::ScanProgress(fraction))).await;
|
||||
out.emit(Event::ScanDone { hits, networks });
|
||||
});
|
||||
}
|
||||
Cmd::Connect { host, port, user, password, known_fingerprint } => {
|
||||
stop_status(&mut status_task);
|
||||
if let Link::Ssh(conn) = std::mem::replace(&mut link, Link::None) {
|
||||
let _ = conn.handle.disconnect(Disconnect::ByApplication, "", "en").await;
|
||||
}
|
||||
match connect(&host, port, &user, &password, known_fingerprint).await {
|
||||
Ok((conn, info, fingerprint)) => {
|
||||
link = Link::Ssh(conn);
|
||||
out.emit(Event::Connected { info, fingerprint, demo: false });
|
||||
}
|
||||
Err((problem, detail, fingerprint)) => out.emit(Event::ConnectFailed { problem, detail, fingerprint }),
|
||||
}
|
||||
}
|
||||
Cmd::ConnectDemo => {
|
||||
stop_status(&mut status_task);
|
||||
tokio::time::sleep(Duration::from_millis(700)).await;
|
||||
let demo = Demo {
|
||||
installed: false,
|
||||
with_panel: true,
|
||||
shared: Arc::new(Mutex::new(DemoState { sending: true, host: "auto".into() })),
|
||||
};
|
||||
out.emit(Event::Connected {
|
||||
info: demo_info(&demo),
|
||||
fingerprint: "SHA256:demo".into(),
|
||||
demo: true,
|
||||
});
|
||||
link = Link::Demo(demo);
|
||||
}
|
||||
Cmd::Disconnect => {
|
||||
stop_status(&mut status_task);
|
||||
if let Link::Ssh(conn) = std::mem::replace(&mut link, Link::None) {
|
||||
let _ = conn.handle.disconnect(Disconnect::ByApplication, "", "en").await;
|
||||
}
|
||||
}
|
||||
Cmd::RefreshInfo => match &link {
|
||||
Link::Ssh(conn) => match fetch_info(&conn.handle, &conn.host, &conn.user).await {
|
||||
Ok(info) => out.emit(Event::Info(info)),
|
||||
Err(detail) => lost_or(&conn.handle, &out, detail),
|
||||
},
|
||||
Link::Demo(demo) => out.emit(Event::Info(demo_info(demo))),
|
||||
Link::None => {}
|
||||
},
|
||||
Cmd::Install { with_panel } => match &mut link {
|
||||
Link::Ssh(conn) => {
|
||||
let result = run_task(conn, TaskKind::Install, with_panel, false, &out).await;
|
||||
let (ok, message) = flatten(result);
|
||||
out.emit(Event::TaskDone { kind: TaskKind::Install, ok, message });
|
||||
if let Ok(info) = fetch_info(&conn.handle, &conn.host, &conn.user).await {
|
||||
out.emit(Event::Info(info));
|
||||
}
|
||||
}
|
||||
Link::Demo(demo) => {
|
||||
out.emit(Event::TaskStep(TaskStep::Uploading));
|
||||
for step in 1..=10 {
|
||||
tokio::time::sleep(Duration::from_millis(90)).await;
|
||||
out.emit(Event::UploadProgress(step as f32 / 10.0));
|
||||
}
|
||||
out.emit(Event::TaskStep(TaskStep::Verifying));
|
||||
out.emit(Event::TaskLine(format!("Upload verified (SHA-256 {}…)", &bundle::TARBALL_SHA256[..16])));
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
out.emit(Event::TaskStep(TaskStep::Running));
|
||||
for line in DEMO_INSTALL_OUTPUT.iter().filter(|line| with_panel || !line.contains("panel")) {
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
out.emit(Event::TaskLine((*line).to_string()));
|
||||
}
|
||||
demo.installed = true;
|
||||
demo.with_panel = with_panel;
|
||||
out.emit(Event::TaskDone { kind: TaskKind::Install, ok: true, message: "Installed.".into() });
|
||||
out.emit(Event::Info(demo_info(demo)));
|
||||
}
|
||||
Link::None => out.emit(Event::TaskDone { kind: TaskKind::Install, ok: false, message: "Not connected to the headset.".into() }),
|
||||
},
|
||||
Cmd::Uninstall { purge } => match &mut link {
|
||||
Link::Ssh(conn) => {
|
||||
stop_status(&mut status_task);
|
||||
let (ok, message) = flatten(run_task(conn, TaskKind::Uninstall, false, purge, &out).await);
|
||||
out.emit(Event::TaskDone { kind: TaskKind::Uninstall, ok, message });
|
||||
if let Ok(info) = fetch_info(&conn.handle, &conn.host, &conn.user).await {
|
||||
out.emit(Event::Info(info));
|
||||
}
|
||||
}
|
||||
Link::Demo(demo) => {
|
||||
stop_status(&mut status_task);
|
||||
out.emit(Event::TaskStep(TaskStep::Running));
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
out.emit(Event::TaskLine("Removed frameeyeosc and the panel.".into()));
|
||||
demo.installed = false;
|
||||
out.emit(Event::TaskDone { kind: TaskKind::Uninstall, ok: true, message: "Removed.".into() });
|
||||
out.emit(Event::Info(demo_info(demo)));
|
||||
}
|
||||
Link::None => out.emit(Event::TaskDone { kind: TaskKind::Uninstall, ok: false, message: "Not connected to the headset.".into() }),
|
||||
},
|
||||
Cmd::StartStatus => {
|
||||
stop_status(&mut status_task);
|
||||
match &link {
|
||||
Link::Ssh(conn) => status_task = Some(tokio::spawn(stream_status(conn.handle.clone(), out.clone()))),
|
||||
Link::Demo(demo) => {
|
||||
let shared = demo.shared.clone();
|
||||
let installed = demo.installed;
|
||||
let out = out.clone();
|
||||
status_task = Some(tokio::spawn(async move {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
let state = shared.lock().unwrap().clone();
|
||||
let mut live = demo_status(start.elapsed().as_secs_f64(), &state);
|
||||
if !installed {
|
||||
live.service = "inactive".into();
|
||||
live.status = None;
|
||||
}
|
||||
out.emit(Event::Status(live));
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
Link::None => {}
|
||||
}
|
||||
}
|
||||
Cmd::StopStatus => stop_status(&mut status_task),
|
||||
Cmd::FetchLogs => match &link {
|
||||
Link::Ssh(conn) => match exec_simple(&conn.handle, &remote::logs(), Duration::from_secs(20)).await {
|
||||
Ok(output) => out.emit(Event::Logs(output.stdout)),
|
||||
Err(detail) => lost_or(&conn.handle, &out, detail),
|
||||
},
|
||||
Link::Demo(_) => out.emit(Event::Logs(
|
||||
"== frameeyeosc (newest last) ==\nReading /dev/shm/eye-server.mmap\nSending OSC to 192.168.1.20:9000\nEye tracking started\n(demo)\n".into(),
|
||||
)),
|
||||
Link::None => {}
|
||||
},
|
||||
Cmd::RestartService => {
|
||||
let label = "Start frameeyeosc".to_string();
|
||||
match &link {
|
||||
Link::Ssh(conn) => match exec_simple(&conn.handle, &remote::restart_service(), Duration::from_secs(30)).await {
|
||||
Ok(output) if output.code == Some(0) => out.emit(Event::ActionDone { label, ok: true, message: "frameeyeosc was (re)started.".into() }),
|
||||
Ok(output) => out.emit(Event::ActionDone { label, ok: false, message: output.stdout }),
|
||||
Err(detail) => lost_or(&conn.handle, &out, detail),
|
||||
},
|
||||
Link::Demo(_) => out.emit(Event::ActionDone { label, ok: true, message: "frameeyeosc was (re)started.".into() }),
|
||||
Link::None => {}
|
||||
}
|
||||
}
|
||||
Cmd::ShowPanel => {
|
||||
let label = "Show the Eye panel".to_string();
|
||||
match &link {
|
||||
Link::Ssh(conn) => match exec_simple(&conn.handle, &remote::show_panel(), Duration::from_secs(30)).await {
|
||||
Ok(_) => out.emit(Event::ActionDone {
|
||||
label,
|
||||
ok: true,
|
||||
message: "Asked the headset to open the Eye panel on the SteamVR dashboard (SteamVR must be running).".into(),
|
||||
}),
|
||||
Err(detail) => lost_or(&conn.handle, &out, detail),
|
||||
},
|
||||
Link::Demo(_) => out.emit(Event::ActionDone { label, ok: true, message: "The Eye panel would open in the headset now (demo).".into() }),
|
||||
Link::None => {}
|
||||
}
|
||||
}
|
||||
Cmd::SetConfig { changes, label } => match &link {
|
||||
Link::Ssh(conn) => match set_config(conn, &changes).await {
|
||||
Ok(()) => out.emit(Event::ActionDone { label, ok: true, message: "Saved on the headset; frameeyeosc uses it right away.".into() }),
|
||||
Err(message) => {
|
||||
if conn.handle.is_closed() {
|
||||
lost_or(&conn.handle, &out, message);
|
||||
} else {
|
||||
out.emit(Event::ActionDone { label, ok: false, message });
|
||||
}
|
||||
}
|
||||
},
|
||||
Link::Demo(demo) => {
|
||||
let mut state = demo.shared.lock().unwrap();
|
||||
for (key, value) in &changes {
|
||||
match (key.as_str(), value) {
|
||||
("sending", serde_json::Value::Bool(sending)) => state.sending = *sending,
|
||||
("host", serde_json::Value::String(host)) => state.host = host.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.emit(Event::ActionDone { label, ok: true, message: "Saved (demo).".into() });
|
||||
}
|
||||
Link::None => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten(result: Result<String, String>) -> (bool, String) {
|
||||
match result {
|
||||
Ok(message) => (true, message),
|
||||
Err(message) => (false, message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Report a failed request: as a lost connection if that's what it was.
|
||||
fn lost_or(handle: &Handle<Client>, out: &Emitter, detail: String) {
|
||||
if handle.is_closed() {
|
||||
out.emit(Event::Disconnected { reason: format!("The connection to the headset was lost ({detail}).") });
|
||||
} else {
|
||||
out.emit(Event::ActionDone { label: "Headset".into(), ok: false, message: detail });
|
||||
}
|
||||
}
|
||||
Generated
+112
@@ -0,0 +1,112 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "fake-eye-server"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "fake-eye-server"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
description = "Pretends to be the Steam Frame's eye tracker (shared-memory layout v4) so frameeyeosc can run on a PC"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
@@ -0,0 +1,381 @@
|
||||
//! Pretends to be the Steam Frame's eye tracker, so the real frameeyeosc (and its Eye panel) can run on
|
||||
//! an ordinary Linux PC. It creates /dev/shm/eye-server.mmap with the private version-4 layout that
|
||||
//! frameeyeosc reads (see frameeyeosc's src/main.rs) and publishes made-up samples about 120 times a second:
|
||||
//! eyes that look around, blink and now and then wink.
|
||||
//!
|
||||
//! During the Eye panel's eye fit it plays along: it reads the capture requests the panel writes into
|
||||
//! config.json and looks at each dot (or closes its eyes), like a person following the fit would.
|
||||
//! Its "sensor" is deliberately a little off (reads low and a bit narrow vertically), so the fit has
|
||||
//! something to correct.
|
||||
//!
|
||||
//! fake-eye-server [--rate HZ] [--no-bias] [--config PATH]
|
||||
//! Stop with Ctrl+C; the shared-memory file is removed on exit.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::ptr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const PATH: &str = "/dev/shm/eye-server.mmap";
|
||||
const SHM_SIZE: usize = 0x4f21a;
|
||||
const VERSION: u32 = 4;
|
||||
const OFF_INITIALIZED: usize = 0x04;
|
||||
const OFF_MUTEX: usize = 0x08;
|
||||
const OFF_SEQUENCE: usize = 0x38;
|
||||
const OFF_REQUESTED: usize = 0x3c;
|
||||
const OFF_RECORD: usize = 0x152;
|
||||
// Inside the packed record
|
||||
const REC_STATE: usize = 0x00;
|
||||
const REC_FLAG: usize = 0x04;
|
||||
const REC_TIME: usize = 0x05;
|
||||
const REC_GAZE: usize = 0x0d;
|
||||
const REC_GAZE_COV: usize = 0x25;
|
||||
const REC_FIXATION: usize = 0x3d;
|
||||
const REC_PRE_GAZE: usize = 0x49;
|
||||
const REC_PRE_COV: usize = 0x61;
|
||||
const REC_OPENNESS: usize = 0x79;
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<libc::pthread_mutex_t>() <= 0x30);
|
||||
|
||||
static STOP: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
extern "C" fn on_signal(_: libc::c_int) {
|
||||
STOP.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
struct Shm {
|
||||
base: *mut u8,
|
||||
}
|
||||
|
||||
impl Shm {
|
||||
fn create() -> io::Result<Self> {
|
||||
let _ = std::fs::remove_file(PATH);
|
||||
let file = OpenOptions::new().read(true).write(true).create_new(true).mode(0o666).open(PATH)?;
|
||||
file.set_len(SHM_SIZE as u64)?;
|
||||
use std::os::fd::AsRawFd;
|
||||
let base = unsafe {
|
||||
libc::mmap(ptr::null_mut(), SHM_SIZE, libc::PROT_READ | libc::PROT_WRITE, libc::MAP_SHARED, file.as_raw_fd(), 0)
|
||||
};
|
||||
if base == libc::MAP_FAILED {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let shm = Shm { base: base.cast() };
|
||||
unsafe {
|
||||
// A robust, process-shared mutex, like the real eye server's
|
||||
let mut attr: libc::pthread_mutexattr_t = std::mem::zeroed();
|
||||
libc::pthread_mutexattr_init(&mut attr);
|
||||
libc::pthread_mutexattr_setpshared(&mut attr, libc::PTHREAD_PROCESS_SHARED);
|
||||
libc::pthread_mutexattr_setrobust(&mut attr, libc::PTHREAD_MUTEX_ROBUST);
|
||||
libc::pthread_mutex_init(shm.mutex(), &attr);
|
||||
libc::pthread_mutexattr_destroy(&mut attr);
|
||||
shm.write_u32(0, VERSION);
|
||||
shm.write_u32(OFF_INITIALIZED, 1);
|
||||
}
|
||||
Ok(shm)
|
||||
}
|
||||
|
||||
fn mutex(&self) -> *mut libc::pthread_mutex_t {
|
||||
unsafe { self.base.add(OFF_MUTEX).cast() }
|
||||
}
|
||||
|
||||
unsafe fn write_u32(&self, offset: usize, value: u32) {
|
||||
unsafe { ptr::write_volatile(self.base.add(offset).cast::<u32>(), value) };
|
||||
}
|
||||
|
||||
unsafe fn write_bytes(&self, offset: usize, bytes: &[u8]) {
|
||||
unsafe { ptr::copy_nonoverlapping(bytes.as_ptr(), self.base.add(offset), bytes.len()) };
|
||||
}
|
||||
|
||||
fn publish(&self, sample: &Sample) {
|
||||
let record = OFF_RECORD;
|
||||
let floats = |values: &[f32]| -> Vec<u8> { values.iter().flat_map(|value| value.to_le_bytes()).collect() };
|
||||
unsafe {
|
||||
let code = libc::pthread_mutex_lock(self.mutex());
|
||||
if code == libc::EOWNERDEAD {
|
||||
libc::pthread_mutex_consistent(self.mutex());
|
||||
}
|
||||
self.write_bytes(record + REC_STATE, &1u32.to_le_bytes());
|
||||
self.write_bytes(record + REC_FLAG, &[1]);
|
||||
self.write_bytes(record + REC_TIME, &sample.time.to_le_bytes());
|
||||
let gaze = [sample.left[0], sample.left[1], sample.left[2], sample.right[0], sample.right[1], sample.right[2]];
|
||||
self.write_bytes(record + REC_GAZE, &floats(&gaze));
|
||||
self.write_bytes(record + REC_GAZE_COV, &floats(&[0.005; 6]));
|
||||
self.write_bytes(record + REC_FIXATION, &floats(&sample.fixation));
|
||||
self.write_bytes(record + REC_PRE_GAZE, &floats(&gaze));
|
||||
self.write_bytes(record + REC_PRE_COV, &floats(&[0.005; 6]));
|
||||
self.write_bytes(record + REC_OPENNESS, &floats(&sample.openness));
|
||||
let sequence = self.base.add(OFF_SEQUENCE).cast::<u32>();
|
||||
ptr::write_volatile(sequence, ptr::read_volatile(sequence).wrapping_add(1));
|
||||
self.write_u32(OFF_REQUESTED, 0);
|
||||
libc::pthread_mutex_unlock(self.mutex());
|
||||
// Shared (not private) futex: frameeyeosc waits on this word from another process
|
||||
libc::syscall(libc::SYS_futex, sequence, libc::FUTEX_WAKE, i32::MAX, ptr::null::<libc::timespec>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Shm {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::munmap(self.base.cast(), SHM_SIZE) };
|
||||
let _ = std::fs::remove_file(PATH);
|
||||
}
|
||||
}
|
||||
|
||||
struct Sample {
|
||||
time: f64,
|
||||
left: [f32; 3],
|
||||
right: [f32; 3],
|
||||
fixation: [f32; 3],
|
||||
openness: [f32; 2],
|
||||
}
|
||||
|
||||
/// A small deterministic pseudo-random generator (xorshift), so no extra crate is needed.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f32 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
(self.0 >> 40) as f32 / (1u64 << 24) as f32
|
||||
}
|
||||
|
||||
fn noise(&mut self, size: f32) -> f32 {
|
||||
(self.next() + self.next() + self.next() - 1.5) * size
|
||||
}
|
||||
}
|
||||
|
||||
/// What the pretend person is doing.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Look {
|
||||
Wander,
|
||||
/// Following an eye-fit dot: degrees right, degrees up.
|
||||
Target(f32, f32),
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Reads the eye-fit capture the panel asked for (config.json `gaze_capture`) and whether frameeyeosc
|
||||
/// finished it (status.json).
|
||||
struct FitWatcher {
|
||||
config: Option<PathBuf>,
|
||||
status: Option<PathBuf>,
|
||||
last_check: Instant,
|
||||
look: Look,
|
||||
/// Until when to keep holding the look after a capture finished (the dot is still up, gliding on).
|
||||
hold_until: Option<Instant>,
|
||||
active_id: i64,
|
||||
}
|
||||
|
||||
impl FitWatcher {
|
||||
fn new(config: Option<PathBuf>) -> Self {
|
||||
let config = config.or_else(|| {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
|
||||
Some(base.join("frameeyeosc").join("config.json"))
|
||||
});
|
||||
let status = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| Some(PathBuf::from(format!("/run/user/{}", unsafe { libc::getuid() }))))
|
||||
.map(|dir| dir.join("frameeyeosc").join("status.json"));
|
||||
Self {
|
||||
config,
|
||||
status,
|
||||
last_check: Instant::now() - Duration::from_secs(1),
|
||||
look: Look::Wander,
|
||||
hold_until: None,
|
||||
active_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json(path: &Option<PathBuf>) -> Option<serde_json::Value> {
|
||||
let text = std::fs::read_to_string(path.as_ref()?).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
fn update(&mut self) -> Look {
|
||||
if self.last_check.elapsed() < Duration::from_millis(50) {
|
||||
return self.current();
|
||||
}
|
||||
self.last_check = Instant::now();
|
||||
let request = Self::read_json(&self.config).and_then(|config| config.get("gaze_capture").cloned());
|
||||
let id = request.as_ref().and_then(|request| request.get("id")).and_then(|id| id.as_i64()).unwrap_or(0);
|
||||
let target = request.as_ref().and_then(|request| request.get("target")).and_then(|t| t.as_str()).unwrap_or("");
|
||||
let done_id = Self::read_json(&self.status)
|
||||
.and_then(|status| status.get("gaze_capture").cloned())
|
||||
.filter(|capture| capture.get("state").and_then(|state| state.as_str()) == Some("done"))
|
||||
.and_then(|capture| capture.get("id").and_then(|id| id.as_i64()))
|
||||
.unwrap_or(0);
|
||||
if id != 0 && id != done_id {
|
||||
// A capture is running (or about to): look where it wants
|
||||
if id != self.active_id {
|
||||
self.active_id = id;
|
||||
eprintln!("[fake-eye-server] eye fit asks for \"{target}\" (capture {id}); looking there");
|
||||
}
|
||||
self.look = match target {
|
||||
"center" => Look::Target(0.0, 0.0),
|
||||
"up" => Look::Target(0.0, 15.0),
|
||||
"down" => Look::Target(0.0, -15.0),
|
||||
"left" => Look::Target(-20.0, 0.0),
|
||||
"right" => Look::Target(20.0, 0.0),
|
||||
"closed" => Look::Closed,
|
||||
_ => self.look,
|
||||
};
|
||||
self.hold_until = Some(Instant::now() + Duration::from_secs(3));
|
||||
} else if self.look == Look::Closed && id == done_id {
|
||||
// The eyes-shut step is over: open them again
|
||||
self.look = Look::Wander;
|
||||
self.hold_until = None;
|
||||
}
|
||||
self.current()
|
||||
}
|
||||
|
||||
fn current(&mut self) -> Look {
|
||||
match self.hold_until {
|
||||
Some(until) if Instant::now() > until => {
|
||||
self.hold_until = None;
|
||||
self.look = Look::Wander;
|
||||
Look::Wander
|
||||
}
|
||||
_ => self.look,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn direction(yaw_deg: f32, pitch_deg: f32) -> [f32; 3] {
|
||||
let (x, y) = (yaw_deg.to_radians().tan(), pitch_deg.to_radians().tan());
|
||||
let length = (x * x + y * y + 1.0).sqrt();
|
||||
[x / length, y / length, -1.0 / length]
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut rate = 120.0f64;
|
||||
let mut bias = true;
|
||||
let mut config = None;
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--rate" if i + 1 < args.len() => {
|
||||
rate = args[i + 1].parse().unwrap_or(rate).clamp(5.0, 500.0);
|
||||
i += 1;
|
||||
}
|
||||
"--no-bias" => bias = false,
|
||||
"--config" if i + 1 < args.len() => {
|
||||
config = Some(PathBuf::from(&args[i + 1]));
|
||||
i += 1;
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
println!("fake-eye-server [--rate HZ] [--no-bias] [--config PATH]\nPretends to be the Steam Frame's eye tracker at {PATH}.");
|
||||
return;
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown option {other}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
unsafe {
|
||||
libc::signal(libc::SIGINT, on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t);
|
||||
libc::signal(libc::SIGTERM, on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t);
|
||||
}
|
||||
let shm = match Shm::create() {
|
||||
Ok(shm) => shm,
|
||||
Err(error) => {
|
||||
eprintln!("[fake-eye-server] can't create {PATH}: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
eprintln!("[fake-eye-server] publishing made-up eye samples at {rate:.0} Hz to {PATH} (Ctrl+C to stop)");
|
||||
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15);
|
||||
let mut fit = FitWatcher::new(config);
|
||||
let start = Instant::now();
|
||||
let period = Duration::from_secs_f64(1.0 / rate);
|
||||
let mut next = Instant::now();
|
||||
// Wandering: fixations at random points, with quick saccades between them
|
||||
let (mut yaw, mut pitch) = (0.0f32, 0.0f32);
|
||||
let (mut goal_yaw, mut goal_pitch) = (0.0f32, 0.0f32);
|
||||
let mut next_saccade = 0.8f64;
|
||||
let mut next_blink = 2.5f64;
|
||||
let mut blink_start = -10.0f64;
|
||||
let mut wink_start = -10.0f64;
|
||||
let mut next_wink = 17.0f64;
|
||||
let (mut open_left, mut open_right) = (0.84f32, 0.84f32);
|
||||
|
||||
while !STOP.load(Ordering::SeqCst) {
|
||||
let t = start.elapsed().as_secs_f64();
|
||||
let look = fit.update();
|
||||
let (target_yaw, target_pitch, shut) = match look {
|
||||
Look::Wander => {
|
||||
if t > next_saccade {
|
||||
goal_yaw = rng.noise(34.0);
|
||||
goal_pitch = rng.noise(20.0) - 2.0;
|
||||
next_saccade = t + 0.6 + rng.next() as f64 * 1.6;
|
||||
}
|
||||
(goal_yaw, goal_pitch, false)
|
||||
}
|
||||
Look::Target(y, p) => (y, p, false),
|
||||
Look::Closed => (0.0, 0.0, true),
|
||||
};
|
||||
// Saccade: cover most of the way within ~50 ms
|
||||
let k = 1.0 - (-(1.0 / rate as f32) * 45.0).exp();
|
||||
yaw += (target_yaw - yaw) * k;
|
||||
pitch += (target_pitch - pitch) * k;
|
||||
|
||||
// Blinks (not while following a fit dot) and a wink now and then
|
||||
if look == Look::Wander && t > next_blink {
|
||||
blink_start = t;
|
||||
next_blink = t + 2.5 + rng.next() as f64 * 3.5;
|
||||
}
|
||||
if look == Look::Wander && t > next_wink {
|
||||
wink_start = t;
|
||||
next_wink = t + 15.0 + rng.next() as f64 * 10.0;
|
||||
}
|
||||
let blink = |since: f64| -> f32 {
|
||||
let s = (t - since) as f32;
|
||||
if s < 0.0 || s > 0.26 { 1.0 } else if s < 0.08 { 1.0 - s / 0.08 } else if s < 0.14 { 0.0 } else { (s - 0.14) / 0.12 }
|
||||
};
|
||||
let blinking = blink(blink_start);
|
||||
let winking = if (t - wink_start) < 0.6 { 0.0 } else { 1.0 };
|
||||
// The Frame reads eyes as less open when looking down
|
||||
let relaxed = 0.84 - 0.25 * (-pitch / 20.0).max(0.0);
|
||||
let (goal_left, goal_right) = if shut {
|
||||
(0.18, 0.19)
|
||||
} else {
|
||||
(0.2 + (relaxed - 0.2) * blinking, 0.2 + (relaxed * 1.02 - 0.2) * blinking * winking)
|
||||
};
|
||||
let lid_k = 1.0 - (-(1.0 / rate as f32) * 60.0).exp();
|
||||
open_left += (goal_left - open_left) * lid_k;
|
||||
open_right += (goal_right - open_right) * lid_k;
|
||||
|
||||
// What the "sensor" reports: a little noise, and (unless --no-bias) a bit low and narrow
|
||||
let (sensor_yaw, sensor_pitch) = if bias { (yaw * 0.95 + 1.0, pitch * 0.85 - 2.5) } else { (yaw, pitch) };
|
||||
let (n1, n2, n3, n4) = (rng.noise(0.5), rng.noise(0.5), rng.noise(0.5), rng.noise(0.5));
|
||||
// Each eye turns in a little toward a point about 2 m away
|
||||
let left = direction(sensor_yaw + 0.9 + n1, sensor_pitch + n2);
|
||||
let right = direction(sensor_yaw - 0.9 + n3, sensor_pitch + n4);
|
||||
let center = direction(sensor_yaw + (n1 + n3) / 2.0, sensor_pitch + (n2 + n4) / 2.0);
|
||||
let sample = Sample {
|
||||
time: 1000.0 + t,
|
||||
left,
|
||||
right,
|
||||
fixation: center.map(|c| c * 0.7),
|
||||
openness: [(open_left + rng.noise(0.01)).clamp(0.0, 1.2), (open_right + rng.noise(0.01)).clamp(0.0, 1.2)],
|
||||
};
|
||||
shm.publish(&sample);
|
||||
|
||||
next += period;
|
||||
let now = Instant::now();
|
||||
if next > now {
|
||||
std::thread::sleep(next - now);
|
||||
} else {
|
||||
next = now;
|
||||
}
|
||||
}
|
||||
eprintln!("[fake-eye-server] stopped");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# frameeyeosc-panel as a desktop window, for previewing it on a Linux PC without SteamVR.
|
||||
# Builds the panel's own sources unchanged, except:
|
||||
# - vr_overlay.cpp / vk_texture.cpp (SteamVR and its Vulkan textures) are replaced by vr_overlay_desktop.cpp (SDL2)
|
||||
# - config.h's font paths point at this distribution's Noto Sans CJK (SteamOS keeps them elsewhere)
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(frameeyeosc-panel-preview LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
endif()
|
||||
|
||||
set(UPSTREAM "${CMAKE_CURRENT_SOURCE_DIR}/../../upstream/frameeyeosc" CACHE PATH "frameeyeosc source tree")
|
||||
set(PANEL_FONT_REGULAR "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc" CACHE FILEPATH "Regular font")
|
||||
set(PANEL_FONT_BOLD "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Bold.ttc" CACHE FILEPATH "Bold font")
|
||||
|
||||
# A copy of the panel's sources with the font paths replaced
|
||||
set(SRC "${CMAKE_BINARY_DIR}/panel-src")
|
||||
file(REMOVE_RECURSE "${SRC}")
|
||||
file(COPY "${UPSTREAM}/panel/src/" DESTINATION "${SRC}")
|
||||
file(READ "${SRC}/config.h" CONFIG_H)
|
||||
string(REPLACE "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc" "${PANEL_FONT_REGULAR}" CONFIG_H "${CONFIG_H}")
|
||||
string(REPLACE "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc" "${PANEL_FONT_BOLD}" CONFIG_H "${CONFIG_H}")
|
||||
file(WRITE "${SRC}/config.h" "${CONFIG_H}")
|
||||
|
||||
file(STRINGS "${UPSTREAM}/Cargo.toml" CARGO_VERSION_LINE REGEX "^version = \"[^\"]+\"" LIMIT_COUNT 1)
|
||||
string(REGEX REPLACE "^version = \"([^\"]+)\".*" "\\1" FRAMEEYEOSC_VERSION "${CARGO_VERSION_LINE}")
|
||||
|
||||
find_package(PkgConfig REQUIRED)
|
||||
find_package(Threads REQUIRED)
|
||||
pkg_check_modules(CAIRO REQUIRED IMPORTED_TARGET cairo)
|
||||
pkg_check_modules(FREETYPE REQUIRED IMPORTED_TARGET freetype2)
|
||||
pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2)
|
||||
# vk_texture.h (included by vr_overlay.h) needs the Vulkan headers only; nothing links Vulkan
|
||||
find_path(VULKAN_INCLUDE vulkan/vulkan.h REQUIRED)
|
||||
|
||||
add_executable(frameeyeosc-panel-preview
|
||||
${SRC}/main.cpp
|
||||
${SRC}/autostart.cpp
|
||||
${SRC}/command.cpp
|
||||
${SRC}/config.cpp
|
||||
${SRC}/draw.cpp
|
||||
${SRC}/gaze_dots.cpp
|
||||
${SRC}/gaze_fit.cpp
|
||||
${SRC}/fit_text.cpp
|
||||
${SRC}/host_entry.cpp
|
||||
${SRC}/i18n.cpp
|
||||
${SRC}/json.cpp
|
||||
${SRC}/model.cpp
|
||||
${SRC}/panel.cpp
|
||||
${SRC}/sounds.cpp
|
||||
${SRC}/status.cpp
|
||||
${SRC}/target.cpp
|
||||
${SRC}/theme.cpp
|
||||
${UPSTREAM}/vendor/frame-updater/cpp/update_check.cpp
|
||||
vr_overlay_desktop.cpp
|
||||
)
|
||||
target_include_directories(frameeyeosc-panel-preview PRIVATE "${SRC}" "${UPSTREAM}/vendor/frame-updater/cpp" "${VULKAN_INCLUDE}")
|
||||
target_compile_definitions(frameeyeosc-panel-preview PRIVATE FRAMEEYEOSC_VERSION="${FRAMEEYEOSC_VERSION}")
|
||||
target_compile_options(frameeyeosc-panel-preview PRIVATE -Wall -Wextra)
|
||||
target_link_libraries(frameeyeosc-panel-preview PRIVATE PkgConfig::CAIRO PkgConfig::FREETYPE PkgConfig::SDL2 Threads::Threads)
|
||||
@@ -0,0 +1,534 @@
|
||||
// A desktop stand-in for frameeyeosc-panel's vr_overlay.cpp: the same panel in an ordinary window (SDL2)
|
||||
// instead of on the SteamVR dashboard, so it can be tried on a PC without a headset. Everything else
|
||||
// (drawing, buttons, settings, the eye fit, the sounds) is frameeyeosc-panel's own code, unchanged.
|
||||
//
|
||||
// While "the dashboard is open" the window shows the panel and the mouse works like the laser pointer.
|
||||
// Space (or the button at the top) closes the dashboard: the window then shows what you would see in the
|
||||
// headset, which is where the eye fit's dot and the debug gaze dots appear.
|
||||
|
||||
#include "vr_overlay.h"
|
||||
|
||||
#include "gaze_fit.h"
|
||||
|
||||
#include <SDL.h>
|
||||
#include <cairo.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// vk_texture.cpp needs OpenVR, so it isn't built here; VrOverlay only holds these as (unused) members.
|
||||
VulkanContext::~VulkanContext() {}
|
||||
OverlayTexture::~OverlayTexture() {}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kBar = 46; ///< the toolbar above the panel (logical px)
|
||||
constexpr double kPxPerDegree = 12.0; ///< the headset view: 100 degrees across the 1200 px panel width
|
||||
constexpr double kTargetWidthM = 0.3; ///< the eye fit's target is 0.3 m wide, 2 m ahead (see vr_overlay.cpp)
|
||||
constexpr double kTargetDistanceM = 2.0;
|
||||
|
||||
struct Image {
|
||||
std::vector<uint8_t> rgba; ///< non-premultiplied
|
||||
int size = 0;
|
||||
cairo_surface_t* surface = nullptr;
|
||||
|
||||
void set(const uint8_t* data, int edge) {
|
||||
rgba.assign(data, data + static_cast<size_t>(edge) * edge * 4);
|
||||
size = edge;
|
||||
if (surface != nullptr) cairo_surface_destroy(surface);
|
||||
surface = nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
struct Dot {
|
||||
bool shown = false;
|
||||
double x = 0, y = 0, z = -1, width = 0.02;
|
||||
Image image;
|
||||
};
|
||||
|
||||
struct Desktop {
|
||||
SDL_Window* window = nullptr;
|
||||
SDL_Renderer* renderer = nullptr;
|
||||
SDL_Texture* texture = nullptr;
|
||||
cairo_surface_t* frame = nullptr; ///< the whole window: toolbar and view
|
||||
int width = 0; ///< logical size
|
||||
int height = 0;
|
||||
int panelHeight = 0;
|
||||
cairo_surface_t* panel = nullptr;
|
||||
bool dashboardOpen = true;
|
||||
bool pointerInPanel = false;
|
||||
bool buttonHover = false;
|
||||
bool dirty = true;
|
||||
Uint32 lastDraw = 0;
|
||||
bool targetShown = false;
|
||||
double targetYaw = 0, targetPitch = 0;
|
||||
Image target;
|
||||
Dot dots[2];
|
||||
} gDesk;
|
||||
|
||||
/** Non-premultiplied RGBA into a cairo (premultiplied, native-endian ARGB) surface. */
|
||||
cairo_surface_t* toSurface(const uint8_t* rgba, int width, int height) {
|
||||
cairo_surface_t* surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height);
|
||||
cairo_surface_flush(surface);
|
||||
unsigned char* data = cairo_image_surface_get_data(surface);
|
||||
const int stride = cairo_image_surface_get_stride(surface);
|
||||
for (int y = 0; y < height; ++y) {
|
||||
uint32_t* row = reinterpret_cast<uint32_t*>(data + static_cast<size_t>(y) * stride);
|
||||
const uint8_t* in = rgba + static_cast<size_t>(y) * width * 4;
|
||||
for (int x = 0; x < width; ++x, in += 4) {
|
||||
const uint32_t a = in[3];
|
||||
row[x] = (a << 24) | ((in[0] * a / 255) << 16) | ((in[1] * a / 255) << 8) | (in[2] * a / 255);
|
||||
}
|
||||
}
|
||||
cairo_surface_mark_dirty(surface);
|
||||
return surface;
|
||||
}
|
||||
|
||||
cairo_surface_t* surfaceOf(Image& image) {
|
||||
if (image.surface == nullptr && image.size > 0) image.surface = toSurface(image.rgba.data(), image.size, image.size);
|
||||
return image.surface;
|
||||
}
|
||||
|
||||
void rgb(cairo_t* cr, uint32_t hex, double alpha = 1.0) {
|
||||
cairo_set_source_rgba(cr, ((hex >> 16) & 0xff) / 255.0, ((hex >> 8) & 0xff) / 255.0, (hex & 0xff) / 255.0, alpha);
|
||||
}
|
||||
|
||||
void text(cairo_t* cr, double x, double y, const char* utf8, double size, uint32_t color, bool bold = false,
|
||||
bool centered = false) {
|
||||
cairo_select_font_face(cr, "Noto Sans", CAIRO_FONT_SLANT_NORMAL, bold ? CAIRO_FONT_WEIGHT_BOLD : CAIRO_FONT_WEIGHT_NORMAL);
|
||||
cairo_set_font_size(cr, size);
|
||||
if (centered) {
|
||||
cairo_text_extents_t extents;
|
||||
cairo_text_extents(cr, utf8, &extents);
|
||||
x -= extents.width / 2 + extents.x_bearing;
|
||||
}
|
||||
rgb(cr, color);
|
||||
cairo_move_to(cr, x, y);
|
||||
cairo_show_text(cr, utf8);
|
||||
}
|
||||
|
||||
/** The toolbar button's rectangle (logical px). */
|
||||
void buttonRect(double& x, double& y, double& w, double& h) {
|
||||
w = 250;
|
||||
h = 32;
|
||||
x = gDesk.width - w - 10;
|
||||
y = (kBar - h) / 2;
|
||||
}
|
||||
|
||||
bool inButton(double px, double py) {
|
||||
double x, y, w, h;
|
||||
buttonRect(x, y, w, h);
|
||||
return px >= x && px <= x + w && py >= y && py <= y + h;
|
||||
}
|
||||
|
||||
void roundedRect(cairo_t* cr, double x, double y, double w, double h, double r) {
|
||||
cairo_new_sub_path(cr);
|
||||
cairo_arc(cr, x + w - r, y + r, r, -M_PI / 2, 0);
|
||||
cairo_arc(cr, x + w - r, y + h - r, r, 0, M_PI / 2);
|
||||
cairo_arc(cr, x + r, y + h - r, r, M_PI / 2, M_PI);
|
||||
cairo_arc(cr, x + r, y + r, r, M_PI, 3 * M_PI / 2);
|
||||
cairo_close_path(cr);
|
||||
}
|
||||
|
||||
/** Draw an image centered at a direction in front of you, `degrees` wide. */
|
||||
void drawInView(cairo_t* cr, cairo_surface_t* image, int size, double yawDeg, double pitchDeg, double degrees) {
|
||||
if (image == nullptr || size <= 0) return;
|
||||
const double cx = gDesk.width / 2.0 + yawDeg * kPxPerDegree;
|
||||
const double cy = kBar + gDesk.panelHeight / 2.0 - pitchDeg * kPxPerDegree;
|
||||
const double px = degrees * kPxPerDegree;
|
||||
cairo_save(cr);
|
||||
cairo_translate(cr, cx - px / 2, cy - px / 2);
|
||||
cairo_scale(cr, px / size, px / size);
|
||||
cairo_set_source_surface(cr, image, 0, 0);
|
||||
cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_GOOD);
|
||||
cairo_paint(cr);
|
||||
cairo_restore(cr);
|
||||
}
|
||||
|
||||
void drawHeadsetView(cairo_t* cr) {
|
||||
const double top = kBar;
|
||||
const double h = gDesk.panelHeight;
|
||||
const double w = gDesk.width;
|
||||
cairo_pattern_t* sky = cairo_pattern_create_radial(w / 2, top + h / 2, 40, w / 2, top + h / 2, w * 0.75);
|
||||
cairo_pattern_add_color_stop_rgb(sky, 0, 0.09, 0.11, 0.15);
|
||||
cairo_pattern_add_color_stop_rgb(sky, 1, 0.02, 0.03, 0.05);
|
||||
cairo_rectangle(cr, 0, top, w, h);
|
||||
cairo_set_source(cr, sky);
|
||||
cairo_fill(cr);
|
||||
cairo_pattern_destroy(sky);
|
||||
// A faint grid every 10 degrees, brighter through the middle
|
||||
cairo_set_line_width(cr, 1);
|
||||
for (int degree = -50; degree <= 50; degree += 10) {
|
||||
rgb(cr, degree == 0 ? 0x3a4350 : 0x1c222b);
|
||||
const double x = w / 2 + degree * kPxPerDegree + 0.5;
|
||||
cairo_move_to(cr, x, top);
|
||||
cairo_line_to(cr, x, top + h);
|
||||
cairo_stroke(cr);
|
||||
}
|
||||
for (int degree = -30; degree <= 30; degree += 10) {
|
||||
rgb(cr, degree == 0 ? 0x3a4350 : 0x1c222b);
|
||||
const double y = top + h / 2 - degree * kPxPerDegree + 0.5;
|
||||
cairo_move_to(cr, 0, y);
|
||||
cairo_line_to(cr, w, y);
|
||||
cairo_stroke(cr);
|
||||
}
|
||||
text(cr, w / 2 + 6, top + 18, "straight ahead", 12, 0x5d6672);
|
||||
text(cr, 8, top + h / 2 - 6, "eye level", 12, 0x5d6672);
|
||||
|
||||
if (gDesk.targetShown) {
|
||||
const double degrees = 2 * std::atan(kTargetWidthM / 2 / kTargetDistanceM) * 180 / M_PI;
|
||||
drawInView(cr, surfaceOf(gDesk.target), gDesk.target.size, gDesk.targetYaw, gDesk.targetPitch, degrees);
|
||||
}
|
||||
bool anyDot = false;
|
||||
for (Dot& dot : gDesk.dots) {
|
||||
if (!dot.shown) continue;
|
||||
anyDot = true;
|
||||
const double distance = std::sqrt(dot.x * dot.x + dot.y * dot.y + dot.z * dot.z);
|
||||
const double yaw = std::atan2(dot.x, -dot.z) * 180 / M_PI;
|
||||
const double pitch = std::atan2(dot.y, std::sqrt(dot.x * dot.x + dot.z * dot.z)) * 180 / M_PI;
|
||||
const double degrees = 2 * std::atan(dot.width / 2 / std::max(distance, 0.05)) * 180 / M_PI;
|
||||
drawInView(cr, surfaceOf(dot.image), dot.image.size, yaw, pitch, std::max(degrees, 1.2));
|
||||
}
|
||||
|
||||
const double bottom = top + h;
|
||||
text(cr, w / 2, bottom - 44, "The dashboard is closed: this is what you would see in the headset.", 16, 0xc9d1d9, true, true);
|
||||
if (gDesk.targetShown) {
|
||||
text(cr, w / 2, bottom - 20, "The eye fit is running. The pretend eyes follow the dot, like you would with yours.", 14, 0x9198a1,
|
||||
false, true);
|
||||
} else if (anyDot) {
|
||||
text(cr, w / 2, bottom - 20, "The dots show where the eyes being sent to VRChat are looking (Show gaze dots is on).", 14,
|
||||
0x9198a1, false, true);
|
||||
} else {
|
||||
text(cr, w / 2, bottom - 20,
|
||||
"Press Space to open the dashboard again. (Eye fit tab: press Fit my eyes, then close the dashboard.)", 14,
|
||||
0x9198a1, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
void redraw() {
|
||||
if (gDesk.renderer == nullptr) return;
|
||||
cairo_t* cr = cairo_create(gDesk.frame);
|
||||
// Toolbar
|
||||
rgb(cr, 0x0d1117);
|
||||
cairo_rectangle(cr, 0, 0, gDesk.width, kBar);
|
||||
cairo_fill(cr);
|
||||
rgb(cr, 0x30363d);
|
||||
cairo_rectangle(cr, 0, kBar - 1, gDesk.width, 1);
|
||||
cairo_fill(cr);
|
||||
text(cr, 14, 20, "Eye panel preview", 15, 0xe6edf3, true);
|
||||
text(cr, 14, 38, "A pretend Steam Frame on this PC. Nothing is sent to VRChat.", 12.5, 0x9198a1);
|
||||
double bx, by, bw, bh;
|
||||
buttonRect(bx, by, bw, bh);
|
||||
roundedRect(cr, bx, by, bw, bh, 7);
|
||||
rgb(cr, gDesk.buttonHover ? 0xc45fe0 : 0xe27dfd);
|
||||
cairo_fill(cr);
|
||||
text(cr, bx + bw / 2, by + 21, gDesk.dashboardOpen ? "Close the dashboard (Space)" : "Open the dashboard (Space)", 14,
|
||||
0x0d1117, true, true);
|
||||
// The view
|
||||
if (gDesk.dashboardOpen) {
|
||||
rgb(cr, 0x0d1117);
|
||||
cairo_rectangle(cr, 0, kBar, gDesk.width, gDesk.panelHeight);
|
||||
cairo_fill(cr);
|
||||
if (gDesk.panel != nullptr) {
|
||||
cairo_set_source_surface(cr, gDesk.panel, 0, kBar);
|
||||
cairo_paint(cr);
|
||||
}
|
||||
} else {
|
||||
drawHeadsetView(cr);
|
||||
}
|
||||
cairo_destroy(cr);
|
||||
cairo_surface_flush(gDesk.frame);
|
||||
SDL_UpdateTexture(gDesk.texture, nullptr, cairo_image_surface_get_data(gDesk.frame),
|
||||
cairo_image_surface_get_stride(gDesk.frame));
|
||||
SDL_SetRenderDrawColor(gDesk.renderer, 0x0d, 0x11, 0x17, 0xff);
|
||||
SDL_RenderClear(gDesk.renderer);
|
||||
SDL_RenderCopy(gDesk.renderer, gDesk.texture, nullptr, nullptr);
|
||||
SDL_RenderPresent(gDesk.renderer);
|
||||
gDesk.dirty = false;
|
||||
gDesk.lastDraw = SDL_GetTicks();
|
||||
}
|
||||
|
||||
/**
|
||||
* For testing and for taking pictures: PREVIEW_SCRIPT runs simple steps separated by ";":
|
||||
* wait SECONDS | click X,Y (panel px) | key space | shot FILE.png | quit
|
||||
*/
|
||||
struct Script {
|
||||
std::vector<std::string> steps;
|
||||
size_t next = 0;
|
||||
Uint32 resumeAt = 0;
|
||||
bool loaded = false;
|
||||
} gScript;
|
||||
|
||||
void setDashboard(bool open, VrEvents& events);
|
||||
|
||||
void runScript(VrEvents& events) {
|
||||
if (!gScript.loaded) {
|
||||
gScript.loaded = true;
|
||||
if (const char* script = std::getenv("PREVIEW_SCRIPT")) {
|
||||
std::string all = script;
|
||||
size_t start = 0;
|
||||
while (start <= all.size()) {
|
||||
const size_t end = all.find(';', start);
|
||||
std::string step = all.substr(start, end == std::string::npos ? std::string::npos : end - start);
|
||||
const size_t first = step.find_first_not_of(" \t");
|
||||
if (first != std::string::npos) gScript.steps.push_back(step.substr(first));
|
||||
if (end == std::string::npos) break;
|
||||
start = end + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
while (gScript.next < gScript.steps.size() && SDL_GetTicks() >= gScript.resumeAt) {
|
||||
const std::string step = gScript.steps[gScript.next++];
|
||||
double x = 0, y = 0, seconds = 0;
|
||||
char path[512] = {};
|
||||
if (std::sscanf(step.c_str(), "wait %lf", &seconds) == 1) {
|
||||
gScript.resumeAt = SDL_GetTicks() + static_cast<Uint32>(seconds * 1000);
|
||||
} else if (std::sscanf(step.c_str(), "click %lf,%lf", &x, &y) == 2) {
|
||||
events.pointer.push_back({PointerInput::Type::Move, x, y});
|
||||
events.pointer.push_back({PointerInput::Type::Down, x, y});
|
||||
events.pointer.push_back({PointerInput::Type::Up, x, y});
|
||||
gScript.resumeAt = SDL_GetTicks() + 150;
|
||||
} else if (step == "key space") {
|
||||
setDashboard(!gDesk.dashboardOpen, events);
|
||||
} else if (std::sscanf(step.c_str(), "shot %511s", path) == 1) {
|
||||
redraw();
|
||||
const cairo_status_t result = cairo_surface_write_to_png(gDesk.frame, path);
|
||||
std::fprintf(stderr, "[preview] %s %s\n", result == CAIRO_STATUS_SUCCESS ? "saved" : "could not save", path);
|
||||
} else if (step == "quit") {
|
||||
events.closeRequested = true;
|
||||
} else {
|
||||
std::fprintf(stderr, "[preview] unknown script step: %s\n", step.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void setDashboard(bool open, VrEvents& events) {
|
||||
if (gDesk.dashboardOpen == open) return;
|
||||
gDesk.dashboardOpen = open;
|
||||
if (!open && gDesk.pointerInPanel) {
|
||||
events.pointer.push_back({PointerInput::Type::Leave, 0, 0});
|
||||
gDesk.pointerInPanel = false;
|
||||
}
|
||||
std::fprintf(stderr, "[preview] dashboard %s\n", open ? "opened" : "closed");
|
||||
gDesk.dirty = true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
VrOverlay::VrOverlay() = default;
|
||||
|
||||
VrOverlay::~VrOverlay() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
VrOverlay::ConnectResult VrOverlay::connect(int width, int height, std::string& message) {
|
||||
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear");
|
||||
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS) != 0) {
|
||||
message = std::string("SDL: ") + SDL_GetError();
|
||||
return ConnectResult::Error;
|
||||
}
|
||||
gDesk.width = width;
|
||||
gDesk.panelHeight = height;
|
||||
gDesk.height = height + kBar;
|
||||
gDesk.window = SDL_CreateWindow("Eye panel preview (frameeyeosc)", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||
gDesk.width, gDesk.height, SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI);
|
||||
if (gDesk.window == nullptr) {
|
||||
message = std::string("SDL window: ") + SDL_GetError();
|
||||
SDL_Quit();
|
||||
return ConnectResult::Error;
|
||||
}
|
||||
gDesk.renderer = SDL_CreateRenderer(gDesk.window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
|
||||
if (gDesk.renderer == nullptr) gDesk.renderer = SDL_CreateRenderer(gDesk.window, -1, SDL_RENDERER_SOFTWARE);
|
||||
if (gDesk.renderer == nullptr) {
|
||||
message = std::string("SDL renderer: ") + SDL_GetError();
|
||||
return ConnectResult::Error;
|
||||
}
|
||||
SDL_RenderSetLogicalSize(gDesk.renderer, gDesk.width, gDesk.height);
|
||||
gDesk.texture = SDL_CreateTexture(gDesk.renderer, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, gDesk.width,
|
||||
gDesk.height);
|
||||
gDesk.frame = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, gDesk.width, gDesk.height);
|
||||
connected_ = true;
|
||||
panelHeight_ = height;
|
||||
std::fprintf(stderr, "[preview] window %dx%d (the panel plus a %d px toolbar)\n", gDesk.width, gDesk.height, kBar);
|
||||
redraw();
|
||||
return ConnectResult::Ok;
|
||||
}
|
||||
|
||||
void VrOverlay::shutdown() {
|
||||
if (!connected_) return;
|
||||
connected_ = false;
|
||||
for (Image* image : {&gDesk.target, &gDesk.dots[0].image, &gDesk.dots[1].image}) {
|
||||
if (image->surface != nullptr) cairo_surface_destroy(image->surface);
|
||||
image->surface = nullptr;
|
||||
}
|
||||
if (gDesk.panel != nullptr) cairo_surface_destroy(gDesk.panel);
|
||||
if (gDesk.frame != nullptr) cairo_surface_destroy(gDesk.frame);
|
||||
gDesk.panel = gDesk.frame = nullptr;
|
||||
if (gDesk.texture != nullptr) SDL_DestroyTexture(gDesk.texture);
|
||||
if (gDesk.renderer != nullptr) SDL_DestroyRenderer(gDesk.renderer);
|
||||
if (gDesk.window != nullptr) SDL_DestroyWindow(gDesk.window);
|
||||
gDesk.texture = nullptr;
|
||||
gDesk.renderer = nullptr;
|
||||
gDesk.window = nullptr;
|
||||
SDL_Quit();
|
||||
std::fprintf(stderr, "[preview] window closed\n");
|
||||
}
|
||||
|
||||
VrEvents VrOverlay::pollEvents() {
|
||||
VrEvents events;
|
||||
SDL_Event event;
|
||||
while (SDL_PollEvent(&event)) {
|
||||
switch (event.type) {
|
||||
case SDL_QUIT:
|
||||
// Like the dashboard icon's "close": the panel quits
|
||||
events.closeRequested = true;
|
||||
break;
|
||||
case SDL_KEYDOWN:
|
||||
if (event.key.repeat == 0 && (event.key.keysym.sym == SDLK_SPACE || event.key.keysym.sym == SDLK_TAB ||
|
||||
event.key.keysym.sym == SDLK_ESCAPE)) {
|
||||
setDashboard(event.key.keysym.sym == SDLK_ESCAPE ? false : !gDesk.dashboardOpen, events);
|
||||
}
|
||||
break;
|
||||
case SDL_MOUSEMOTION: {
|
||||
const double x = event.motion.x, y = event.motion.y;
|
||||
const bool hover = inButton(x, y);
|
||||
if (hover != gDesk.buttonHover) {
|
||||
gDesk.buttonHover = hover;
|
||||
gDesk.dirty = true;
|
||||
}
|
||||
if (gDesk.dashboardOpen && y >= kBar) {
|
||||
events.pointer.push_back({PointerInput::Type::Move, x, y - kBar});
|
||||
gDesk.pointerInPanel = true;
|
||||
} else if (gDesk.pointerInPanel) {
|
||||
events.pointer.push_back({PointerInput::Type::Leave, 0, 0});
|
||||
gDesk.pointerInPanel = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SDL_MOUSEBUTTONDOWN:
|
||||
if (event.button.button != SDL_BUTTON_LEFT) break;
|
||||
if (inButton(event.button.x, event.button.y)) {
|
||||
setDashboard(!gDesk.dashboardOpen, events);
|
||||
} else if (gDesk.dashboardOpen && event.button.y >= kBar) {
|
||||
events.pointer.push_back({PointerInput::Type::Down, double(event.button.x), double(event.button.y - kBar)});
|
||||
} else if (!gDesk.dashboardOpen && event.button.y >= kBar) {
|
||||
// Clicking into the headset view opens the dashboard again, like the system button
|
||||
setDashboard(true, events);
|
||||
}
|
||||
break;
|
||||
case SDL_MOUSEBUTTONUP:
|
||||
if (event.button.button == SDL_BUTTON_LEFT && gDesk.dashboardOpen && event.button.y >= kBar) {
|
||||
events.pointer.push_back({PointerInput::Type::Up, double(event.button.x), double(event.button.y - kBar)});
|
||||
}
|
||||
break;
|
||||
case SDL_WINDOWEVENT:
|
||||
if (event.window.event == SDL_WINDOWEVENT_LEAVE && gDesk.pointerInPanel) {
|
||||
events.pointer.push_back({PointerInput::Type::Leave, 0, 0});
|
||||
gDesk.pointerInPanel = false;
|
||||
}
|
||||
gDesk.dirty = true;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
runScript(events);
|
||||
// The headset view moves (the fit's dot, the gaze dots) without the panel changing, so it is redrawn
|
||||
// regularly; the panel view only when something changed.
|
||||
const Uint32 now = SDL_GetTicks();
|
||||
if (gDesk.dirty || (!gDesk.dashboardOpen && now - gDesk.lastDraw >= 11) || now - gDesk.lastDraw >= 500) redraw();
|
||||
return events;
|
||||
}
|
||||
|
||||
bool VrOverlay::steamVrAlive() const {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VrOverlay::panelVisible() const {
|
||||
return gDesk.dashboardOpen;
|
||||
}
|
||||
|
||||
void VrOverlay::showPanel() {
|
||||
VrEvents ignored;
|
||||
setDashboard(true, ignored);
|
||||
if (gDesk.window != nullptr) SDL_RaiseWindow(gDesk.window);
|
||||
}
|
||||
|
||||
bool VrOverlay::dashboardVisible() const {
|
||||
return gDesk.dashboardOpen;
|
||||
}
|
||||
|
||||
double VrOverlay::userIpdMeters() const {
|
||||
return gaze_fit::kDefaultIpdM;
|
||||
}
|
||||
|
||||
bool VrOverlay::showTarget(double yawDeg, double pitchDeg, const uint8_t* rgba, int size) {
|
||||
if (rgba != nullptr) gDesk.target.set(rgba, size);
|
||||
if (!gDesk.targetShown || yawDeg != gDesk.targetYaw || pitchDeg != gDesk.targetPitch || rgba != nullptr) gDesk.dirty = true;
|
||||
gDesk.targetShown = true;
|
||||
gDesk.targetYaw = yawDeg;
|
||||
gDesk.targetPitch = pitchDeg;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VrOverlay::waitFrameSync(uint32_t /*timeoutMs*/) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void VrOverlay::hideTarget() {
|
||||
if (gDesk.targetShown) gDesk.dirty = true;
|
||||
gDesk.targetShown = false;
|
||||
}
|
||||
|
||||
bool VrOverlay::showDot(int index, double x, double y, double z, double /*yawDeg*/, double /*pitchDeg*/, const uint8_t* rgba,
|
||||
int size, bool newImage, double widthM) {
|
||||
if (index < 0 || index > 1) return false;
|
||||
Dot& dot = gDesk.dots[index];
|
||||
if (newImage || dot.image.size == 0) dot.image.set(rgba, size);
|
||||
dot.shown = true;
|
||||
dot.x = x;
|
||||
dot.y = y;
|
||||
dot.z = z;
|
||||
dot.width = widthM;
|
||||
return true;
|
||||
}
|
||||
|
||||
void VrOverlay::hideDot(int index) {
|
||||
if (index >= 0 && index <= 1) gDesk.dots[index].shown = false;
|
||||
}
|
||||
|
||||
bool VrOverlay::submitThumbnail(const uint8_t* rgba, int size) {
|
||||
if (gDesk.window == nullptr) return false;
|
||||
// The dashboard thumbnail (the eye) becomes the window icon
|
||||
SDL_Surface* icon = SDL_CreateRGBSurfaceWithFormatFrom(const_cast<uint8_t*>(rgba), size, size, 32, size * 4,
|
||||
SDL_PIXELFORMAT_RGBA32);
|
||||
if (icon == nullptr) return false;
|
||||
SDL_SetWindowIcon(gDesk.window, icon);
|
||||
SDL_FreeSurface(icon);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VrOverlay::submitPanel(const uint8_t* rgba) {
|
||||
if (gDesk.panel != nullptr) cairo_surface_destroy(gDesk.panel);
|
||||
gDesk.panel = toSurface(rgba, gDesk.width, gDesk.panelHeight);
|
||||
gDesk.dirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void VrOverlay::logOverlayState(const char* when) const {
|
||||
std::fprintf(stderr, "[preview] panel shown in a desktop window (%s)\n", when);
|
||||
}
|
||||
|
||||
int VrOverlay::probe() {
|
||||
std::printf("--probe needs SteamVR; this is the desktop preview build\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
int VrOverlay::switchAway(double /*seconds*/) {
|
||||
std::printf("--probe-switch-away needs SteamVR; this is the desktop preview build\n");
|
||||
return 1;
|
||||
}
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# Try the Steam Frame's "Eye" panel (frameeyeosc-panel) on this Linux PC, with a pretend headset:
|
||||
# fake-eye-server plays the Frame's eye tracker (made-up eyes that look around and blink)
|
||||
# frameeyeosc the real program that runs on the headset (built for this PC)
|
||||
# the Eye panel the real panel, in a window instead of the SteamVR dashboard
|
||||
# Everything lives in a scratch folder (settings, status), so nothing on this PC is changed, and
|
||||
# frameeyeosc runs in its own empty network, so it can't reach VRChat or anything else.
|
||||
#
|
||||
# ./run-panel-preview.sh start (close the window or press "Quit app" in the panel to stop)
|
||||
# ./run-panel-preview.sh --reset start over with default settings
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
bin="$here/bin"
|
||||
for program in fake-eye-server frameeyeosc frameeyeosc-panel-preview; do
|
||||
if [[ ! -x "$bin/$program" ]]; then
|
||||
echo "Missing $bin/$program. Build it first (see README.md)." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
runtime="${XDG_RUNTIME_DIR:-/tmp}"
|
||||
sandbox="$runtime/frameeye-preview"
|
||||
if [[ "${1:-}" == "--reset" ]]; then
|
||||
rm -rf "$sandbox"
|
||||
echo "Settings reset."
|
||||
fi
|
||||
mkdir -p "$sandbox/home" "$sandbox/config/frameeyeosc" "$sandbox/run"
|
||||
chmod 700 "$sandbox" "$sandbox/run"
|
||||
# One preview at a time: a second pretend eye tracker would replace the first one's shared memory
|
||||
exec 9>"$sandbox/.lock"
|
||||
if ! flock -n 9; then
|
||||
echo "The Eye panel preview is already open."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# First start: send to "this PC" as a fixed address, as if the headset streamed to it
|
||||
config="$sandbox/config/frameeyeosc/config.json"
|
||||
if [[ ! -f "$config" ]]; then
|
||||
printf '{\n "host": "127.0.0.1"\n}\n' > "$config"
|
||||
fi
|
||||
|
||||
# The panel's update button calls this; in the preview it only ever says "up to date"
|
||||
updater="$sandbox/home/.local/share/frameeyeosc/frame-update.sh"
|
||||
mkdir -p "$(dirname "$updater")"
|
||||
cat > "$updater" <<'EOF'
|
||||
#!/bin/sh
|
||||
# Preview stand-in for frameeyeosc's updater: never downloads or installs anything.
|
||||
current=0.5.3
|
||||
while [ $# -gt 0 ]; do [ "$1" = "--current" ] && current="$2"; shift; done
|
||||
printf '{"status":"up-to-date","current":"%s","latest":"%s","url":"https://github.com/sasaken1102r/frameeyeosc/releases","cached":false,"checked_at":%s}\n' \
|
||||
"$current" "$current" "$(date +%s)"
|
||||
EOF
|
||||
chmod 755 "$updater"
|
||||
|
||||
env_sandbox=(HOME="$sandbox/home" XDG_CONFIG_HOME="$sandbox/config" XDG_CACHE_HOME="$sandbox/home/.cache")
|
||||
pids=()
|
||||
cleanup() {
|
||||
for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; done
|
||||
wait 2>/dev/null || true
|
||||
echo "Preview stopped."
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "Starting the pretend eye tracker..."
|
||||
env "${env_sandbox[@]}" XDG_RUNTIME_DIR="$sandbox/run" "$bin/fake-eye-server" &
|
||||
pids+=($!)
|
||||
sleep 0.3
|
||||
|
||||
echo "Starting frameeyeosc (in its own empty network, so nothing reaches VRChat)..."
|
||||
isolate=(unshare --user --map-root-user --net sh -c 'ip link set lo up 2>/dev/null; exec "$0" "$@"')
|
||||
if ! unshare --user --map-root-user --net true 2>/dev/null; then
|
||||
# No user namespaces: pin the destination to a port nothing uses, and lock it from the panel instead
|
||||
echo " (can't isolate its network here; sending to 127.0.0.1:9019 instead, locked)"
|
||||
isolate=()
|
||||
daemon_args=(--target 127.0.0.1:9019)
|
||||
else
|
||||
daemon_args=()
|
||||
fi
|
||||
env "${env_sandbox[@]}" XDG_RUNTIME_DIR="$sandbox/run" "${isolate[@]}" "$bin/frameeyeosc" "${daemon_args[@]}" &
|
||||
pids+=($!)
|
||||
sleep 0.5
|
||||
|
||||
echo "Opening the Eye panel. Space closes/opens the pretend dashboard."
|
||||
# The panel keeps the real runtime dir for the window (Wayland) and sounds (PipeWire); its settings
|
||||
# and status come from the scratch folder
|
||||
env "${env_sandbox[@]}" "$bin/frameeyeosc-panel-preview" --status "$sandbox/run/frameeyeosc/status.json" || true
|
||||
@@ -0,0 +1,17 @@
|
||||
# A pretend Steam Frame for testing FrameEye Helper without a headset: a real OpenSSH server with the
|
||||
# "steamos" user (password login), the fake eye tracker, and an x86_64 build of frameeyeosc standing in
|
||||
# for the headset's aarch64 one. SteamOS pieces a container lacks (systemd user services, an ARM CPU)
|
||||
# are faked by the small scripts in bin/.
|
||||
FROM registry.fedoraproject.org/fedora:44
|
||||
RUN dnf -y install --setopt=install_weak_deps=False openssh-server procps-ng tar gzip findutils util-linux \
|
||||
&& dnf clean all \
|
||||
&& useradd -m -u 1000 steamos \
|
||||
&& echo 'steamos:frame-test-pw' | chpasswd \
|
||||
&& ssh-keygen -A \
|
||||
&& sed -i -E "s/^session\s+required\s+pam_loginuid.so/session optional pam_loginuid.so/" /etc/pam.d/sshd \
|
||||
&& printf 'PasswordAuthentication yes\nKbdInteractiveAuthentication no\nPermitRootLogin no\n' > /etc/ssh/sshd_config.d/00-fakeframe.conf
|
||||
COPY bin/ /usr/local/bin/
|
||||
COPY opt/ /opt/fakeframe/
|
||||
RUN chmod 755 /usr/local/bin/* /opt/fakeframe/*
|
||||
EXPOSE 22
|
||||
CMD ["/usr/local/bin/fakeframe-start"]
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
# Container entrypoint: the user's runtime dir, the fake eye tracker (as steamos), then sshd.
|
||||
mkdir -p /run/user/1000 && chown steamos:steamos /run/user/1000 && chmod 700 /run/user/1000
|
||||
if [ -n "${KBD_INTERACTIVE:-}" ]; then
|
||||
printf 'PasswordAuthentication no\nKbdInteractiveAuthentication yes\nUsePAM yes\nPermitRootLogin no\n' > /etc/ssh/sshd_config.d/00-fakeframe.conf
|
||||
fi
|
||||
runuser -u steamos -- bash -c 'nohup /opt/fakeframe/fake-eye-server > /tmp/fake-eye-server.log 2>&1 &'
|
||||
exec /usr/sbin/sshd -D -e
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# `journalctl --user -u UNIT -n N`: the fake services' logs.
|
||||
unit=""; lines=50
|
||||
while [ $# -gt 0 ]; do case "$1" in -u) unit="${2%.service}"; shift ;; -n) lines="$2"; shift ;; esac; shift; done
|
||||
log="$HOME/.fakesystemd/$unit.log"
|
||||
if [ -f "$log" ]; then tail -n "$lines" "$log"; else echo "-- No entries --"; fi
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Just enough of `systemctl --user` for frameeyeosc's install.sh and FrameEye Helper. frameeyeosc.service
|
||||
# runs the x86_64 frameeyeosc (the installed aarch64 binary can't run here); the panel only gets a flag.
|
||||
state="$HOME/.fakesystemd"; mkdir -p "$state/enabled"
|
||||
[ "${1:-}" = "--user" ] && shift
|
||||
[ "${1:-}" = "--no-pager" ] && shift
|
||||
cmd="${1:-}"; shift || true
|
||||
now=false; [ "${1:-}" = "--now" ] && { now=true; shift; }
|
||||
unit="${1:-}"; unit="${unit%.service}"
|
||||
pidfile="$state/$unit.pid"
|
||||
running() { [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; }
|
||||
start() {
|
||||
running && return 0
|
||||
case "$unit" in
|
||||
frameeyeosc)
|
||||
[ -f "$HOME/.config/frameeyeosc/env" ] && . "$HOME/.config/frameeyeosc/env"
|
||||
nohup /opt/fakeframe/frameeyeosc $FRAMEEYEOSC_ARGS >> "$state/$unit.log" 2>&1 &
|
||||
echo $! > "$pidfile" ;;
|
||||
*) sleep 100000 >/dev/null 2>&1 & echo $! > "$pidfile" ;;
|
||||
esac
|
||||
}
|
||||
stop() { if running; then kill "$(cat "$pidfile")"; fi; rm -f "$pidfile"; }
|
||||
case "$cmd" in
|
||||
daemon-reload) ;;
|
||||
enable) touch "$state/enabled/$unit"; echo "Created symlink '$HOME/.config/systemd/user/steamvr.service.wants/$unit.service' → '$HOME/.config/systemd/user/$unit.service'."; if $now; then start; fi ;;
|
||||
disable) rm -f "$state/enabled/$unit"; if $now; then stop; fi ;;
|
||||
start) start ;;
|
||||
restart) stop; start ;;
|
||||
try-restart) if running; then stop; start; fi ;;
|
||||
stop) stop ;;
|
||||
is-active)
|
||||
[ "$unit" = "steamvr" ] && { echo active; exit 0; }
|
||||
if running; then echo active; else echo inactive; exit 3; fi ;;
|
||||
is-enabled) if [ -f "$state/enabled/$unit" ]; then echo enabled; else echo disabled; exit 1; fi ;;
|
||||
status)
|
||||
echo "● $unit.service"
|
||||
if running; then echo " Active: active (running) (fake systemd, pid $(cat "$pidfile"))"; else echo " Active: inactive (dead)"; fi ;;
|
||||
*) echo "fake systemctl: $cmd not supported" >&2; exit 1 ;;
|
||||
esac
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
# The fake headset claims to be ARM64 like the Steam Frame (install.sh checks this).
|
||||
if [ "$*" = "-m" ]; then echo aarch64; else exec /usr/bin/uname "$@"; fi
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the pretend Steam Frame image. Needs the preview binaries (build/build-preview.sh) first.
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
bin="$here/../../preview/bin"
|
||||
mkdir -p "$here/opt"
|
||||
cp "$bin/frameeyeosc" "$bin/fake-eye-server" "$here/opt/"
|
||||
podman build -t fakeframe "$here"
|
||||
Submodule
+1
Submodule upstream/frameeyeosc added at a9f2a86b94.
Reference in new issue
Block a user