first version

This commit is contained in:
miyu0-bit committed 2026-09-30 04:28:38 -03:00
commit 3d43bfe5ca
47 files changed
+25167

No files matched your search

+11
View File
@@ -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/
+3
View File
@@ -0,0 +1,3 @@
[submodule "upstream/frameeyeosc"]
path = upstream/frameeyeosc
url = https://github.com/sasaken1102r/frameeyeosc.git
+21
View File
@@ -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.
+106
View File
@@ -0,0 +1,106 @@
# FrameEye Helper
Steam Frame eye tracking in VRChat, without touching a terminal.
![The status page, with the eyes that are being sent to VRChat](docs/images/helper-check.png)
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).
File diff suppressed because it is too large. Load diff
+16
View File
@@ -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
+24
View File
@@ -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"
+26
View File
@@ -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"
+71
View File
@@ -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
+100
View File
@@ -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

+43
View File
@@ -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`.
+5771
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -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
+22
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
553abc2b8f2842f2a4bb5ed9e3452e5bdbe1a511dba47439f058a93d2368cf1c frameeyeosc-0.5.3-steamframe-aarch64.tar.gz
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+23
View File
@@ -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>
+29
View File
@@ -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.

After

Width:  |  Height:  |  Size: 17 KiB

+11
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+60
View File
@@ -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);
}
}
+127
View File
@@ -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(&parameters));
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())
}
+85
View File
@@ -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) };
}
}
+150
View File
@@ -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);
}
}
+278
View File
@@ -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"));
}
}
+197
View File
@@ -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)
}
+66
View File
@@ -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)
}
}
+188
View File
@@ -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);
}
}
+808
View File
@@ -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 });
}
}
+112
View File
@@ -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"
+14
View File
@@ -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
+381
View File
@@ -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");
}
+62
View File
@@ -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;
}
+87
View File
@@ -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
+17
View File
@@ -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"]
+8
View File
@@ -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
+6
View File
@@ -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
+39
View File
@@ -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
+3
View File
@@ -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
+8
View File
@@ -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 upstream/frameeyeosc added at a9f2a86b94.