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

+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 });
}
}