mirror of
https://github.com/miyu0-bit/frameeye-helper.git
synced 2026-10-06 01:00:14 +02:00
first version
This commit is contained in:
commit
3d43bfe5ca
47 files changed
+25167
No files matched your search
Generated
+5771
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "frameeye-helper"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
description = "One-click setup for Steam Frame eye tracking in VRChat: installs frameeyeosc on the headset over SSH"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "FrameEyeHelper"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
eframe = { version = "0.36", features = ["glow"] }
|
||||
egui = "0.36"
|
||||
russh = { version = "0.63", default-features = false, features = ["ring", "flate2", "rsa"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "sync", "macros", "io-util"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.11"
|
||||
if-addrs = "0.15"
|
||||
directories = "6"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.55"
|
||||
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_Foundation"] }
|
||||
|
||||
[features]
|
||||
# `--selftest`: drive the SSH worker against a test headset (tests/fakeframe). Not in release builds.
|
||||
selftest = []
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "3"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
opt-level = 3
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 konsti219
|
||||
Copyright (c) 2026 sasaken1102r (changes in this fork)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
553abc2b8f2842f2a4bb5ed9e3452e5bdbe1a511dba47439f058a93d2368cf1c frameeyeosc-0.5.3-steamframe-aarch64.tar.gz
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity type="win32" name="FrameEyeHelper" version="1.0.0.0"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,29 @@
|
||||
#include <winver.h>
|
||||
|
||||
1 ICON "app.ico"
|
||||
1 24 "app.manifest"
|
||||
|
||||
VS_VERSION_INFO VERSIONINFO
|
||||
FILEVERSION 1,0,0,0
|
||||
PRODUCTVERSION 1,0,0,0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904B0"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "FrameEye Helper"
|
||||
VALUE "FileDescription", "FrameEye Helper - Steam Frame eye tracking for VRChat"
|
||||
VALUE "FileVersion", "1.0.0"
|
||||
VALUE "InternalName", "FrameEyeHelper"
|
||||
VALUE "OriginalFilename", "FrameEyeHelper.exe"
|
||||
VALUE "ProductName", "FrameEye Helper"
|
||||
VALUE "ProductVersion", "1.0.0"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1200
|
||||
END
|
||||
END
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,11 @@
|
||||
// Windows builds get the Eye icon and a manifest (runs as the normal user, DPI aware) embedded.
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=assets/app.rc");
|
||||
println!("cargo:rerun-if-changed=assets/app.ico");
|
||||
println!("cargo:rerun-if-changed=assets/app.manifest");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
embed_resource::compile("assets/app.rc", embed_resource::NONE)
|
||||
.manifest_optional()
|
||||
.expect("embedding the Windows icon and manifest failed");
|
||||
}
|
||||
}
|
||||
+1700
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,60 @@
|
||||
//! The frameeyeosc release that ships inside this program, so installing needs no download.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// The bundled release (github.com/sasaken1102r/frameeyeosc, tag v0.5.3).
|
||||
pub const VERSION: &str = "0.5.3";
|
||||
pub const TARBALL: &[u8] = include_bytes!("../assets/frameeyeosc-0.5.3-steamframe-aarch64.tar.gz");
|
||||
/// From the release's own SHA256SUMS file.
|
||||
pub const TARBALL_SHA256: &str = "553abc2b8f2842f2a4bb5ed9e3452e5bdbe1a511dba47439f058a93d2368cf1c";
|
||||
/// The `frameeyeosc` binary inside it, to recognise this version when the panel isn't installed.
|
||||
pub const DAEMON_SHA256: &str = "a021fda0fb83cf4760201ded03f4158c644f95e1438cc1de1e48e93e452cdfd5";
|
||||
|
||||
pub const PROJECT_URL: &str = "https://github.com/miyu0-bit/frameeye-helper";
|
||||
pub const UPSTREAM_URL: &str = "https://github.com/sasaken1102r/frameeyeosc";
|
||||
pub const ORIGINAL_URL: &str = "https://github.com/konsti219/frameeyeosc";
|
||||
pub const LICENSE: &str = include_str!("../assets/FRAMEEYEOSC_LICENSE.txt");
|
||||
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// True if the embedded tarball is intact.
|
||||
pub fn verify() -> bool {
|
||||
sha256_hex(TARBALL) == TARBALL_SHA256
|
||||
}
|
||||
|
||||
/// Compare dotted versions ("0.5.3" vs "0.10.0"); non-numeric parts count as 0.
|
||||
pub fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let parse = |v: &str| -> Vec<u64> {
|
||||
v.trim()
|
||||
.trim_start_matches('v')
|
||||
.split(['.', '-', '+'])
|
||||
.take(3)
|
||||
.map(|part| part.parse().unwrap_or(0))
|
||||
.collect()
|
||||
};
|
||||
let (mut a, mut b) = (parse(a), parse(b));
|
||||
a.resize(3, 0);
|
||||
b.resize(3, 0);
|
||||
a.cmp(&b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
#[test]
|
||||
fn bundled_tarball_matches_release_checksum() {
|
||||
assert!(verify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_compare_numerically() {
|
||||
assert_eq!(compare_versions("0.5.3", "0.5.3"), Ordering::Equal);
|
||||
assert_eq!(compare_versions("0.4.0", "0.5.3"), Ordering::Less);
|
||||
assert_eq!(compare_versions("0.10.0", "0.9.9"), Ordering::Greater);
|
||||
assert_eq!(compare_versions("v0.6", "0.5.3"), Ordering::Greater);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Windows only: let VRChat receive OSC (UDP 9000) from the headset through Windows Defender Firewall.
|
||||
//! VRChat's own firewall rule is often only allowed on "Public" networks, so OSC arriving over a
|
||||
//! "Private" home network is dropped. The rule added here is narrow: inbound UDP 9000, from the local
|
||||
//! network only, and only for VRChat.exe when it can be found.
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Where VRChat.exe is, from Steam's library list.
|
||||
#[cfg(windows)]
|
||||
pub fn find_vrchat() -> Option<PathBuf> {
|
||||
use winreg::RegKey;
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
|
||||
|
||||
let mut roots: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(key) = RegKey::predef(HKEY_CURRENT_USER).open_subkey(r"Software\Valve\Steam") {
|
||||
if let Ok(path) = key.get_value::<String, _>("SteamPath") {
|
||||
roots.push(PathBuf::from(path.replace('/', "\\")));
|
||||
}
|
||||
}
|
||||
if let Ok(key) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey(r"SOFTWARE\WOW6432Node\Valve\Steam") {
|
||||
if let Ok(path) = key.get_value::<String, _>("InstallPath") {
|
||||
roots.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
roots.push(PathBuf::from(r"C:\Program Files (x86)\Steam"));
|
||||
|
||||
let mut libraries = roots.clone();
|
||||
for root in &roots {
|
||||
let Ok(text) = std::fs::read_to_string(root.join("steamapps").join("libraryfolders.vdf")) else {
|
||||
continue;
|
||||
};
|
||||
for line in text.lines() {
|
||||
// "path" "D:\\SteamLibrary"
|
||||
if let Some(rest) = line.trim().strip_prefix("\"path\"") {
|
||||
libraries.push(PathBuf::from(rest.trim().trim_matches('"').replace("\\\\", "\\")));
|
||||
}
|
||||
}
|
||||
}
|
||||
libraries
|
||||
.into_iter()
|
||||
.map(|library| library.join("steamapps").join("common").join("VRChat").join("VRChat.exe"))
|
||||
.find(|exe| exe.is_file())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn script(vrchat: Option<&std::path::Path>) -> String {
|
||||
let program = vrchat.map(|path| path.display().to_string().replace('\'', "''")).unwrap_or_default();
|
||||
format!(
|
||||
r#"$ErrorActionPreference = 'Stop'
|
||||
$Host.UI.RawUI.WindowTitle = 'FrameEye Helper - Windows Firewall'
|
||||
Write-Host 'FrameEye Helper: letting VRChat receive eye tracking (OSC, UDP port 9000) from your Steam Frame.'
|
||||
Write-Host ''
|
||||
$name = 'VRChat OSC from Steam Frame (FrameEye Helper)'
|
||||
$program = '{program}'
|
||||
try {{
|
||||
Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue | Remove-NetFirewallRule
|
||||
$rule = @{{ DisplayName = $name; Direction = 'Inbound'; Action = 'Allow'; Protocol = 'UDP'; LocalPort = 9000; RemoteAddress = 'LocalSubnet'; Profile = 'Any' }}
|
||||
if ($program) {{ $rule.Program = $program }}
|
||||
New-NetFirewallRule @rule | Out-Null
|
||||
Write-Host "Added the firewall rule '$name'." -ForegroundColor Green
|
||||
if ($program) {{ Write-Host " It lets only VRChat ($program) receive UDP port 9000, and only from your local network." }}
|
||||
else {{ Write-Host ' It opens only UDP port 9000, and only to your local network (VRChat.exe was not found).' }}
|
||||
Write-Host ''
|
||||
Write-Host 'Checking for rules that block VRChat...'
|
||||
$blocks = @(Get-NetFirewallRule -Direction Inbound -Action Block -Enabled True -ErrorAction SilentlyContinue |
|
||||
Where-Object {{ (($_ | Get-NetFirewallApplicationFilter).Program -like '*\VRChat.exe') }})
|
||||
if ($blocks.Count -gt 0) {{
|
||||
Write-Host 'Windows also has rules that BLOCK VRChat. A block rule wins over an allow rule:' -ForegroundColor Yellow
|
||||
$blocks | ForEach-Object {{ Write-Host (' - ' + $_.DisplayName + ' (profile: ' + $_.Profile + ')') }}
|
||||
Write-Host '(These usually come from pressing "Cancel" on the firewall question when VRChat first started.)'
|
||||
$answer = Read-Host 'Turn these block rules off? Type y and press Enter for yes'
|
||||
if ($answer -match '^[yY]') {{ $blocks | Disable-NetFirewallRule; Write-Host 'Turned them off.' -ForegroundColor Green }}
|
||||
}} else {{
|
||||
Write-Host 'None found. Good.' -ForegroundColor Green
|
||||
}}
|
||||
}} catch {{
|
||||
Write-Host "Something went wrong: $_" -ForegroundColor Red
|
||||
}}
|
||||
Write-Host ''
|
||||
Read-Host 'Done. Press Enter to close this window'
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Opens an elevated PowerShell window (Windows asks for permission first) that adds the rule.
|
||||
#[cfg(windows)]
|
||||
pub fn allow_vrchat_osc() -> Result<String, String> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::UI::Shell::ShellExecuteW;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
|
||||
|
||||
let vrchat = find_vrchat();
|
||||
let path = std::env::temp_dir().join("frameeye-helper-firewall.ps1");
|
||||
// Windows PowerShell 5.1 reads a file without a BOM as ANSI, which garbles non-ASCII paths.
|
||||
let mut bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
bytes.extend_from_slice(script(vrchat.as_deref()).as_bytes());
|
||||
std::fs::write(&path, bytes).map_err(|error| format!("Couldn't write {}: {error}", path.display()))?;
|
||||
|
||||
let wide = |text: &str| -> Vec<u16> { std::ffi::OsStr::new(text).encode_wide().chain(Some(0)).collect() };
|
||||
let parameters = format!("-NoProfile -ExecutionPolicy Bypass -File \"{}\"", path.display());
|
||||
let (verb, file, parameters) = (wide("runas"), wide("powershell.exe"), wide(¶meters));
|
||||
let result = unsafe {
|
||||
ShellExecuteW(
|
||||
std::ptr::null_mut(),
|
||||
verb.as_ptr(),
|
||||
file.as_ptr(),
|
||||
parameters.as_ptr(),
|
||||
std::ptr::null(),
|
||||
SW_SHOWNORMAL,
|
||||
)
|
||||
};
|
||||
// ShellExecute returns a value above 32 on success
|
||||
if result as usize > 32 {
|
||||
Ok(match vrchat {
|
||||
Some(exe) => format!("A PowerShell window is adding the rule for {}. Follow it there.", exe.display()),
|
||||
None => "VRChat.exe wasn't found, so the rule opens UDP 9000 to the local network only. Follow the PowerShell window.".into(),
|
||||
})
|
||||
} else {
|
||||
Err("Windows didn't run it (the permission question was probably answered with No).".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn allow_vrchat_osc() -> Result<String, String> {
|
||||
Err("The firewall fix is only needed (and only works) on Windows.".into())
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! FrameEye Helper: installs frameeyeosc (Steam Frame eye tracking → VRChat over OSC) on the headset
|
||||
//! with a few clicks, and shows whether it's working.
|
||||
|
||||
// No console window behind the app on Windows (release builds)
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
mod app;
|
||||
mod bundle;
|
||||
mod firewall;
|
||||
mod net;
|
||||
mod remote;
|
||||
#[cfg(feature = "selftest")]
|
||||
mod selftest;
|
||||
mod settings;
|
||||
mod status;
|
||||
mod worker;
|
||||
|
||||
/// The Eye icon (the same one the headset's dashboard shows), as RGBA.
|
||||
pub fn icon_rgba() -> Option<(Vec<u8>, u32, u32)> {
|
||||
let image = image::load_from_memory(include_bytes!("../assets/icon-256.png")).ok()?.into_rgba8();
|
||||
let (width, height) = image.dimensions();
|
||||
Some((image.into_raw(), width, height))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.iter().any(|arg| arg == "--version") {
|
||||
println!("FrameEye Helper {} (frameeyeosc {} inside)", env!("CARGO_PKG_VERSION"), bundle::VERSION);
|
||||
return;
|
||||
}
|
||||
#[cfg(feature = "selftest")]
|
||||
if args.get(1).map(String::as_str) == Some("--selftest") {
|
||||
std::process::exit(selftest::run(&args[2..]));
|
||||
}
|
||||
let flags = app::Flags::parse(&args);
|
||||
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
.with_title("FrameEye Helper")
|
||||
.with_app_id("frameeye-helper")
|
||||
.with_inner_size([960.0, 760.0])
|
||||
.with_min_inner_size([760.0, 560.0]);
|
||||
if let Some((rgba, width, height)) = icon_rgba() {
|
||||
viewport = viewport.with_icon(egui::IconData { rgba, width, height });
|
||||
}
|
||||
let options = |renderer| eframe::NativeOptions {
|
||||
viewport: viewport.clone(),
|
||||
renderer,
|
||||
centered: true,
|
||||
..Default::default()
|
||||
};
|
||||
let run = |renderer| {
|
||||
let flags = flags.clone();
|
||||
eframe::run_native(
|
||||
"FrameEye Helper",
|
||||
options(renderer),
|
||||
Box::new(move |cc| Ok(Box::new(app::App::new(cc, flags)))),
|
||||
)
|
||||
};
|
||||
|
||||
// wgpu (DirectX 12 / Vulkan) first, then OpenGL for PCs where that fails; "--opengl" skips wgpu.
|
||||
let first = if args.iter().any(|arg| arg == "--opengl") {
|
||||
Err(eframe::Error::AppCreation("OpenGL requested".into()))
|
||||
} else {
|
||||
run(eframe::Renderer::Wgpu)
|
||||
};
|
||||
if let Err(error) = first {
|
||||
eprintln!("Could not start with wgpu ({error}); trying OpenGL");
|
||||
if let Err(error) = run(eframe::Renderer::Glow) {
|
||||
report_fatal(&format!("FrameEye Helper couldn't open its window.\n\n{error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Last resort when no window could be opened: a message box on Windows, stderr elsewhere.
|
||||
fn report_fatal(message: &str) {
|
||||
eprintln!("{message}");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{MB_ICONERROR, MB_OK, MessageBoxW};
|
||||
let wide = |text: &str| -> Vec<u16> { std::ffi::OsStr::new(text).encode_wide().chain(Some(0)).collect() };
|
||||
let (text, title) = (wide(message), wide("FrameEye Helper"));
|
||||
unsafe { MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), MB_OK | MB_ICONERROR) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! This PC's own addresses, and the "Find my headset" scan of the local network for SSH servers.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// Every address of this PC (including loopback).
|
||||
pub fn local_ips() -> Vec<IpAddr> {
|
||||
if_addrs::get_if_addrs()
|
||||
.map(|interfaces| interfaces.into_iter().map(|interface| interface.ip()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether frameeyeosc's destination is this PC: one of its addresses (`local`, from `local_ips`), or
|
||||
/// the address the headset sees this PC's SSH connection come from.
|
||||
pub fn is_this_pc(target: &str, client_ip: Option<IpAddr>, local: &[IpAddr]) -> Option<bool> {
|
||||
let ip = target
|
||||
.parse::<SocketAddr>()
|
||||
.map(|addr| addr.ip())
|
||||
.or_else(|_| target.parse::<IpAddr>())
|
||||
.ok()?;
|
||||
let ip = match ip {
|
||||
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(IpAddr::V6(v6), IpAddr::V4),
|
||||
v4 => v4,
|
||||
};
|
||||
Some(Some(ip) == client_ip || local.contains(&ip))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ScanHit {
|
||||
pub ip: Ipv4Addr,
|
||||
/// The SSH server's greeting, e.g. "SSH-2.0-OpenSSH_9.9".
|
||||
pub banner: String,
|
||||
}
|
||||
|
||||
/// The /24 networks this PC is on (private IPv4 only, no link-local), with this PC's own address.
|
||||
fn networks() -> Vec<(Ipv4Addr, Ipv4Addr)> {
|
||||
let mut networks = Vec::new();
|
||||
for interface in if_addrs::get_if_addrs().unwrap_or_default() {
|
||||
let IpAddr::V4(ip) = interface.ip() else { continue };
|
||||
if ip.is_loopback() || ip.is_link_local() || !ip.is_private() {
|
||||
continue;
|
||||
}
|
||||
let [a, b, c, _] = ip.octets();
|
||||
let base = Ipv4Addr::new(a, b, c, 0);
|
||||
if !networks.iter().any(|(known, _)| *known == base) {
|
||||
networks.push((base, ip));
|
||||
}
|
||||
}
|
||||
networks
|
||||
}
|
||||
|
||||
/// Look for SSH servers on the local /24 networks. `progress` gets a 0..1 fraction now and then.
|
||||
pub async fn scan(port: u16, progress: impl Fn(f32) + Send + Sync + 'static) -> (Vec<ScanHit>, usize) {
|
||||
let networks = networks();
|
||||
let targets: Vec<Ipv4Addr> = networks
|
||||
.iter()
|
||||
.flat_map(|(base, own)| {
|
||||
let [a, b, c, _] = base.octets();
|
||||
(1..=254u8).map(move |d| Ipv4Addr::new(a, b, c, d)).filter(move |ip| ip != own)
|
||||
})
|
||||
.collect();
|
||||
let total = targets.len();
|
||||
let limit = Arc::new(Semaphore::new(96));
|
||||
let progress = Arc::new(progress);
|
||||
let done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let mut tasks = Vec::with_capacity(total);
|
||||
for ip in targets {
|
||||
let limit = limit.clone();
|
||||
let progress = progress.clone();
|
||||
let done = done.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = limit.acquire().await.ok()?;
|
||||
let hit = probe(ip, port).await;
|
||||
let finished = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
if finished % 16 == 0 || finished == total {
|
||||
progress(finished as f32 / total.max(1) as f32);
|
||||
}
|
||||
hit
|
||||
}));
|
||||
}
|
||||
let mut hits = Vec::new();
|
||||
for task in tasks {
|
||||
if let Ok(Some(hit)) = task.await {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
hits.sort_by_key(|hit| (!hit.banner.contains("OpenSSH"), hit.ip));
|
||||
(hits, networks.len())
|
||||
}
|
||||
|
||||
async fn probe(ip: Ipv4Addr, port: u16) -> Option<ScanHit> {
|
||||
let addr = SocketAddr::new(IpAddr::V4(ip), port);
|
||||
let mut stream = tokio::time::timeout(Duration::from_millis(700), TcpStream::connect(addr))
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
let mut buffer = [0u8; 128];
|
||||
let read = tokio::time::timeout(Duration::from_millis(1500), stream.read(&mut buffer)).await;
|
||||
let banner = match read {
|
||||
Ok(Ok(n)) if n > 0 => String::from_utf8_lossy(&buffer[..n]).lines().next().unwrap_or("").trim().to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
// Only SSH servers (they greet first with "SSH-"); anything else on this port is not a headset.
|
||||
banner.starts_with("SSH-").then_some(ScanHit { ip, banner })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A local server that greets like `greeting` and then closes.
|
||||
fn server(greeting: &'static [u8]) -> u16 {
|
||||
use std::io::Write;
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut stream, _)) = listener.accept() {
|
||||
let _ = stream.write_all(greeting);
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_finds_ssh_servers_only() {
|
||||
let ssh = server(b"SSH-2.0-OpenSSH_9.9\r\n");
|
||||
let hit = probe(Ipv4Addr::LOCALHOST, ssh).await.expect("an SSH server is found");
|
||||
assert_eq!(hit.banner, "SSH-2.0-OpenSSH_9.9");
|
||||
let web = server(b"HTTP/1.1 400 Bad Request\r\n");
|
||||
assert!(probe(Ipv4Addr::LOCALHOST, web).await.is_none());
|
||||
// Nothing listening: refused quickly, no hit
|
||||
let closed = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
assert!(probe(Ipv4Addr::LOCALHOST, closed).await.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_targets_are_this_pc() {
|
||||
let local = local_ips();
|
||||
assert_eq!(is_this_pc("127.0.0.1:9000", None, &local), Some(true));
|
||||
assert_eq!(is_this_pc("[::ffff:127.0.0.1]:9000", None, &local), Some(true));
|
||||
assert_eq!(is_this_pc("203.0.113.9:9000", None, &local), Some(false));
|
||||
assert_eq!(is_this_pc("203.0.113.9:9000", Some("203.0.113.9".parse().unwrap()), &local), Some(true));
|
||||
assert_eq!(is_this_pc("not an address", None, &local), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Every command the helper runs on the headset, in one place so they are easy to review.
|
||||
//! They only touch the user's own home folder and user services (no sudo), and only through
|
||||
//! frameeyeosc's own install.sh.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::bundle;
|
||||
|
||||
/// Where the release is unpacked while installing (removed afterwards).
|
||||
const STAGE: &str = r#"d="$HOME/.cache/frameeye-helper""#;
|
||||
/// `systemctl --user` needs the user's runtime dir, which some SSH setups don't export.
|
||||
const RUNTIME: &str = r#"export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}""#;
|
||||
|
||||
pub fn info() -> String {
|
||||
format!(
|
||||
r#"{RUNTIME}
|
||||
b="$HOME/.local/bin"
|
||||
echo "arch=$(uname -m)"
|
||||
echo "user=$(id -un)"
|
||||
if [ -e /dev/shm/eye-server.mmap ]; then echo "eyeserver=yes"; else echo "eyeserver=no"; fi
|
||||
if [ -x "$b/frameeyeosc" ]; then echo "daemon=yes"; echo "daemon_sha=$(sha256sum "$b/frameeyeosc" 2>/dev/null | cut -d' ' -f1)"; else echo "daemon=no"; fi
|
||||
if [ -x "$b/frameeyeosc-panel" ]; then echo "panel=yes"; echo "panel_version=$("$b/frameeyeosc-panel" --version 2>/dev/null | head -n 1)"; else echo "panel=no"; fi
|
||||
echo "active=$(systemctl --user is-active frameeyeosc.service 2>/dev/null)"
|
||||
echo "enabled=$(systemctl --user is-enabled frameeyeosc.service 2>/dev/null)"
|
||||
echo "panel_active=$(systemctl --user is-active frameeyeosc-panel.service 2>/dev/null)"
|
||||
echo "steamvr=$(systemctl --user is-active steamvr.service 2>/dev/null)"
|
||||
if [ -r /etc/os-release ]; then . /etc/os-release; echo "os=${{PRETTY_NAME:-${{NAME:-}}}} ${{VERSION_ID:-}}"; fi
|
||||
echo "ssh=${{SSH_CONNECTION:-}}"
|
||||
missing=""; for tool in tar sha256sum systemctl; do command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"; done
|
||||
echo "missing=$missing"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads the release from stdin into the staging folder and prints its SHA-256.
|
||||
pub fn upload() -> String {
|
||||
format!(
|
||||
r#"set -e
|
||||
{STAGE}
|
||||
mkdir -p "$d"
|
||||
cat > "$d/package.tar.gz.part"
|
||||
mv -f "$d/package.tar.gz.part" "$d/package.tar.gz"
|
||||
sha256sum "$d/package.tar.gz" | cut -d' ' -f1
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Unpacks the uploaded release and runs its install.sh; with the panel, also starts it right away
|
||||
/// (install.sh would otherwise only start it with the next SteamVR start).
|
||||
pub fn install(with_panel: bool) -> String {
|
||||
let (args, start_panel) = if with_panel {
|
||||
("--with-panel", "systemctl --user start frameeyeosc-panel.service >/dev/null 2>&1 || true")
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
format!(
|
||||
r#"set -e
|
||||
{RUNTIME}
|
||||
{STAGE}
|
||||
rm -rf "$d/frameeyeosc"
|
||||
tar xzf "$d/package.tar.gz" -C "$d"
|
||||
cd "$d/frameeyeosc"
|
||||
./install.sh {args}
|
||||
{start_panel}
|
||||
cd "$HOME"
|
||||
rm -rf "$d"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Removes frameeyeosc and the panel with the release's own install.sh (settings stay unless `purge`).
|
||||
pub fn uninstall(purge: bool) -> String {
|
||||
let purge = if purge { " --purge" } else { "" };
|
||||
format!(
|
||||
r#"set -e
|
||||
{RUNTIME}
|
||||
{STAGE}
|
||||
rm -rf "$d/frameeyeosc"
|
||||
tar xzf "$d/package.tar.gz" -C "$d"
|
||||
cd "$d/frameeyeosc"
|
||||
./install.sh --uninstall{purge}
|
||||
cd "$HOME"
|
||||
rm -rf "$d"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Prints the service state and status.json five times a second until the channel closes. Each
|
||||
/// frame ends with a `--end--` line.
|
||||
pub fn status_stream() -> String {
|
||||
format!(
|
||||
r#"{RUNTIME}
|
||||
f="$XDG_RUNTIME_DIR/frameeyeosc/status.json"
|
||||
i=0
|
||||
while :; do
|
||||
if [ $((i % 5)) -eq 0 ]; then
|
||||
echo "active=$(systemctl --user is-active frameeyeosc.service 2>/dev/null)"
|
||||
echo "panel_active=$(systemctl --user is-active frameeyeosc-panel.service 2>/dev/null)"
|
||||
echo "steamvr=$(systemctl --user is-active steamvr.service 2>/dev/null)"
|
||||
if [ -e /dev/shm/eye-server.mmap ]; then echo "eyeserver=yes"; else echo "eyeserver=no"; fi
|
||||
fi
|
||||
echo "now=$(date +%s.%N)"
|
||||
if [ -r "$f" ]; then printf 'status='; tr -d '\n' < "$f"; echo; else echo "status="; fi
|
||||
echo "--end--"
|
||||
i=$((i + 1))
|
||||
sleep 0.2
|
||||
done
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
pub fn logs() -> String {
|
||||
format!(
|
||||
r#"{RUNTIME}
|
||||
echo "== frameeyeosc (newest last) =="
|
||||
journalctl --user -u frameeyeosc.service -n 60 --no-pager -o short 2>&1 || true
|
||||
echo
|
||||
echo "== frameeyeosc-panel =="
|
||||
journalctl --user -u frameeyeosc-panel.service -n 25 --no-pager -o short 2>&1 || true
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
pub fn restart_service() -> String {
|
||||
format!(
|
||||
r#"{RUNTIME}
|
||||
systemctl --user enable frameeyeosc.service 2>&1
|
||||
systemctl --user restart frameeyeosc.service 2>&1
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Starts the panel service if needed, then asks the running panel to open itself on the dashboard
|
||||
/// (a second launch signals the resident one and exits at once).
|
||||
pub fn show_panel() -> String {
|
||||
format!(
|
||||
r#"{RUNTIME}
|
||||
systemctl --user start frameeyeosc-panel.service 2>&1 || true
|
||||
sleep 2
|
||||
"$HOME/.local/bin/frameeyeosc-panel" 2>&1 | tail -n 1
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
pub fn read_config() -> String {
|
||||
r#"cat "${XDG_CONFIG_HOME:-$HOME/.config}/frameeyeosc/config.json" 2>/dev/null || true"#.to_string()
|
||||
}
|
||||
|
||||
/// Replaces config.json with stdin (atomically, like the panel does). frameeyeosc picks it up within 0.1 s.
|
||||
pub fn write_config() -> String {
|
||||
r#"set -e
|
||||
c="${XDG_CONFIG_HOME:-$HOME/.config}/frameeyeosc"
|
||||
mkdir -p "$c"
|
||||
cat > "$c/config.json.tmp"
|
||||
mv -f "$c/config.json.tmp" "$c/config.json"
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// What `info()` found on the headset.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct HeadsetInfo {
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub arch: String,
|
||||
pub eye_server: bool,
|
||||
pub daemon_installed: bool,
|
||||
pub daemon_sha: String,
|
||||
pub panel_installed: bool,
|
||||
/// "0.5.3", from `frameeyeosc-panel --version`.
|
||||
pub panel_version: Option<String>,
|
||||
pub service_active: String,
|
||||
pub service_enabled: String,
|
||||
pub panel_active: String,
|
||||
pub steamvr: String,
|
||||
pub os: String,
|
||||
/// This PC's address as the headset sees it (from SSH_CONNECTION).
|
||||
pub client_ip: Option<IpAddr>,
|
||||
pub missing_tools: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Installed {
|
||||
No,
|
||||
Version(String),
|
||||
/// Installed, but the version can't be told (a daemon-only install of another version).
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl HeadsetInfo {
|
||||
pub fn parse(text: &str, host: &str, user: &str) -> Self {
|
||||
let mut info = HeadsetInfo {
|
||||
host: host.to_string(),
|
||||
user: user.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once('=') else { continue };
|
||||
let value = value.trim();
|
||||
match key.trim() {
|
||||
"arch" => info.arch = value.into(),
|
||||
"eyeserver" => info.eye_server = value == "yes",
|
||||
"daemon" => info.daemon_installed = value == "yes",
|
||||
"daemon_sha" => info.daemon_sha = value.into(),
|
||||
"panel" => info.panel_installed = value == "yes",
|
||||
"panel_version" => {
|
||||
info.panel_version = value
|
||||
.split_whitespace()
|
||||
.last()
|
||||
.filter(|version| version.chars().next().is_some_and(|c| c.is_ascii_digit()))
|
||||
.map(str::to_string)
|
||||
}
|
||||
"active" => info.service_active = value.into(),
|
||||
"enabled" => info.service_enabled = value.into(),
|
||||
"panel_active" => info.panel_active = value.into(),
|
||||
"steamvr" => info.steamvr = value.into(),
|
||||
"os" => info.os = value.into(),
|
||||
"ssh" => info.client_ip = value.split_whitespace().next().and_then(|ip| ip.parse().ok()),
|
||||
"missing" => info.missing_tools = value.into(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
pub fn is_frame(&self) -> bool {
|
||||
self.arch == "aarch64"
|
||||
}
|
||||
|
||||
pub fn installed(&self) -> Installed {
|
||||
if !self.daemon_installed {
|
||||
Installed::No
|
||||
} else if let Some(version) = &self.panel_version {
|
||||
Installed::Version(version.clone())
|
||||
} else if self.daemon_sha == bundle::DAEMON_SHA256 {
|
||||
Installed::Version(bundle::VERSION.into())
|
||||
} else {
|
||||
Installed::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn info_output_is_parsed() {
|
||||
let text = "arch=aarch64\nuser=steamos\neyeserver=yes\ndaemon=yes\ndaemon_sha=abc\npanel=yes\n\
|
||||
panel_version=frameeyeosc-panel 0.5.3\nactive=active\nenabled=enabled\npanel_active=active\n\
|
||||
steamvr=active\nos=SteamOS 3.8\nssh=192.168.1.20 51234 192.168.1.42 22\nmissing=\n";
|
||||
let info = HeadsetInfo::parse(text, "192.168.1.42", "steamos");
|
||||
assert!(info.is_frame());
|
||||
assert!(info.eye_server);
|
||||
assert_eq!(info.installed(), Installed::Version("0.5.3".into()));
|
||||
assert_eq!(info.client_ip, Some("192.168.1.20".parse().unwrap()));
|
||||
assert_eq!(info.os, "SteamOS 3.8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_only_installs_are_recognised_by_hash() {
|
||||
let text = format!("arch=aarch64\ndaemon=yes\ndaemon_sha={}\npanel=no\n", bundle::DAEMON_SHA256);
|
||||
let info = HeadsetInfo::parse(&text, "h", "u");
|
||||
assert_eq!(info.installed(), Installed::Version(bundle::VERSION.into()));
|
||||
let other = HeadsetInfo::parse("daemon=yes\ndaemon_sha=ffff\npanel=no\n", "h", "u");
|
||||
assert_eq!(other.installed(), Installed::Unknown);
|
||||
assert_eq!(HeadsetInfo::parse("daemon=no\n", "h", "u").installed(), Installed::No);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_plain_posix_sh() {
|
||||
// Braces meant for the shell must survive format!().
|
||||
assert!(info().contains("${PRETTY_NAME:-${NAME:-}}"));
|
||||
assert!(install(true).contains("./install.sh --with-panel"));
|
||||
assert!(!install(false).contains("--with-panel"));
|
||||
assert!(uninstall(true).contains("--uninstall --purge"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! `--selftest HOST PORT USER PASSWORD` (only with the "selftest" feature): drives the real worker, i.e.
|
||||
//! the same SSH code the buttons use, through the whole flow against a test headset (tests/fakeframe),
|
||||
//! checking each step. Also listens on UDP 9011 to see the eye data actually arrive. (Never VRChat's
|
||||
//! 9000: a VRChat running on the test PC would get the made-up eye data.)
|
||||
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::remote::Installed;
|
||||
use crate::status::Health;
|
||||
use crate::worker::{Cmd, Event, Problem, TaskKind, Worker};
|
||||
|
||||
const TEST_PORT: u16 = 9011;
|
||||
|
||||
struct Run {
|
||||
worker: Worker,
|
||||
failures: usize,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// Wait for an event `want` accepts (logging the others), up to `seconds`.
|
||||
fn expect(&mut self, what: &str, seconds: u64, mut want: impl FnMut(&Event) -> bool) -> Option<Event> {
|
||||
let deadline = Instant::now() + Duration::from_secs(seconds);
|
||||
while Instant::now() < deadline {
|
||||
for event in self.worker.poll() {
|
||||
if want(&event) {
|
||||
println!(" PASS {what}");
|
||||
return Some(event);
|
||||
}
|
||||
match &event {
|
||||
Event::TaskLine(line) => println!(" | {line}"),
|
||||
Event::ConnectFailed { problem, detail, .. } => println!(" (connect failed: {problem:?}: {detail})"),
|
||||
Event::TaskDone { ok, message, .. } => println!(" (task done ok={ok}: {message})"),
|
||||
Event::ActionDone { label, ok, message } => println!(" ({label} ok={ok}: {message})"),
|
||||
Event::Disconnected { reason } => println!(" (disconnected: {reason})"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
println!(" FAIL {what} (nothing within {seconds} s)");
|
||||
self.failures += 1;
|
||||
None
|
||||
}
|
||||
|
||||
fn check(&mut self, what: &str, ok: bool) {
|
||||
println!(" {} {what}", if ok { "PASS" } else { "FAIL" });
|
||||
if !ok {
|
||||
self.failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> i32 {
|
||||
let [host, port, user, password] = args else {
|
||||
eprintln!("usage: FrameEyeHelper --selftest HOST PORT USER PASSWORD");
|
||||
return 2;
|
||||
};
|
||||
let port: u16 = port.parse().unwrap_or(22);
|
||||
let connect = |password: &str, known: Option<String>| Cmd::Connect {
|
||||
host: host.clone(),
|
||||
port,
|
||||
user: user.clone(),
|
||||
password: password.to_string(),
|
||||
known_fingerprint: known,
|
||||
};
|
||||
// Count OSC packets that reach this PC, on a test port instead of VRChat's 9000
|
||||
let received = Arc::new(AtomicUsize::new(0));
|
||||
match UdpSocket::bind(format!("0.0.0.0:{TEST_PORT}")) {
|
||||
Ok(socket) => {
|
||||
let received = received.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut buffer = [0u8; 1024];
|
||||
while let Ok(n) = socket.recv(&mut buffer) {
|
||||
if buffer[..n].starts_with(b"/avatar/parameters/") {
|
||||
received.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(error) => println!(" (UDP {TEST_PORT} is busy, so OSC arrival isn't checked: {error})"),
|
||||
}
|
||||
|
||||
let mut run = Run {
|
||||
worker: Worker::start(egui::Context::default()),
|
||||
failures: 0,
|
||||
};
|
||||
|
||||
println!("1. Wrong password is reported as such");
|
||||
run.worker.send(connect("not-the-password", None));
|
||||
run.expect("WrongPassword", 20, |e| matches!(e, Event::ConnectFailed { problem: Problem::WrongPassword, .. }));
|
||||
|
||||
println!("2. A changed host key is caught");
|
||||
run.worker.send(connect(password, Some("SHA256:somethingElse".into())));
|
||||
run.expect("HostKeyChanged", 20, |e| {
|
||||
matches!(e, Event::ConnectFailed { problem: Problem::HostKeyChanged, fingerprint: Some(_), .. })
|
||||
});
|
||||
|
||||
println!("3. Connect and read the headset");
|
||||
run.worker.send(connect(password, None));
|
||||
let Some(Event::Connected { info, fingerprint, .. }) = run.expect("Connected", 30, |e| matches!(e, Event::Connected { .. })) else {
|
||||
println!("cannot continue without a connection");
|
||||
return 1;
|
||||
};
|
||||
println!(" fingerprint {fingerprint}, arch {}, os {}, this PC as seen by the headset: {:?}", info.arch, info.os, info.client_ip);
|
||||
run.check("looks like a Steam Frame (aarch64)", info.is_frame());
|
||||
run.check("eye tracker present", info.eye_server);
|
||||
let fresh = info.installed() == Installed::No;
|
||||
println!(" installed before: {:?}", info.installed());
|
||||
|
||||
println!("4. Install with the panel");
|
||||
run.worker.send(Cmd::Install { with_panel: true });
|
||||
run.expect("install finished OK", 120, |e| matches!(e, Event::TaskDone { kind: TaskKind::Install, ok: true, .. }));
|
||||
if let Some(Event::Info(info)) = run.expect("fresh headset info", 30, |e| matches!(e, Event::Info(_))) {
|
||||
run.check(&format!("installed version is 0.5.3 ({:?})", info.installed()), info.installed() == Installed::Version("0.5.3".into()));
|
||||
run.check("service active", info.service_active == "active");
|
||||
}
|
||||
|
||||
println!("5. Live status");
|
||||
run.worker.send(Cmd::StartStatus);
|
||||
let mut last = None;
|
||||
run.expect("status with a running frameeyeosc and eye data", 20, |e| match e {
|
||||
Event::Status(live) if live.health() != Health::Unknown && live.health() != Health::NotRunning => {
|
||||
last = Some(live.clone());
|
||||
live.fresh_status().is_some_and(|status| status.tracking)
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if let Some(live) = &last {
|
||||
let status = live.fresh_status();
|
||||
println!(
|
||||
" health {:?}, target {:?}, rate {:?}, tracker {:?}",
|
||||
live.health(),
|
||||
status.and_then(|s| s.target.clone()),
|
||||
status.map(|s| s.rate),
|
||||
status.and_then(|s| s.tracker_rate)
|
||||
);
|
||||
}
|
||||
|
||||
println!("6. Send to this PC");
|
||||
// A real headset reaches the PC at the address it sees the SSH login come from. Rootless podman's
|
||||
// default networking gives the test container the PC's own address, so the test may name another.
|
||||
let this_pc = std::env::var("SELFTEST_PC_ADDRESS")
|
||||
.ok()
|
||||
.or_else(|| info.client_ip.map(|ip| ip.to_string()))
|
||||
.unwrap_or_else(|| "127.0.0.1".into());
|
||||
run.worker.send(Cmd::SetConfig {
|
||||
changes: vec![
|
||||
("host".into(), serde_json::Value::String(this_pc.clone())),
|
||||
("port".into(), serde_json::Value::from(TEST_PORT)),
|
||||
],
|
||||
label: "host".into(),
|
||||
});
|
||||
run.expect("config written", 20, |e| matches!(e, Event::ActionDone { ok: true, .. }));
|
||||
let expected = format!("{this_pc}:{TEST_PORT}");
|
||||
run.expect(&format!("status now sends to {expected}"), 20, |e| match e {
|
||||
Event::Status(live) => live.health() == Health::Sending && live.fresh_status().and_then(|s| s.target.as_deref()) == Some(expected.as_str()),
|
||||
_ => false,
|
||||
});
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
let packets = received.load(Ordering::Relaxed);
|
||||
println!(" OSC packets that reached this PC's UDP {TEST_PORT}: {packets}");
|
||||
run.check("eye data arrived at this PC", packets > 100);
|
||||
|
||||
println!("7. Pause and resume");
|
||||
run.worker.send(Cmd::SetConfig { changes: vec![("sending".into(), false.into())], label: "pause".into() });
|
||||
run.expect("paused", 20, |e| matches!(e, Event::Status(live) if live.health() == Health::Paused));
|
||||
run.worker.send(Cmd::SetConfig { changes: vec![("sending".into(), true.into())], label: "resume".into() });
|
||||
run.expect("sending again", 20, |e| matches!(e, Event::Status(live) if live.health() == Health::Sending));
|
||||
run.worker.send(Cmd::StopStatus);
|
||||
|
||||
println!("8. Logs and restart");
|
||||
run.worker.send(Cmd::FetchLogs);
|
||||
if let Some(Event::Logs(text)) = run.expect("logs", 20, |e| matches!(e, Event::Logs(_))) {
|
||||
run.check("log mentions sending", text.contains("Sending OSC to"));
|
||||
}
|
||||
run.worker.send(Cmd::RestartService);
|
||||
run.expect("restart OK", 30, |e| matches!(e, Event::ActionDone { ok: true, .. }));
|
||||
|
||||
println!("9. Uninstall (with --purge)");
|
||||
run.worker.send(Cmd::Uninstall { purge: true });
|
||||
run.expect("uninstall finished OK", 120, |e| matches!(e, Event::TaskDone { kind: TaskKind::Uninstall, ok: true, .. }));
|
||||
if let Some(Event::Info(info)) = run.expect("headset info after", 30, |e| matches!(e, Event::Info(_))) {
|
||||
run.check("nothing installed any more", info.installed() == Installed::No);
|
||||
}
|
||||
run.worker.send(Cmd::Disconnect);
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"{} (fresh headset: {fresh}, OSC packets received: {packets})",
|
||||
if run.failures == 0 { "ALL PASSED".to_string() } else { format!("{} FAILED", run.failures) }
|
||||
);
|
||||
i32::from(run.failures > 0)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! What the helper remembers between runs. The password is never stored.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
pub host: String,
|
||||
pub user: String,
|
||||
pub port: u16,
|
||||
pub with_panel: bool,
|
||||
/// "host:port" -> SHA256 fingerprint of the headset's SSH host key (trusted on first use).
|
||||
pub known_hosts: BTreeMap<String, String>,
|
||||
pub check_steamlink_osc_off: bool,
|
||||
pub check_vrchat_osc_on: bool,
|
||||
pub check_avatar: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: String::new(),
|
||||
user: "steamos".into(),
|
||||
port: 22,
|
||||
with_panel: true,
|
||||
known_hosts: BTreeMap::new(),
|
||||
check_steamlink_osc_off: false,
|
||||
check_vrchat_osc_on: false,
|
||||
check_avatar: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn path() -> Option<PathBuf> {
|
||||
let dirs = directories::ProjectDirs::from("", "", "FrameEyeHelper")?;
|
||||
Some(dirs.config_dir().join("settings.json"))
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn load() -> Self {
|
||||
path()
|
||||
.and_then(|path| std::fs::read(path).ok())
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Best effort: a read-only profile just means nothing is remembered.
|
||||
pub fn save(&self) {
|
||||
let Some(path) = path() else { return };
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
if let Ok(json) = serde_json::to_vec_pretty(self) {
|
||||
let temporary = path.with_extension("json.tmp");
|
||||
if std::fs::write(&temporary, json).is_ok() {
|
||||
let _ = std::fs::rename(temporary, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn host_key_id(host: &str, port: u16) -> String {
|
||||
format!("{}:{}", host.trim(), port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! frameeyeosc's status.json (written ten times a second on the headset) and what the helper makes of it.
|
||||
//! Only the fields the helper shows are read; everything is optional so newer versions still parse.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct StatusJson {
|
||||
pub pid: u32,
|
||||
/// Unix seconds when the file was written (headset clock).
|
||||
pub time: f64,
|
||||
pub started: f64,
|
||||
pub sending: bool,
|
||||
/// "vrchat" or "etvr"
|
||||
pub output: String,
|
||||
/// "auto" or "fixed"
|
||||
pub target_mode: String,
|
||||
/// "192.168.1.20:9000" once a destination is known.
|
||||
pub target: Option<String>,
|
||||
/// OSC samples sent in the last second.
|
||||
pub rate: f32,
|
||||
/// Samples the eye tracker delivered in the last second.
|
||||
pub tracker_rate: Option<f32>,
|
||||
pub tracking: bool,
|
||||
pub raw: Option<RawValues>,
|
||||
pub sent: Option<SentValues>,
|
||||
pub config_error: Option<String>,
|
||||
pub effective: Option<Effective>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RawValues {
|
||||
pub openness: [f32; 2],
|
||||
pub gaze: [f32; 2],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SentValues {
|
||||
/// Eyelids on the VRCFT scale: 0 closed, 0.75 relaxed, 1 widened.
|
||||
pub lids_vrcft: [f32; 2],
|
||||
pub gaze: [f32; 2],
|
||||
pub gaze_left: [f32; 2],
|
||||
pub gaze_right: [f32; 2],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Effective {
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
pub prefix: String,
|
||||
pub independent_eyes: bool,
|
||||
}
|
||||
|
||||
/// One reading of the headset's state, as the status stream reports it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LiveStatus {
|
||||
/// `systemctl --user is-active` of frameeyeosc: "active", "inactive", "failed", "activating", ...
|
||||
pub service: String,
|
||||
pub panel_service: String,
|
||||
pub steamvr: String,
|
||||
pub eye_server: bool,
|
||||
/// The headset's clock when this was read, to judge how fresh status.json is.
|
||||
pub headset_now: f64,
|
||||
pub status: Option<StatusJson>,
|
||||
}
|
||||
|
||||
/// What the "Check it works" page shows as the headline.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Health {
|
||||
/// Nothing heard from the headset yet.
|
||||
Unknown,
|
||||
/// The service isn't running (or its status file is stale).
|
||||
NotRunning,
|
||||
/// Running, but eye tracking isn't delivering (headset not worn, SteamVR not running).
|
||||
NoEyeData,
|
||||
/// Tracking, but auto mode hasn't found the Steam Link PC yet.
|
||||
NoTarget,
|
||||
/// Sending was switched off in the panel.
|
||||
Paused,
|
||||
/// Sending eye data.
|
||||
Sending,
|
||||
}
|
||||
|
||||
impl LiveStatus {
|
||||
/// The status file counts as current if it was written in the last 3 seconds by a running service.
|
||||
pub fn fresh_status(&self) -> Option<&StatusJson> {
|
||||
let status = self.status.as_ref()?;
|
||||
let age = self.headset_now - status.time;
|
||||
(self.service == "active" && (-2.0..3.0).contains(&age)).then_some(status)
|
||||
}
|
||||
|
||||
pub fn health(&self) -> Health {
|
||||
if self.service.is_empty() {
|
||||
return Health::Unknown;
|
||||
}
|
||||
let Some(status) = self.fresh_status() else {
|
||||
return Health::NotRunning;
|
||||
};
|
||||
if !status.sending {
|
||||
Health::Paused
|
||||
} else if !status.tracking {
|
||||
Health::NoEyeData
|
||||
} else if status.target.is_none() {
|
||||
Health::NoTarget
|
||||
} else {
|
||||
Health::Sending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse one frame of the status stream: `key=value` lines, the status JSON after `status=`.
|
||||
pub fn parse_frame(lines: &[String], previous: &LiveStatus) -> LiveStatus {
|
||||
let mut live = previous.clone();
|
||||
for line in lines {
|
||||
let Some((key, value)) = line.split_once('=') else { continue };
|
||||
match key {
|
||||
"active" => live.service = value.trim().to_string(),
|
||||
"panel_active" => live.panel_service = value.trim().to_string(),
|
||||
"steamvr" => live.steamvr = value.trim().to_string(),
|
||||
"eyeserver" => live.eye_server = value.trim() == "yes",
|
||||
"now" => live.headset_now = value.trim().parse().unwrap_or(live.headset_now),
|
||||
"status" => {
|
||||
live.status = if value.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
// A half-read file is simply skipped; the next frame comes 0.2 s later.
|
||||
serde_json::from_str(value).ok().or(live.status)
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
live
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"{"version":1,"pid":4242,"time":1000.5,"started":900.0,"sending":true,"output":"vrchat","target_mode":"auto","target":"192.168.1.20:9000","rate":90.0,"tracker_rate":120.0,"tracking":true,"raw":{"openness":[0.8,0.79],"openness_scaled":[0.8,0.8],"gaze":[0.1,-0.05],"gaze_left":[0.1,-0.05],"gaze_right":[0.1,-0.05]},"sent":{"lids":[0.75,0.75],"lids_vrcft":[0.75,0.74],"gaze":[0.09,-0.04],"gaze_left":[0.09,-0.04],"gaze_right":[0.09,-0.04]},"calibration":{"enabled":true,"relaxed":[0.8,0.8],"scales":[1.0,1.0],"fitted":[false,false],"learning":false},"config_path":"/home/steamos/.config/frameeyeosc/config.json","calibration_path":null,"config_error":null,"locked":[],"effective":{"sending":true,"output":"vrchat","host":"auto","port":null,"prefix":"/FT","independent_eyes":false},"gaze_capture":null}"#;
|
||||
|
||||
fn frame(status: &str) -> Vec<String> {
|
||||
vec![
|
||||
"active=active".into(),
|
||||
"panel_active=inactive".into(),
|
||||
"eyeserver=yes".into(),
|
||||
"now=1001.0".into(),
|
||||
format!("status={status}"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_sender_is_healthy() {
|
||||
let live = parse_frame(&frame(SAMPLE), &LiveStatus::default());
|
||||
assert_eq!(live.health(), Health::Sending);
|
||||
let status = live.fresh_status().unwrap();
|
||||
assert_eq!(status.target.as_deref(), Some("192.168.1.20:9000"));
|
||||
assert_eq!(status.sent.as_ref().unwrap().lids_vrcft, [0.75, 0.74]);
|
||||
assert_eq!(status.effective.as_ref().unwrap().prefix, "/FT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_status_file_means_not_running() {
|
||||
let mut lines = frame(SAMPLE);
|
||||
lines[3] = "now=1010.0".into();
|
||||
assert_eq!(parse_frame(&lines, &LiveStatus::default()).health(), Health::NotRunning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_follow_the_flags() {
|
||||
let paused = SAMPLE.replace(r#""sending":true,"output""#, r#""sending":false,"output""#);
|
||||
assert_eq!(parse_frame(&frame(&paused), &LiveStatus::default()).health(), Health::Paused);
|
||||
let untracked = SAMPLE.replace(r#""tracking":true"#, r#""tracking":false"#);
|
||||
assert_eq!(parse_frame(&frame(&untracked), &LiveStatus::default()).health(), Health::NoEyeData);
|
||||
let no_target = SAMPLE.replace(r#""target":"192.168.1.20:9000""#, r#""target":null"#);
|
||||
assert_eq!(parse_frame(&frame(&no_target), &LiveStatus::default()).health(), Health::NoTarget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_line_keeps_the_last_good_status() {
|
||||
let good = parse_frame(&frame(SAMPLE), &LiveStatus::default());
|
||||
let broken = parse_frame(&frame("{\"pid\":"), &good);
|
||||
assert_eq!(broken.status.unwrap().pid, 4242);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
//! A background thread that talks to the headset over SSH (or pretends to, in demo mode), so the
|
||||
//! window never freezes. The UI sends `Cmd`s and gets `Event`s back.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
use russh::client::{self, Handle, KeyboardInteractiveAuthResponse};
|
||||
use russh::keys::{HashAlg, PublicKeyOrCertificate};
|
||||
use russh::{ChannelMsg, Disconnect, MethodKind};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc as tmpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::bundle;
|
||||
use crate::net::{self, ScanHit};
|
||||
use crate::remote::{self, HeadsetInfo};
|
||||
use crate::status::{self, LiveStatus, StatusJson};
|
||||
|
||||
pub enum Cmd {
|
||||
Connect { host: String, port: u16, user: String, password: String, known_fingerprint: Option<String> },
|
||||
ConnectDemo,
|
||||
Disconnect,
|
||||
RefreshInfo,
|
||||
Install { with_panel: bool },
|
||||
Uninstall { purge: bool },
|
||||
StartStatus,
|
||||
StopStatus,
|
||||
FetchLogs,
|
||||
RestartService,
|
||||
ShowPanel,
|
||||
/// Change keys in frameeyeosc's config.json (kept: every other key).
|
||||
SetConfig { changes: Vec<(String, serde_json::Value)>, label: String },
|
||||
Scan { port: u16 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Problem {
|
||||
BadAddress,
|
||||
Unreachable,
|
||||
Refused,
|
||||
Timeout,
|
||||
Handshake,
|
||||
HostKeyChanged,
|
||||
WrongPassword,
|
||||
NoPasswordLogin,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TaskKind {
|
||||
Install,
|
||||
Uninstall,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum TaskStep {
|
||||
Uploading,
|
||||
Verifying,
|
||||
Running,
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
Connected { info: HeadsetInfo, fingerprint: String, demo: bool },
|
||||
ConnectFailed { problem: Problem, detail: String, fingerprint: Option<String> },
|
||||
Info(HeadsetInfo),
|
||||
TaskStep(TaskStep),
|
||||
TaskLine(String),
|
||||
UploadProgress(f32),
|
||||
TaskDone { kind: TaskKind, ok: bool, message: String },
|
||||
Status(LiveStatus),
|
||||
StatusProblem(String),
|
||||
Logs(String),
|
||||
ActionDone { label: String, ok: bool, message: String },
|
||||
ScanProgress(f32),
|
||||
ScanDone { hits: Vec<ScanHit>, networks: usize },
|
||||
Disconnected { reason: String },
|
||||
}
|
||||
|
||||
pub struct Worker {
|
||||
commands: tmpsc::UnboundedSender<Cmd>,
|
||||
events: mpsc::Receiver<Event>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub fn start(ctx: egui::Context) -> Self {
|
||||
let (commands, command_rx) = tmpsc::unbounded_channel();
|
||||
let (event_tx, events) = mpsc::channel();
|
||||
let out = Emitter { tx: event_tx, ctx };
|
||||
std::thread::Builder::new()
|
||||
.name("headset".into())
|
||||
.spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("could not start the async runtime");
|
||||
runtime.block_on(run(command_rx, out));
|
||||
})
|
||||
.expect("could not start the worker thread");
|
||||
Self { commands, events }
|
||||
}
|
||||
|
||||
pub fn send(&self, cmd: Cmd) {
|
||||
let _ = self.commands.send(cmd);
|
||||
}
|
||||
|
||||
pub fn poll(&self) -> Vec<Event> {
|
||||
self.events.try_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Emitter {
|
||||
tx: mpsc::Sender<Event>,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
impl Emitter {
|
||||
fn emit(&self, event: Event) {
|
||||
let _ = self.tx.send(event);
|
||||
self.ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// SSH
|
||||
|
||||
/// Remembers the host key it was shown, and accepts it if it is the one trusted before (or if
|
||||
/// there is none yet: trust on first use, like `ssh -o StrictHostKeyChecking=accept-new`).
|
||||
struct Client {
|
||||
expected: Option<String>,
|
||||
seen: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl client::Handler for Client {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
|
||||
let fingerprint = match key {
|
||||
PublicKeyOrCertificate::PublicKey { key, .. } => key.fingerprint(HashAlg::Sha256).to_string(),
|
||||
PublicKeyOrCertificate::Certificate(certificate) => {
|
||||
certificate.public_key().fingerprint(HashAlg::Sha256).to_string()
|
||||
}
|
||||
};
|
||||
let accepted = self.expected.as_ref().is_none_or(|expected| *expected == fingerprint);
|
||||
*self.seen.lock().unwrap() = Some(fingerprint);
|
||||
Ok(accepted)
|
||||
}
|
||||
}
|
||||
|
||||
struct Conn {
|
||||
handle: Arc<Handle<Client>>,
|
||||
host: String,
|
||||
user: String,
|
||||
}
|
||||
|
||||
struct Output {
|
||||
stdout: String,
|
||||
code: Option<u32>,
|
||||
}
|
||||
|
||||
/// Run one command. `stdin` is sent first (with `on_upload` progress); each stdout line goes to
|
||||
/// `on_line` as it arrives.
|
||||
async fn exec(
|
||||
handle: &Handle<Client>,
|
||||
command: &str,
|
||||
stdin: Option<&[u8]>,
|
||||
mut on_upload: impl FnMut(f32),
|
||||
mut on_line: impl FnMut(&str),
|
||||
) -> Result<Output, russh::Error> {
|
||||
let mut channel = handle.channel_open_session().await?;
|
||||
channel.exec(true, command).await?;
|
||||
if let Some(bytes) = stdin {
|
||||
let mut sent = 0usize;
|
||||
for chunk in bytes.chunks(64 * 1024) {
|
||||
channel.data(chunk).await?;
|
||||
sent += chunk.len();
|
||||
on_upload(sent as f32 / bytes.len().max(1) as f32);
|
||||
}
|
||||
}
|
||||
channel.eof().await?;
|
||||
let mut stdout = Vec::new();
|
||||
let mut line_start = 0usize;
|
||||
let mut code = None;
|
||||
while let Some(message) = channel.wait().await {
|
||||
match message {
|
||||
ChannelMsg::Data { data } => {
|
||||
stdout.extend_from_slice(&data);
|
||||
while let Some(end) = stdout[line_start..].iter().position(|byte| *byte == b'\n') {
|
||||
on_line(String::from_utf8_lossy(&stdout[line_start..line_start + end]).trim_end());
|
||||
line_start += end + 1;
|
||||
}
|
||||
}
|
||||
// stderr: the scripts send everything that matters to stdout
|
||||
ChannelMsg::ExtendedData { .. } => {}
|
||||
ChannelMsg::ExitStatus { exit_status } => code = Some(exit_status),
|
||||
ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if line_start < stdout.len() {
|
||||
on_line(String::from_utf8_lossy(&stdout[line_start..]).trim_end());
|
||||
}
|
||||
Ok(Output {
|
||||
stdout: String::from_utf8_lossy(&stdout).into_owned(),
|
||||
code,
|
||||
})
|
||||
}
|
||||
|
||||
async fn exec_simple(handle: &Handle<Client>, command: &str, limit: Duration) -> Result<Output, String> {
|
||||
match timeout(limit, exec(handle, command, None, |_| {}, |_| {})).await {
|
||||
Ok(Ok(output)) => Ok(output),
|
||||
Ok(Err(error)) => Err(error.to_string()),
|
||||
Err(_) => Err(format!("the headset didn't answer within {} seconds", limit.as_secs())),
|
||||
}
|
||||
}
|
||||
|
||||
fn io_problem(error: &std::io::Error, addr: SocketAddr) -> (Problem, String) {
|
||||
use std::io::ErrorKind::*;
|
||||
match error.kind() {
|
||||
ConnectionRefused => (Problem::Refused, format!("{addr} refused the connection ({error}).")),
|
||||
TimedOut => (Problem::Timeout, format!("No answer from {addr} ({error}).")),
|
||||
HostUnreachable | NetworkUnreachable | AddrNotAvailable => {
|
||||
(Problem::Unreachable, format!("{addr} can't be reached ({error})."))
|
||||
}
|
||||
_ => (Problem::Other, format!("Connecting to {addr} failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectError = (Problem, String, Option<String>);
|
||||
|
||||
async fn connect(
|
||||
host: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
password: &str,
|
||||
known: Option<String>,
|
||||
) -> Result<(Conn, HeadsetInfo, String), ConnectError> {
|
||||
let host = host.trim();
|
||||
if host.is_empty() {
|
||||
return Err((Problem::BadAddress, "Type the headset's IP address first.".into(), None));
|
||||
}
|
||||
let addrs: Vec<SocketAddr> = match timeout(Duration::from_secs(6), tokio::net::lookup_host((host, port))).await {
|
||||
Ok(Ok(addrs)) => addrs.collect(),
|
||||
Ok(Err(error)) => return Err((Problem::BadAddress, format!("\"{host}\" isn't an address this PC can find ({error})."), None)),
|
||||
Err(_) => return Err((Problem::BadAddress, format!("Looking up \"{host}\" took too long."), None)),
|
||||
};
|
||||
let mut stream = None;
|
||||
let mut last = (Problem::BadAddress, format!("\"{host}\" didn't give any address."));
|
||||
for addr in addrs {
|
||||
match timeout(Duration::from_secs(6), TcpStream::connect(addr)).await {
|
||||
Ok(Ok(connected)) => {
|
||||
stream = Some(connected);
|
||||
break;
|
||||
}
|
||||
Ok(Err(error)) => last = io_problem(&error, addr),
|
||||
Err(_) => last = (Problem::Timeout, format!("No answer from {addr} within 6 seconds.")),
|
||||
}
|
||||
}
|
||||
let Some(stream) = stream else {
|
||||
return Err((last.0, last.1, None));
|
||||
};
|
||||
let _ = stream.set_nodelay(true);
|
||||
|
||||
let seen = Arc::new(Mutex::new(None));
|
||||
let handler = Client {
|
||||
expected: known.clone(),
|
||||
seen: seen.clone(),
|
||||
};
|
||||
let config = Arc::new(client::Config {
|
||||
keepalive_interval: Some(Duration::from_secs(10)),
|
||||
keepalive_max: 3,
|
||||
nodelay: true,
|
||||
..Default::default()
|
||||
});
|
||||
let seen_key = || seen.lock().unwrap().clone();
|
||||
let mut handle = match timeout(Duration::from_secs(15), client::connect_stream(config, stream, handler)).await {
|
||||
Ok(Ok(handle)) => handle,
|
||||
Ok(Err(error)) => {
|
||||
let fingerprint = seen_key();
|
||||
if let (Some(expected), Some(shown)) = (&known, &fingerprint) {
|
||||
if expected != shown {
|
||||
return Err((
|
||||
Problem::HostKeyChanged,
|
||||
format!("Expected {expected}, but the headset showed {shown}."),
|
||||
fingerprint,
|
||||
));
|
||||
}
|
||||
}
|
||||
return Err((Problem::Handshake, format!("The secure connection couldn't be set up: {error}"), fingerprint));
|
||||
}
|
||||
Err(_) => return Err((Problem::Timeout, "The headset didn't finish setting up the secure connection.".into(), seen_key())),
|
||||
};
|
||||
let fingerprint = seen_key().unwrap_or_default();
|
||||
|
||||
authenticate(&mut handle, user, password)
|
||||
.await
|
||||
.map_err(|(problem, detail)| (problem, detail, Some(fingerprint.clone())))?;
|
||||
|
||||
let handle = Arc::new(handle);
|
||||
let info = fetch_info(&handle, host, user)
|
||||
.await
|
||||
.map_err(|detail| (Problem::Other, format!("Logged in, but reading the headset's state failed: {detail}"), Some(fingerprint.clone())))?;
|
||||
Ok((
|
||||
Conn {
|
||||
handle,
|
||||
host: host.to_string(),
|
||||
user: user.to_string(),
|
||||
},
|
||||
info,
|
||||
fingerprint,
|
||||
))
|
||||
}
|
||||
|
||||
/// Password login, falling back to keyboard-interactive (the same password) where the server
|
||||
/// only offers that.
|
||||
async fn authenticate(handle: &mut Handle<Client>, user: &str, password: &str) -> Result<(), (Problem, String)> {
|
||||
let other = |error: russh::Error| (Problem::Other, format!("Logging in failed: {error}"));
|
||||
let remaining = match handle.authenticate_password(user, password).await.map_err(other)? {
|
||||
client::AuthResult::Success => return Ok(()),
|
||||
client::AuthResult::Failure { remaining_methods, .. } => remaining_methods,
|
||||
};
|
||||
if remaining.contains(&MethodKind::KeyboardInteractive) {
|
||||
let mut response = handle
|
||||
.authenticate_keyboard_interactive_start(user, None)
|
||||
.await
|
||||
.map_err(other)?;
|
||||
for _ in 0..6 {
|
||||
match response {
|
||||
KeyboardInteractiveAuthResponse::Success => return Ok(()),
|
||||
KeyboardInteractiveAuthResponse::Failure { .. } => break,
|
||||
KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => {
|
||||
let answers = prompts
|
||||
.iter()
|
||||
.map(|prompt| if prompt.echo { String::new() } else { password.to_string() })
|
||||
.collect();
|
||||
response = handle
|
||||
.authenticate_keyboard_interactive_respond(answers)
|
||||
.await
|
||||
.map_err(other)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if remaining.contains(&MethodKind::Password) || remaining.contains(&MethodKind::KeyboardInteractive) {
|
||||
Err((Problem::WrongPassword, format!("The headset didn't accept the password for user \"{user}\".")))
|
||||
} else {
|
||||
let methods: Vec<&str> = remaining.iter().map(<&str>::from).collect();
|
||||
Err((
|
||||
Problem::NoPasswordLogin,
|
||||
format!("The headset doesn't allow password login (it offers: {}).", methods.join(", ")),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_info(handle: &Handle<Client>, host: &str, user: &str) -> Result<HeadsetInfo, String> {
|
||||
let output = exec_simple(handle, &remote::info(), Duration::from_secs(20)).await?;
|
||||
Ok(HeadsetInfo::parse(&output.stdout, host, user))
|
||||
}
|
||||
|
||||
/// Upload the bundled release, check it arrived intact, then run install.sh (or its uninstall).
|
||||
async fn run_task(conn: &Conn, kind: TaskKind, with_panel: bool, purge: bool, out: &Emitter) -> Result<String, String> {
|
||||
if !bundle::verify() {
|
||||
return Err("The copy of frameeyeosc inside this program is damaged. Download the helper again.".into());
|
||||
}
|
||||
out.emit(Event::TaskStep(TaskStep::Uploading));
|
||||
let upload = timeout(
|
||||
Duration::from_secs(120),
|
||||
exec(&conn.handle, &remote::upload(), Some(bundle::TARBALL), |fraction| out.emit(Event::UploadProgress(fraction)), |_| {}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Sending the files to the headset took too long.".to_string())?
|
||||
.map_err(|error| format!("Sending the files to the headset failed: {error}"))?;
|
||||
out.emit(Event::TaskStep(TaskStep::Verifying));
|
||||
let arrived = upload.stdout.trim();
|
||||
if upload.code != Some(0) || arrived != bundle::TARBALL_SHA256 {
|
||||
return Err(format!(
|
||||
"The files didn't arrive intact (checksum {}). Try again.",
|
||||
if arrived.is_empty() { "missing" } else { arrived }
|
||||
));
|
||||
}
|
||||
out.emit(Event::TaskLine(format!("Upload verified (SHA-256 {}…)", &arrived[..16])));
|
||||
out.emit(Event::TaskStep(TaskStep::Running));
|
||||
let script = match kind {
|
||||
TaskKind::Install => remote::install(with_panel),
|
||||
TaskKind::Uninstall => remote::uninstall(purge),
|
||||
};
|
||||
let script = format!("exec 2>&1\n{script}");
|
||||
let result = timeout(
|
||||
Duration::from_secs(180),
|
||||
exec(&conn.handle, &script, None, |_| {}, |line| out.emit(Event::TaskLine(line.to_string()))),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "install.sh didn't finish within 3 minutes.".to_string())?
|
||||
.map_err(|error| format!("Running install.sh failed: {error}"))?;
|
||||
match result.code {
|
||||
Some(0) => Ok(match kind {
|
||||
TaskKind::Install => "Installed.".into(),
|
||||
TaskKind::Uninstall => "Removed.".into(),
|
||||
}),
|
||||
Some(code) => Err(format!("install.sh stopped with exit code {code}. The details above say why.")),
|
||||
None => Err("install.sh ended without an exit code (the connection may have dropped).".into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_config(conn: &Conn, changes: &[(String, serde_json::Value)]) -> Result<(), String> {
|
||||
let current = exec_simple(&conn.handle, &remote::read_config(), Duration::from_secs(15)).await?;
|
||||
let text = current.stdout.trim();
|
||||
let mut config: serde_json::Value = if text.is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::from_str(text).map_err(|_| {
|
||||
"The headset's settings file is damaged. Open the Eye panel in the headset and use \"Reset all\" on the Basic tab."
|
||||
.to_string()
|
||||
})?
|
||||
};
|
||||
let Some(object) = config.as_object_mut() else {
|
||||
return Err("The headset's settings file isn't a JSON object.".into());
|
||||
};
|
||||
for (key, value) in changes {
|
||||
object.insert(key.clone(), value.clone());
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(&config).map_err(|error| error.to_string())?;
|
||||
let written = timeout(
|
||||
Duration::from_secs(15),
|
||||
exec(&conn.handle, &remote::write_config(), Some(&json), |_| {}, |_| {}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Writing the settings took too long.".to_string())?
|
||||
.map_err(|error| error.to_string())?;
|
||||
if written.code == Some(0) { Ok(()) } else { Err("Writing the settings on the headset failed.".into()) }
|
||||
}
|
||||
|
||||
/// Stream status.json until the connection goes away; restarts the stream if only it fails.
|
||||
async fn stream_status(handle: Arc<Handle<Client>>, out: Emitter) {
|
||||
let mut last = LiveStatus::default();
|
||||
loop {
|
||||
let result: Result<(), russh::Error> = async {
|
||||
let mut channel = handle.channel_open_session().await?;
|
||||
channel.exec(true, remote::status_stream()).await?;
|
||||
channel.eof().await?;
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut frame: Vec<String> = Vec::new();
|
||||
while let Some(message) = channel.wait().await {
|
||||
let ChannelMsg::Data { data } = message else { continue };
|
||||
buffer.extend_from_slice(&data);
|
||||
while let Some(end) = buffer.iter().position(|byte| *byte == b'\n') {
|
||||
let line: Vec<u8> = buffer.drain(..=end).collect();
|
||||
let line = String::from_utf8_lossy(&line).trim_end().to_string();
|
||||
if line == "--end--" {
|
||||
last = status::parse_frame(&frame, &last);
|
||||
frame.clear();
|
||||
out.emit(Event::Status(last.clone()));
|
||||
} else {
|
||||
frame.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if handle.is_closed() {
|
||||
out.emit(Event::Disconnected {
|
||||
reason: "The connection to the headset was lost (was it turned off or did it leave the network?).".into(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Err(error) = result {
|
||||
out.emit(Event::StatusProblem(error.to_string()));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Demo mode: a pretend headset, so the whole flow can be tried (and shown) without a Steam Frame.
|
||||
|
||||
struct Demo {
|
||||
installed: bool,
|
||||
with_panel: bool,
|
||||
shared: Arc<Mutex<DemoState>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DemoState {
|
||||
sending: bool,
|
||||
host: String,
|
||||
}
|
||||
|
||||
const DEMO_PC: &str = "192.168.1.20";
|
||||
|
||||
fn demo_info(demo: &Demo) -> HeadsetInfo {
|
||||
HeadsetInfo {
|
||||
host: "192.168.1.42".into(),
|
||||
user: "steamos".into(),
|
||||
arch: "aarch64".into(),
|
||||
eye_server: true,
|
||||
daemon_installed: demo.installed,
|
||||
daemon_sha: if demo.installed { bundle::DAEMON_SHA256.into() } else { String::new() },
|
||||
panel_installed: demo.installed && demo.with_panel,
|
||||
panel_version: (demo.installed && demo.with_panel).then(|| bundle::VERSION.to_string()),
|
||||
service_active: if demo.installed { "active" } else { "inactive" }.into(),
|
||||
service_enabled: if demo.installed { "enabled" } else { "not-found" }.into(),
|
||||
panel_active: if demo.installed && demo.with_panel { "active" } else { "inactive" }.into(),
|
||||
steamvr: "active".into(),
|
||||
os: "SteamOS (demo)".into(),
|
||||
client_ip: DEMO_PC.parse().ok(),
|
||||
missing_tools: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Eyes that look around, blink every few seconds and now and then look far to one side.
|
||||
fn demo_status(t: f64, state: &DemoState) -> LiveStatus {
|
||||
let fixation = (t / 1.4).floor();
|
||||
let hash = |n: f64| ((n * 12.9898).sin() * 43758.5453).fract().abs() as f32;
|
||||
let settle = (((t / 1.4).fract() as f32) * 8.0).min(1.0);
|
||||
let target = |n: f64| [hash(n) * 1.1 - 0.55, hash(n + 7.0) * 0.7 - 0.35];
|
||||
let (from, to) = (target(fixation - 1.0), target(fixation));
|
||||
let gaze = [from[0] + (to[0] - from[0]) * settle, from[1] + (to[1] - from[1]) * settle];
|
||||
let blink_phase = (t % 4.3) as f32;
|
||||
let lid = if blink_phase < 0.12 { 0.0 } else if blink_phase < 0.25 { (blink_phase - 0.12) / 0.13 * 0.75 } else { 0.75 };
|
||||
let wink = (t % 17.0) > 15.0 && (t % 17.0) < 15.5;
|
||||
let lids = [lid, if wink { 0.0 } else { lid }];
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0.0, |elapsed| elapsed.as_secs_f64());
|
||||
let target = if state.host == "auto" { format!("{DEMO_PC}:9000") } else { format!("{}:9000", state.host) };
|
||||
LiveStatus {
|
||||
service: "active".into(),
|
||||
panel_service: "active".into(),
|
||||
steamvr: "active".into(),
|
||||
eye_server: true,
|
||||
headset_now: now,
|
||||
status: Some(StatusJson {
|
||||
pid: 4242,
|
||||
time: now,
|
||||
started: now - 600.0,
|
||||
sending: state.sending,
|
||||
output: "vrchat".into(),
|
||||
target_mode: if state.host == "auto" { "auto" } else { "fixed" }.into(),
|
||||
target: Some(target),
|
||||
rate: if state.sending { 118.0 } else { 0.0 },
|
||||
tracker_rate: Some(118.0 + (t * 3.0).sin() as f32 * 2.0),
|
||||
tracking: true,
|
||||
raw: Some(status::RawValues {
|
||||
openness: [0.3 + lids[0] * 0.66, 0.3 + lids[1] * 0.66],
|
||||
gaze: [gaze[0] + 0.02, gaze[1] - 0.01],
|
||||
}),
|
||||
sent: Some(status::SentValues {
|
||||
lids_vrcft: lids,
|
||||
gaze,
|
||||
gaze_left: gaze,
|
||||
gaze_right: gaze,
|
||||
}),
|
||||
config_error: None,
|
||||
effective: Some(status::Effective {
|
||||
host: state.host.clone(),
|
||||
port: None,
|
||||
prefix: "/FT".into(),
|
||||
independent_eyes: false,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const DEMO_INSTALL_OUTPUT: &[&str] = &[
|
||||
"Created symlink '/home/steamos/.config/systemd/user/steamvr.service.wants/frameeyeosc.service' → '/home/steamos/.config/systemd/user/frameeyeosc.service'.",
|
||||
"Created symlink '/home/steamos/.config/systemd/user/steamvr.service.wants/frameeyeosc-panel.service' → '/home/steamos/.config/systemd/user/frameeyeosc-panel.service'.",
|
||||
"● frameeyeosc.service - Send Steam Frame eye tracking to VRChat over OSC",
|
||||
" Loaded: loaded (/home/steamos/.config/systemd/user/frameeyeosc.service; enabled; preset: enabled)",
|
||||
" Active: active (running)",
|
||||
"",
|
||||
"frameeyeosc is installed and starts together with SteamVR.",
|
||||
" Settings: /home/steamos/.config/frameeyeosc/config.json",
|
||||
" Logs: journalctl --user -u frameeyeosc -f",
|
||||
"",
|
||||
"The panel starts with SteamVR from the next SteamVR start (\"Eye\" on the dashboard).",
|
||||
"",
|
||||
"On your PC, turn off Steam Link's own OSC output (SteamVR settings > Steam Link > OSC),",
|
||||
"otherwise it drives the avatar's eyes too with unsmoothed data.",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
enum Link {
|
||||
None,
|
||||
Ssh(Conn),
|
||||
Demo(Demo),
|
||||
}
|
||||
|
||||
async fn run(mut commands: tmpsc::UnboundedReceiver<Cmd>, out: Emitter) {
|
||||
let mut link = Link::None;
|
||||
let mut status_task: Option<JoinHandle<()>> = None;
|
||||
let stop_status = |task: &mut Option<JoinHandle<()>>| {
|
||||
if let Some(task) = task.take() {
|
||||
task.abort();
|
||||
}
|
||||
};
|
||||
while let Some(cmd) = commands.recv().await {
|
||||
match cmd {
|
||||
Cmd::Scan { port } => {
|
||||
let out = out.clone();
|
||||
tokio::spawn(async move {
|
||||
let progress = out.clone();
|
||||
let (hits, networks) = net::scan(port, move |fraction| progress.emit(Event::ScanProgress(fraction))).await;
|
||||
out.emit(Event::ScanDone { hits, networks });
|
||||
});
|
||||
}
|
||||
Cmd::Connect { host, port, user, password, known_fingerprint } => {
|
||||
stop_status(&mut status_task);
|
||||
if let Link::Ssh(conn) = std::mem::replace(&mut link, Link::None) {
|
||||
let _ = conn.handle.disconnect(Disconnect::ByApplication, "", "en").await;
|
||||
}
|
||||
match connect(&host, port, &user, &password, known_fingerprint).await {
|
||||
Ok((conn, info, fingerprint)) => {
|
||||
link = Link::Ssh(conn);
|
||||
out.emit(Event::Connected { info, fingerprint, demo: false });
|
||||
}
|
||||
Err((problem, detail, fingerprint)) => out.emit(Event::ConnectFailed { problem, detail, fingerprint }),
|
||||
}
|
||||
}
|
||||
Cmd::ConnectDemo => {
|
||||
stop_status(&mut status_task);
|
||||
tokio::time::sleep(Duration::from_millis(700)).await;
|
||||
let demo = Demo {
|
||||
installed: false,
|
||||
with_panel: true,
|
||||
shared: Arc::new(Mutex::new(DemoState { sending: true, host: "auto".into() })),
|
||||
};
|
||||
out.emit(Event::Connected {
|
||||
info: demo_info(&demo),
|
||||
fingerprint: "SHA256:demo".into(),
|
||||
demo: true,
|
||||
});
|
||||
link = Link::Demo(demo);
|
||||
}
|
||||
Cmd::Disconnect => {
|
||||
stop_status(&mut status_task);
|
||||
if let Link::Ssh(conn) = std::mem::replace(&mut link, Link::None) {
|
||||
let _ = conn.handle.disconnect(Disconnect::ByApplication, "", "en").await;
|
||||
}
|
||||
}
|
||||
Cmd::RefreshInfo => match &link {
|
||||
Link::Ssh(conn) => match fetch_info(&conn.handle, &conn.host, &conn.user).await {
|
||||
Ok(info) => out.emit(Event::Info(info)),
|
||||
Err(detail) => lost_or(&conn.handle, &out, detail),
|
||||
},
|
||||
Link::Demo(demo) => out.emit(Event::Info(demo_info(demo))),
|
||||
Link::None => {}
|
||||
},
|
||||
Cmd::Install { with_panel } => match &mut link {
|
||||
Link::Ssh(conn) => {
|
||||
let result = run_task(conn, TaskKind::Install, with_panel, false, &out).await;
|
||||
let (ok, message) = flatten(result);
|
||||
out.emit(Event::TaskDone { kind: TaskKind::Install, ok, message });
|
||||
if let Ok(info) = fetch_info(&conn.handle, &conn.host, &conn.user).await {
|
||||
out.emit(Event::Info(info));
|
||||
}
|
||||
}
|
||||
Link::Demo(demo) => {
|
||||
out.emit(Event::TaskStep(TaskStep::Uploading));
|
||||
for step in 1..=10 {
|
||||
tokio::time::sleep(Duration::from_millis(90)).await;
|
||||
out.emit(Event::UploadProgress(step as f32 / 10.0));
|
||||
}
|
||||
out.emit(Event::TaskStep(TaskStep::Verifying));
|
||||
out.emit(Event::TaskLine(format!("Upload verified (SHA-256 {}…)", &bundle::TARBALL_SHA256[..16])));
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
out.emit(Event::TaskStep(TaskStep::Running));
|
||||
for line in DEMO_INSTALL_OUTPUT.iter().filter(|line| with_panel || !line.contains("panel")) {
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
out.emit(Event::TaskLine((*line).to_string()));
|
||||
}
|
||||
demo.installed = true;
|
||||
demo.with_panel = with_panel;
|
||||
out.emit(Event::TaskDone { kind: TaskKind::Install, ok: true, message: "Installed.".into() });
|
||||
out.emit(Event::Info(demo_info(demo)));
|
||||
}
|
||||
Link::None => out.emit(Event::TaskDone { kind: TaskKind::Install, ok: false, message: "Not connected to the headset.".into() }),
|
||||
},
|
||||
Cmd::Uninstall { purge } => match &mut link {
|
||||
Link::Ssh(conn) => {
|
||||
stop_status(&mut status_task);
|
||||
let (ok, message) = flatten(run_task(conn, TaskKind::Uninstall, false, purge, &out).await);
|
||||
out.emit(Event::TaskDone { kind: TaskKind::Uninstall, ok, message });
|
||||
if let Ok(info) = fetch_info(&conn.handle, &conn.host, &conn.user).await {
|
||||
out.emit(Event::Info(info));
|
||||
}
|
||||
}
|
||||
Link::Demo(demo) => {
|
||||
stop_status(&mut status_task);
|
||||
out.emit(Event::TaskStep(TaskStep::Running));
|
||||
tokio::time::sleep(Duration::from_millis(600)).await;
|
||||
out.emit(Event::TaskLine("Removed frameeyeosc and the panel.".into()));
|
||||
demo.installed = false;
|
||||
out.emit(Event::TaskDone { kind: TaskKind::Uninstall, ok: true, message: "Removed.".into() });
|
||||
out.emit(Event::Info(demo_info(demo)));
|
||||
}
|
||||
Link::None => out.emit(Event::TaskDone { kind: TaskKind::Uninstall, ok: false, message: "Not connected to the headset.".into() }),
|
||||
},
|
||||
Cmd::StartStatus => {
|
||||
stop_status(&mut status_task);
|
||||
match &link {
|
||||
Link::Ssh(conn) => status_task = Some(tokio::spawn(stream_status(conn.handle.clone(), out.clone()))),
|
||||
Link::Demo(demo) => {
|
||||
let shared = demo.shared.clone();
|
||||
let installed = demo.installed;
|
||||
let out = out.clone();
|
||||
status_task = Some(tokio::spawn(async move {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
let state = shared.lock().unwrap().clone();
|
||||
let mut live = demo_status(start.elapsed().as_secs_f64(), &state);
|
||||
if !installed {
|
||||
live.service = "inactive".into();
|
||||
live.status = None;
|
||||
}
|
||||
out.emit(Event::Status(live));
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
Link::None => {}
|
||||
}
|
||||
}
|
||||
Cmd::StopStatus => stop_status(&mut status_task),
|
||||
Cmd::FetchLogs => match &link {
|
||||
Link::Ssh(conn) => match exec_simple(&conn.handle, &remote::logs(), Duration::from_secs(20)).await {
|
||||
Ok(output) => out.emit(Event::Logs(output.stdout)),
|
||||
Err(detail) => lost_or(&conn.handle, &out, detail),
|
||||
},
|
||||
Link::Demo(_) => out.emit(Event::Logs(
|
||||
"== frameeyeosc (newest last) ==\nReading /dev/shm/eye-server.mmap\nSending OSC to 192.168.1.20:9000\nEye tracking started\n(demo)\n".into(),
|
||||
)),
|
||||
Link::None => {}
|
||||
},
|
||||
Cmd::RestartService => {
|
||||
let label = "Start frameeyeosc".to_string();
|
||||
match &link {
|
||||
Link::Ssh(conn) => match exec_simple(&conn.handle, &remote::restart_service(), Duration::from_secs(30)).await {
|
||||
Ok(output) if output.code == Some(0) => out.emit(Event::ActionDone { label, ok: true, message: "frameeyeosc was (re)started.".into() }),
|
||||
Ok(output) => out.emit(Event::ActionDone { label, ok: false, message: output.stdout }),
|
||||
Err(detail) => lost_or(&conn.handle, &out, detail),
|
||||
},
|
||||
Link::Demo(_) => out.emit(Event::ActionDone { label, ok: true, message: "frameeyeosc was (re)started.".into() }),
|
||||
Link::None => {}
|
||||
}
|
||||
}
|
||||
Cmd::ShowPanel => {
|
||||
let label = "Show the Eye panel".to_string();
|
||||
match &link {
|
||||
Link::Ssh(conn) => match exec_simple(&conn.handle, &remote::show_panel(), Duration::from_secs(30)).await {
|
||||
Ok(_) => out.emit(Event::ActionDone {
|
||||
label,
|
||||
ok: true,
|
||||
message: "Asked the headset to open the Eye panel on the SteamVR dashboard (SteamVR must be running).".into(),
|
||||
}),
|
||||
Err(detail) => lost_or(&conn.handle, &out, detail),
|
||||
},
|
||||
Link::Demo(_) => out.emit(Event::ActionDone { label, ok: true, message: "The Eye panel would open in the headset now (demo).".into() }),
|
||||
Link::None => {}
|
||||
}
|
||||
}
|
||||
Cmd::SetConfig { changes, label } => match &link {
|
||||
Link::Ssh(conn) => match set_config(conn, &changes).await {
|
||||
Ok(()) => out.emit(Event::ActionDone { label, ok: true, message: "Saved on the headset; frameeyeosc uses it right away.".into() }),
|
||||
Err(message) => {
|
||||
if conn.handle.is_closed() {
|
||||
lost_or(&conn.handle, &out, message);
|
||||
} else {
|
||||
out.emit(Event::ActionDone { label, ok: false, message });
|
||||
}
|
||||
}
|
||||
},
|
||||
Link::Demo(demo) => {
|
||||
let mut state = demo.shared.lock().unwrap();
|
||||
for (key, value) in &changes {
|
||||
match (key.as_str(), value) {
|
||||
("sending", serde_json::Value::Bool(sending)) => state.sending = *sending,
|
||||
("host", serde_json::Value::String(host)) => state.host = host.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.emit(Event::ActionDone { label, ok: true, message: "Saved (demo).".into() });
|
||||
}
|
||||
Link::None => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten(result: Result<String, String>) -> (bool, String) {
|
||||
match result {
|
||||
Ok(message) => (true, message),
|
||||
Err(message) => (false, message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Report a failed request: as a lost connection if that's what it was.
|
||||
fn lost_or(handle: &Handle<Client>, out: &Emitter, detail: String) {
|
||||
if handle.is_closed() {
|
||||
out.emit(Event::Disconnected { reason: format!("The connection to the headset was lost ({detail}).") });
|
||||
} else {
|
||||
out.emit(Event::ActionDone { label: "Headset".into(), ok: false, message: detail });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user