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

+112
View File
@@ -0,0 +1,112 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "fake-eye-server"
version = "1.0.0"
dependencies = [
"libc",
"serde_json",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "fake-eye-server"
version = "1.0.0"
edition = "2024"
description = "Pretends to be the Steam Frame's eye tracker (shared-memory layout v4) so frameeyeosc can run on a PC"
license = "MIT"
publish = false
[dependencies]
libc = "0.2"
serde_json = "1"
[profile.release]
strip = true
+381
View File
@@ -0,0 +1,381 @@
//! Pretends to be the Steam Frame's eye tracker, so the real frameeyeosc (and its Eye panel) can run on
//! an ordinary Linux PC. It creates /dev/shm/eye-server.mmap with the private version-4 layout that
//! frameeyeosc reads (see frameeyeosc's src/main.rs) and publishes made-up samples about 120 times a second:
//! eyes that look around, blink and now and then wink.
//!
//! During the Eye panel's eye fit it plays along: it reads the capture requests the panel writes into
//! config.json and looks at each dot (or closes its eyes), like a person following the fit would.
//! Its "sensor" is deliberately a little off (reads low and a bit narrow vertically), so the fit has
//! something to correct.
//!
//! fake-eye-server [--rate HZ] [--no-bias] [--config PATH]
//! Stop with Ctrl+C; the shared-memory file is removed on exit.
use std::fs::OpenOptions;
use std::io;
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
use std::ptr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
const PATH: &str = "/dev/shm/eye-server.mmap";
const SHM_SIZE: usize = 0x4f21a;
const VERSION: u32 = 4;
const OFF_INITIALIZED: usize = 0x04;
const OFF_MUTEX: usize = 0x08;
const OFF_SEQUENCE: usize = 0x38;
const OFF_REQUESTED: usize = 0x3c;
const OFF_RECORD: usize = 0x152;
// Inside the packed record
const REC_STATE: usize = 0x00;
const REC_FLAG: usize = 0x04;
const REC_TIME: usize = 0x05;
const REC_GAZE: usize = 0x0d;
const REC_GAZE_COV: usize = 0x25;
const REC_FIXATION: usize = 0x3d;
const REC_PRE_GAZE: usize = 0x49;
const REC_PRE_COV: usize = 0x61;
const REC_OPENNESS: usize = 0x79;
const _: () = assert!(std::mem::size_of::<libc::pthread_mutex_t>() <= 0x30);
static STOP: AtomicBool = AtomicBool::new(false);
extern "C" fn on_signal(_: libc::c_int) {
STOP.store(true, Ordering::SeqCst);
}
struct Shm {
base: *mut u8,
}
impl Shm {
fn create() -> io::Result<Self> {
let _ = std::fs::remove_file(PATH);
let file = OpenOptions::new().read(true).write(true).create_new(true).mode(0o666).open(PATH)?;
file.set_len(SHM_SIZE as u64)?;
use std::os::fd::AsRawFd;
let base = unsafe {
libc::mmap(ptr::null_mut(), SHM_SIZE, libc::PROT_READ | libc::PROT_WRITE, libc::MAP_SHARED, file.as_raw_fd(), 0)
};
if base == libc::MAP_FAILED {
return Err(io::Error::last_os_error());
}
let shm = Shm { base: base.cast() };
unsafe {
// A robust, process-shared mutex, like the real eye server's
let mut attr: libc::pthread_mutexattr_t = std::mem::zeroed();
libc::pthread_mutexattr_init(&mut attr);
libc::pthread_mutexattr_setpshared(&mut attr, libc::PTHREAD_PROCESS_SHARED);
libc::pthread_mutexattr_setrobust(&mut attr, libc::PTHREAD_MUTEX_ROBUST);
libc::pthread_mutex_init(shm.mutex(), &attr);
libc::pthread_mutexattr_destroy(&mut attr);
shm.write_u32(0, VERSION);
shm.write_u32(OFF_INITIALIZED, 1);
}
Ok(shm)
}
fn mutex(&self) -> *mut libc::pthread_mutex_t {
unsafe { self.base.add(OFF_MUTEX).cast() }
}
unsafe fn write_u32(&self, offset: usize, value: u32) {
unsafe { ptr::write_volatile(self.base.add(offset).cast::<u32>(), value) };
}
unsafe fn write_bytes(&self, offset: usize, bytes: &[u8]) {
unsafe { ptr::copy_nonoverlapping(bytes.as_ptr(), self.base.add(offset), bytes.len()) };
}
fn publish(&self, sample: &Sample) {
let record = OFF_RECORD;
let floats = |values: &[f32]| -> Vec<u8> { values.iter().flat_map(|value| value.to_le_bytes()).collect() };
unsafe {
let code = libc::pthread_mutex_lock(self.mutex());
if code == libc::EOWNERDEAD {
libc::pthread_mutex_consistent(self.mutex());
}
self.write_bytes(record + REC_STATE, &1u32.to_le_bytes());
self.write_bytes(record + REC_FLAG, &[1]);
self.write_bytes(record + REC_TIME, &sample.time.to_le_bytes());
let gaze = [sample.left[0], sample.left[1], sample.left[2], sample.right[0], sample.right[1], sample.right[2]];
self.write_bytes(record + REC_GAZE, &floats(&gaze));
self.write_bytes(record + REC_GAZE_COV, &floats(&[0.005; 6]));
self.write_bytes(record + REC_FIXATION, &floats(&sample.fixation));
self.write_bytes(record + REC_PRE_GAZE, &floats(&gaze));
self.write_bytes(record + REC_PRE_COV, &floats(&[0.005; 6]));
self.write_bytes(record + REC_OPENNESS, &floats(&sample.openness));
let sequence = self.base.add(OFF_SEQUENCE).cast::<u32>();
ptr::write_volatile(sequence, ptr::read_volatile(sequence).wrapping_add(1));
self.write_u32(OFF_REQUESTED, 0);
libc::pthread_mutex_unlock(self.mutex());
// Shared (not private) futex: frameeyeosc waits on this word from another process
libc::syscall(libc::SYS_futex, sequence, libc::FUTEX_WAKE, i32::MAX, ptr::null::<libc::timespec>());
}
}
}
impl Drop for Shm {
fn drop(&mut self) {
unsafe { libc::munmap(self.base.cast(), SHM_SIZE) };
let _ = std::fs::remove_file(PATH);
}
}
struct Sample {
time: f64,
left: [f32; 3],
right: [f32; 3],
fixation: [f32; 3],
openness: [f32; 2],
}
/// A small deterministic pseudo-random generator (xorshift), so no extra crate is needed.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f32 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
(self.0 >> 40) as f32 / (1u64 << 24) as f32
}
fn noise(&mut self, size: f32) -> f32 {
(self.next() + self.next() + self.next() - 1.5) * size
}
}
/// What the pretend person is doing.
#[derive(Clone, Copy, PartialEq)]
enum Look {
Wander,
/// Following an eye-fit dot: degrees right, degrees up.
Target(f32, f32),
Closed,
}
/// Reads the eye-fit capture the panel asked for (config.json `gaze_capture`) and whether frameeyeosc
/// finished it (status.json).
struct FitWatcher {
config: Option<PathBuf>,
status: Option<PathBuf>,
last_check: Instant,
look: Look,
/// Until when to keep holding the look after a capture finished (the dot is still up, gliding on).
hold_until: Option<Instant>,
active_id: i64,
}
impl FitWatcher {
fn new(config: Option<PathBuf>) -> Self {
let config = config.or_else(|| {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
Some(base.join("frameeyeosc").join("config.json"))
});
let status = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.or_else(|| Some(PathBuf::from(format!("/run/user/{}", unsafe { libc::getuid() }))))
.map(|dir| dir.join("frameeyeosc").join("status.json"));
Self {
config,
status,
last_check: Instant::now() - Duration::from_secs(1),
look: Look::Wander,
hold_until: None,
active_id: 0,
}
}
fn read_json(path: &Option<PathBuf>) -> Option<serde_json::Value> {
let text = std::fs::read_to_string(path.as_ref()?).ok()?;
serde_json::from_str(&text).ok()
}
fn update(&mut self) -> Look {
if self.last_check.elapsed() < Duration::from_millis(50) {
return self.current();
}
self.last_check = Instant::now();
let request = Self::read_json(&self.config).and_then(|config| config.get("gaze_capture").cloned());
let id = request.as_ref().and_then(|request| request.get("id")).and_then(|id| id.as_i64()).unwrap_or(0);
let target = request.as_ref().and_then(|request| request.get("target")).and_then(|t| t.as_str()).unwrap_or("");
let done_id = Self::read_json(&self.status)
.and_then(|status| status.get("gaze_capture").cloned())
.filter(|capture| capture.get("state").and_then(|state| state.as_str()) == Some("done"))
.and_then(|capture| capture.get("id").and_then(|id| id.as_i64()))
.unwrap_or(0);
if id != 0 && id != done_id {
// A capture is running (or about to): look where it wants
if id != self.active_id {
self.active_id = id;
eprintln!("[fake-eye-server] eye fit asks for \"{target}\" (capture {id}); looking there");
}
self.look = match target {
"center" => Look::Target(0.0, 0.0),
"up" => Look::Target(0.0, 15.0),
"down" => Look::Target(0.0, -15.0),
"left" => Look::Target(-20.0, 0.0),
"right" => Look::Target(20.0, 0.0),
"closed" => Look::Closed,
_ => self.look,
};
self.hold_until = Some(Instant::now() + Duration::from_secs(3));
} else if self.look == Look::Closed && id == done_id {
// The eyes-shut step is over: open them again
self.look = Look::Wander;
self.hold_until = None;
}
self.current()
}
fn current(&mut self) -> Look {
match self.hold_until {
Some(until) if Instant::now() > until => {
self.hold_until = None;
self.look = Look::Wander;
Look::Wander
}
_ => self.look,
}
}
}
fn direction(yaw_deg: f32, pitch_deg: f32) -> [f32; 3] {
let (x, y) = (yaw_deg.to_radians().tan(), pitch_deg.to_radians().tan());
let length = (x * x + y * y + 1.0).sqrt();
[x / length, y / length, -1.0 / length]
}
fn main() {
let mut rate = 120.0f64;
let mut bias = true;
let mut config = None;
let args: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--rate" if i + 1 < args.len() => {
rate = args[i + 1].parse().unwrap_or(rate).clamp(5.0, 500.0);
i += 1;
}
"--no-bias" => bias = false,
"--config" if i + 1 < args.len() => {
config = Some(PathBuf::from(&args[i + 1]));
i += 1;
}
"-h" | "--help" => {
println!("fake-eye-server [--rate HZ] [--no-bias] [--config PATH]\nPretends to be the Steam Frame's eye tracker at {PATH}.");
return;
}
other => {
eprintln!("unknown option {other}");
std::process::exit(2);
}
}
i += 1;
}
unsafe {
libc::signal(libc::SIGINT, on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t);
libc::signal(libc::SIGTERM, on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t);
}
let shm = match Shm::create() {
Ok(shm) => shm,
Err(error) => {
eprintln!("[fake-eye-server] can't create {PATH}: {error}");
std::process::exit(1);
}
};
eprintln!("[fake-eye-server] publishing made-up eye samples at {rate:.0} Hz to {PATH} (Ctrl+C to stop)");
let mut rng = Rng(0x9e3779b97f4a7c15);
let mut fit = FitWatcher::new(config);
let start = Instant::now();
let period = Duration::from_secs_f64(1.0 / rate);
let mut next = Instant::now();
// Wandering: fixations at random points, with quick saccades between them
let (mut yaw, mut pitch) = (0.0f32, 0.0f32);
let (mut goal_yaw, mut goal_pitch) = (0.0f32, 0.0f32);
let mut next_saccade = 0.8f64;
let mut next_blink = 2.5f64;
let mut blink_start = -10.0f64;
let mut wink_start = -10.0f64;
let mut next_wink = 17.0f64;
let (mut open_left, mut open_right) = (0.84f32, 0.84f32);
while !STOP.load(Ordering::SeqCst) {
let t = start.elapsed().as_secs_f64();
let look = fit.update();
let (target_yaw, target_pitch, shut) = match look {
Look::Wander => {
if t > next_saccade {
goal_yaw = rng.noise(34.0);
goal_pitch = rng.noise(20.0) - 2.0;
next_saccade = t + 0.6 + rng.next() as f64 * 1.6;
}
(goal_yaw, goal_pitch, false)
}
Look::Target(y, p) => (y, p, false),
Look::Closed => (0.0, 0.0, true),
};
// Saccade: cover most of the way within ~50 ms
let k = 1.0 - (-(1.0 / rate as f32) * 45.0).exp();
yaw += (target_yaw - yaw) * k;
pitch += (target_pitch - pitch) * k;
// Blinks (not while following a fit dot) and a wink now and then
if look == Look::Wander && t > next_blink {
blink_start = t;
next_blink = t + 2.5 + rng.next() as f64 * 3.5;
}
if look == Look::Wander && t > next_wink {
wink_start = t;
next_wink = t + 15.0 + rng.next() as f64 * 10.0;
}
let blink = |since: f64| -> f32 {
let s = (t - since) as f32;
if s < 0.0 || s > 0.26 { 1.0 } else if s < 0.08 { 1.0 - s / 0.08 } else if s < 0.14 { 0.0 } else { (s - 0.14) / 0.12 }
};
let blinking = blink(blink_start);
let winking = if (t - wink_start) < 0.6 { 0.0 } else { 1.0 };
// The Frame reads eyes as less open when looking down
let relaxed = 0.84 - 0.25 * (-pitch / 20.0).max(0.0);
let (goal_left, goal_right) = if shut {
(0.18, 0.19)
} else {
(0.2 + (relaxed - 0.2) * blinking, 0.2 + (relaxed * 1.02 - 0.2) * blinking * winking)
};
let lid_k = 1.0 - (-(1.0 / rate as f32) * 60.0).exp();
open_left += (goal_left - open_left) * lid_k;
open_right += (goal_right - open_right) * lid_k;
// What the "sensor" reports: a little noise, and (unless --no-bias) a bit low and narrow
let (sensor_yaw, sensor_pitch) = if bias { (yaw * 0.95 + 1.0, pitch * 0.85 - 2.5) } else { (yaw, pitch) };
let (n1, n2, n3, n4) = (rng.noise(0.5), rng.noise(0.5), rng.noise(0.5), rng.noise(0.5));
// Each eye turns in a little toward a point about 2 m away
let left = direction(sensor_yaw + 0.9 + n1, sensor_pitch + n2);
let right = direction(sensor_yaw - 0.9 + n3, sensor_pitch + n4);
let center = direction(sensor_yaw + (n1 + n3) / 2.0, sensor_pitch + (n2 + n4) / 2.0);
let sample = Sample {
time: 1000.0 + t,
left,
right,
fixation: center.map(|c| c * 0.7),
openness: [(open_left + rng.noise(0.01)).clamp(0.0, 1.2), (open_right + rng.noise(0.01)).clamp(0.0, 1.2)],
};
shm.publish(&sample);
next += period;
let now = Instant::now();
if next > now {
std::thread::sleep(next - now);
} else {
next = now;
}
}
eprintln!("[fake-eye-server] stopped");
}
+62
View File
@@ -0,0 +1,62 @@
# frameeyeosc-panel as a desktop window, for previewing it on a Linux PC without SteamVR.
# Builds the panel's own sources unchanged, except:
# - vr_overlay.cpp / vk_texture.cpp (SteamVR and its Vulkan textures) are replaced by vr_overlay_desktop.cpp (SDL2)
# - config.h's font paths point at this distribution's Noto Sans CJK (SteamOS keeps them elsewhere)
cmake_minimum_required(VERSION 3.16)
project(frameeyeosc-panel-preview LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
set(UPSTREAM "${CMAKE_CURRENT_SOURCE_DIR}/../../upstream/frameeyeosc" CACHE PATH "frameeyeosc source tree")
set(PANEL_FONT_REGULAR "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc" CACHE FILEPATH "Regular font")
set(PANEL_FONT_BOLD "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Bold.ttc" CACHE FILEPATH "Bold font")
# A copy of the panel's sources with the font paths replaced
set(SRC "${CMAKE_BINARY_DIR}/panel-src")
file(REMOVE_RECURSE "${SRC}")
file(COPY "${UPSTREAM}/panel/src/" DESTINATION "${SRC}")
file(READ "${SRC}/config.h" CONFIG_H)
string(REPLACE "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc" "${PANEL_FONT_REGULAR}" CONFIG_H "${CONFIG_H}")
string(REPLACE "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc" "${PANEL_FONT_BOLD}" CONFIG_H "${CONFIG_H}")
file(WRITE "${SRC}/config.h" "${CONFIG_H}")
file(STRINGS "${UPSTREAM}/Cargo.toml" CARGO_VERSION_LINE REGEX "^version = \"[^\"]+\"" LIMIT_COUNT 1)
string(REGEX REPLACE "^version = \"([^\"]+)\".*" "\\1" FRAMEEYEOSC_VERSION "${CARGO_VERSION_LINE}")
find_package(PkgConfig REQUIRED)
find_package(Threads REQUIRED)
pkg_check_modules(CAIRO REQUIRED IMPORTED_TARGET cairo)
pkg_check_modules(FREETYPE REQUIRED IMPORTED_TARGET freetype2)
pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2)
# vk_texture.h (included by vr_overlay.h) needs the Vulkan headers only; nothing links Vulkan
find_path(VULKAN_INCLUDE vulkan/vulkan.h REQUIRED)
add_executable(frameeyeosc-panel-preview
${SRC}/main.cpp
${SRC}/autostart.cpp
${SRC}/command.cpp
${SRC}/config.cpp
${SRC}/draw.cpp
${SRC}/gaze_dots.cpp
${SRC}/gaze_fit.cpp
${SRC}/fit_text.cpp
${SRC}/host_entry.cpp
${SRC}/i18n.cpp
${SRC}/json.cpp
${SRC}/model.cpp
${SRC}/panel.cpp
${SRC}/sounds.cpp
${SRC}/status.cpp
${SRC}/target.cpp
${SRC}/theme.cpp
${UPSTREAM}/vendor/frame-updater/cpp/update_check.cpp
vr_overlay_desktop.cpp
)
target_include_directories(frameeyeosc-panel-preview PRIVATE "${SRC}" "${UPSTREAM}/vendor/frame-updater/cpp" "${VULKAN_INCLUDE}")
target_compile_definitions(frameeyeosc-panel-preview PRIVATE FRAMEEYEOSC_VERSION="${FRAMEEYEOSC_VERSION}")
target_compile_options(frameeyeosc-panel-preview PRIVATE -Wall -Wextra)
target_link_libraries(frameeyeosc-panel-preview PRIVATE PkgConfig::CAIRO PkgConfig::FREETYPE PkgConfig::SDL2 Threads::Threads)
@@ -0,0 +1,534 @@
// A desktop stand-in for frameeyeosc-panel's vr_overlay.cpp: the same panel in an ordinary window (SDL2)
// instead of on the SteamVR dashboard, so it can be tried on a PC without a headset. Everything else
// (drawing, buttons, settings, the eye fit, the sounds) is frameeyeosc-panel's own code, unchanged.
//
// While "the dashboard is open" the window shows the panel and the mouse works like the laser pointer.
// Space (or the button at the top) closes the dashboard: the window then shows what you would see in the
// headset, which is where the eye fit's dot and the debug gaze dots appear.
#include "vr_overlay.h"
#include "gaze_fit.h"
#include <SDL.h>
#include <cairo.h>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
// vk_texture.cpp needs OpenVR, so it isn't built here; VrOverlay only holds these as (unused) members.
VulkanContext::~VulkanContext() {}
OverlayTexture::~OverlayTexture() {}
namespace {
constexpr int kBar = 46; ///< the toolbar above the panel (logical px)
constexpr double kPxPerDegree = 12.0; ///< the headset view: 100 degrees across the 1200 px panel width
constexpr double kTargetWidthM = 0.3; ///< the eye fit's target is 0.3 m wide, 2 m ahead (see vr_overlay.cpp)
constexpr double kTargetDistanceM = 2.0;
struct Image {
std::vector<uint8_t> rgba; ///< non-premultiplied
int size = 0;
cairo_surface_t* surface = nullptr;
void set(const uint8_t* data, int edge) {
rgba.assign(data, data + static_cast<size_t>(edge) * edge * 4);
size = edge;
if (surface != nullptr) cairo_surface_destroy(surface);
surface = nullptr;
}
};
struct Dot {
bool shown = false;
double x = 0, y = 0, z = -1, width = 0.02;
Image image;
};
struct Desktop {
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
SDL_Texture* texture = nullptr;
cairo_surface_t* frame = nullptr; ///< the whole window: toolbar and view
int width = 0; ///< logical size
int height = 0;
int panelHeight = 0;
cairo_surface_t* panel = nullptr;
bool dashboardOpen = true;
bool pointerInPanel = false;
bool buttonHover = false;
bool dirty = true;
Uint32 lastDraw = 0;
bool targetShown = false;
double targetYaw = 0, targetPitch = 0;
Image target;
Dot dots[2];
} gDesk;
/** Non-premultiplied RGBA into a cairo (premultiplied, native-endian ARGB) surface. */
cairo_surface_t* toSurface(const uint8_t* rgba, int width, int height) {
cairo_surface_t* surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height);
cairo_surface_flush(surface);
unsigned char* data = cairo_image_surface_get_data(surface);
const int stride = cairo_image_surface_get_stride(surface);
for (int y = 0; y < height; ++y) {
uint32_t* row = reinterpret_cast<uint32_t*>(data + static_cast<size_t>(y) * stride);
const uint8_t* in = rgba + static_cast<size_t>(y) * width * 4;
for (int x = 0; x < width; ++x, in += 4) {
const uint32_t a = in[3];
row[x] = (a << 24) | ((in[0] * a / 255) << 16) | ((in[1] * a / 255) << 8) | (in[2] * a / 255);
}
}
cairo_surface_mark_dirty(surface);
return surface;
}
cairo_surface_t* surfaceOf(Image& image) {
if (image.surface == nullptr && image.size > 0) image.surface = toSurface(image.rgba.data(), image.size, image.size);
return image.surface;
}
void rgb(cairo_t* cr, uint32_t hex, double alpha = 1.0) {
cairo_set_source_rgba(cr, ((hex >> 16) & 0xff) / 255.0, ((hex >> 8) & 0xff) / 255.0, (hex & 0xff) / 255.0, alpha);
}
void text(cairo_t* cr, double x, double y, const char* utf8, double size, uint32_t color, bool bold = false,
bool centered = false) {
cairo_select_font_face(cr, "Noto Sans", CAIRO_FONT_SLANT_NORMAL, bold ? CAIRO_FONT_WEIGHT_BOLD : CAIRO_FONT_WEIGHT_NORMAL);
cairo_set_font_size(cr, size);
if (centered) {
cairo_text_extents_t extents;
cairo_text_extents(cr, utf8, &extents);
x -= extents.width / 2 + extents.x_bearing;
}
rgb(cr, color);
cairo_move_to(cr, x, y);
cairo_show_text(cr, utf8);
}
/** The toolbar button's rectangle (logical px). */
void buttonRect(double& x, double& y, double& w, double& h) {
w = 250;
h = 32;
x = gDesk.width - w - 10;
y = (kBar - h) / 2;
}
bool inButton(double px, double py) {
double x, y, w, h;
buttonRect(x, y, w, h);
return px >= x && px <= x + w && py >= y && py <= y + h;
}
void roundedRect(cairo_t* cr, double x, double y, double w, double h, double r) {
cairo_new_sub_path(cr);
cairo_arc(cr, x + w - r, y + r, r, -M_PI / 2, 0);
cairo_arc(cr, x + w - r, y + h - r, r, 0, M_PI / 2);
cairo_arc(cr, x + r, y + h - r, r, M_PI / 2, M_PI);
cairo_arc(cr, x + r, y + r, r, M_PI, 3 * M_PI / 2);
cairo_close_path(cr);
}
/** Draw an image centered at a direction in front of you, `degrees` wide. */
void drawInView(cairo_t* cr, cairo_surface_t* image, int size, double yawDeg, double pitchDeg, double degrees) {
if (image == nullptr || size <= 0) return;
const double cx = gDesk.width / 2.0 + yawDeg * kPxPerDegree;
const double cy = kBar + gDesk.panelHeight / 2.0 - pitchDeg * kPxPerDegree;
const double px = degrees * kPxPerDegree;
cairo_save(cr);
cairo_translate(cr, cx - px / 2, cy - px / 2);
cairo_scale(cr, px / size, px / size);
cairo_set_source_surface(cr, image, 0, 0);
cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_GOOD);
cairo_paint(cr);
cairo_restore(cr);
}
void drawHeadsetView(cairo_t* cr) {
const double top = kBar;
const double h = gDesk.panelHeight;
const double w = gDesk.width;
cairo_pattern_t* sky = cairo_pattern_create_radial(w / 2, top + h / 2, 40, w / 2, top + h / 2, w * 0.75);
cairo_pattern_add_color_stop_rgb(sky, 0, 0.09, 0.11, 0.15);
cairo_pattern_add_color_stop_rgb(sky, 1, 0.02, 0.03, 0.05);
cairo_rectangle(cr, 0, top, w, h);
cairo_set_source(cr, sky);
cairo_fill(cr);
cairo_pattern_destroy(sky);
// A faint grid every 10 degrees, brighter through the middle
cairo_set_line_width(cr, 1);
for (int degree = -50; degree <= 50; degree += 10) {
rgb(cr, degree == 0 ? 0x3a4350 : 0x1c222b);
const double x = w / 2 + degree * kPxPerDegree + 0.5;
cairo_move_to(cr, x, top);
cairo_line_to(cr, x, top + h);
cairo_stroke(cr);
}
for (int degree = -30; degree <= 30; degree += 10) {
rgb(cr, degree == 0 ? 0x3a4350 : 0x1c222b);
const double y = top + h / 2 - degree * kPxPerDegree + 0.5;
cairo_move_to(cr, 0, y);
cairo_line_to(cr, w, y);
cairo_stroke(cr);
}
text(cr, w / 2 + 6, top + 18, "straight ahead", 12, 0x5d6672);
text(cr, 8, top + h / 2 - 6, "eye level", 12, 0x5d6672);
if (gDesk.targetShown) {
const double degrees = 2 * std::atan(kTargetWidthM / 2 / kTargetDistanceM) * 180 / M_PI;
drawInView(cr, surfaceOf(gDesk.target), gDesk.target.size, gDesk.targetYaw, gDesk.targetPitch, degrees);
}
bool anyDot = false;
for (Dot& dot : gDesk.dots) {
if (!dot.shown) continue;
anyDot = true;
const double distance = std::sqrt(dot.x * dot.x + dot.y * dot.y + dot.z * dot.z);
const double yaw = std::atan2(dot.x, -dot.z) * 180 / M_PI;
const double pitch = std::atan2(dot.y, std::sqrt(dot.x * dot.x + dot.z * dot.z)) * 180 / M_PI;
const double degrees = 2 * std::atan(dot.width / 2 / std::max(distance, 0.05)) * 180 / M_PI;
drawInView(cr, surfaceOf(dot.image), dot.image.size, yaw, pitch, std::max(degrees, 1.2));
}
const double bottom = top + h;
text(cr, w / 2, bottom - 44, "The dashboard is closed: this is what you would see in the headset.", 16, 0xc9d1d9, true, true);
if (gDesk.targetShown) {
text(cr, w / 2, bottom - 20, "The eye fit is running. The pretend eyes follow the dot, like you would with yours.", 14, 0x9198a1,
false, true);
} else if (anyDot) {
text(cr, w / 2, bottom - 20, "The dots show where the eyes being sent to VRChat are looking (Show gaze dots is on).", 14,
0x9198a1, false, true);
} else {
text(cr, w / 2, bottom - 20,
"Press Space to open the dashboard again. (Eye fit tab: press Fit my eyes, then close the dashboard.)", 14,
0x9198a1, false, true);
}
}
void redraw() {
if (gDesk.renderer == nullptr) return;
cairo_t* cr = cairo_create(gDesk.frame);
// Toolbar
rgb(cr, 0x0d1117);
cairo_rectangle(cr, 0, 0, gDesk.width, kBar);
cairo_fill(cr);
rgb(cr, 0x30363d);
cairo_rectangle(cr, 0, kBar - 1, gDesk.width, 1);
cairo_fill(cr);
text(cr, 14, 20, "Eye panel preview", 15, 0xe6edf3, true);
text(cr, 14, 38, "A pretend Steam Frame on this PC. Nothing is sent to VRChat.", 12.5, 0x9198a1);
double bx, by, bw, bh;
buttonRect(bx, by, bw, bh);
roundedRect(cr, bx, by, bw, bh, 7);
rgb(cr, gDesk.buttonHover ? 0xc45fe0 : 0xe27dfd);
cairo_fill(cr);
text(cr, bx + bw / 2, by + 21, gDesk.dashboardOpen ? "Close the dashboard (Space)" : "Open the dashboard (Space)", 14,
0x0d1117, true, true);
// The view
if (gDesk.dashboardOpen) {
rgb(cr, 0x0d1117);
cairo_rectangle(cr, 0, kBar, gDesk.width, gDesk.panelHeight);
cairo_fill(cr);
if (gDesk.panel != nullptr) {
cairo_set_source_surface(cr, gDesk.panel, 0, kBar);
cairo_paint(cr);
}
} else {
drawHeadsetView(cr);
}
cairo_destroy(cr);
cairo_surface_flush(gDesk.frame);
SDL_UpdateTexture(gDesk.texture, nullptr, cairo_image_surface_get_data(gDesk.frame),
cairo_image_surface_get_stride(gDesk.frame));
SDL_SetRenderDrawColor(gDesk.renderer, 0x0d, 0x11, 0x17, 0xff);
SDL_RenderClear(gDesk.renderer);
SDL_RenderCopy(gDesk.renderer, gDesk.texture, nullptr, nullptr);
SDL_RenderPresent(gDesk.renderer);
gDesk.dirty = false;
gDesk.lastDraw = SDL_GetTicks();
}
/**
* For testing and for taking pictures: PREVIEW_SCRIPT runs simple steps separated by ";":
* wait SECONDS | click X,Y (panel px) | key space | shot FILE.png | quit
*/
struct Script {
std::vector<std::string> steps;
size_t next = 0;
Uint32 resumeAt = 0;
bool loaded = false;
} gScript;
void setDashboard(bool open, VrEvents& events);
void runScript(VrEvents& events) {
if (!gScript.loaded) {
gScript.loaded = true;
if (const char* script = std::getenv("PREVIEW_SCRIPT")) {
std::string all = script;
size_t start = 0;
while (start <= all.size()) {
const size_t end = all.find(';', start);
std::string step = all.substr(start, end == std::string::npos ? std::string::npos : end - start);
const size_t first = step.find_first_not_of(" \t");
if (first != std::string::npos) gScript.steps.push_back(step.substr(first));
if (end == std::string::npos) break;
start = end + 1;
}
}
}
while (gScript.next < gScript.steps.size() && SDL_GetTicks() >= gScript.resumeAt) {
const std::string step = gScript.steps[gScript.next++];
double x = 0, y = 0, seconds = 0;
char path[512] = {};
if (std::sscanf(step.c_str(), "wait %lf", &seconds) == 1) {
gScript.resumeAt = SDL_GetTicks() + static_cast<Uint32>(seconds * 1000);
} else if (std::sscanf(step.c_str(), "click %lf,%lf", &x, &y) == 2) {
events.pointer.push_back({PointerInput::Type::Move, x, y});
events.pointer.push_back({PointerInput::Type::Down, x, y});
events.pointer.push_back({PointerInput::Type::Up, x, y});
gScript.resumeAt = SDL_GetTicks() + 150;
} else if (step == "key space") {
setDashboard(!gDesk.dashboardOpen, events);
} else if (std::sscanf(step.c_str(), "shot %511s", path) == 1) {
redraw();
const cairo_status_t result = cairo_surface_write_to_png(gDesk.frame, path);
std::fprintf(stderr, "[preview] %s %s\n", result == CAIRO_STATUS_SUCCESS ? "saved" : "could not save", path);
} else if (step == "quit") {
events.closeRequested = true;
} else {
std::fprintf(stderr, "[preview] unknown script step: %s\n", step.c_str());
}
}
}
void setDashboard(bool open, VrEvents& events) {
if (gDesk.dashboardOpen == open) return;
gDesk.dashboardOpen = open;
if (!open && gDesk.pointerInPanel) {
events.pointer.push_back({PointerInput::Type::Leave, 0, 0});
gDesk.pointerInPanel = false;
}
std::fprintf(stderr, "[preview] dashboard %s\n", open ? "opened" : "closed");
gDesk.dirty = true;
}
} // namespace
VrOverlay::VrOverlay() = default;
VrOverlay::~VrOverlay() {
shutdown();
}
VrOverlay::ConnectResult VrOverlay::connect(int width, int height, std::string& message) {
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear");
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS) != 0) {
message = std::string("SDL: ") + SDL_GetError();
return ConnectResult::Error;
}
gDesk.width = width;
gDesk.panelHeight = height;
gDesk.height = height + kBar;
gDesk.window = SDL_CreateWindow("Eye panel preview (frameeyeosc)", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
gDesk.width, gDesk.height, SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI);
if (gDesk.window == nullptr) {
message = std::string("SDL window: ") + SDL_GetError();
SDL_Quit();
return ConnectResult::Error;
}
gDesk.renderer = SDL_CreateRenderer(gDesk.window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
if (gDesk.renderer == nullptr) gDesk.renderer = SDL_CreateRenderer(gDesk.window, -1, SDL_RENDERER_SOFTWARE);
if (gDesk.renderer == nullptr) {
message = std::string("SDL renderer: ") + SDL_GetError();
return ConnectResult::Error;
}
SDL_RenderSetLogicalSize(gDesk.renderer, gDesk.width, gDesk.height);
gDesk.texture = SDL_CreateTexture(gDesk.renderer, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, gDesk.width,
gDesk.height);
gDesk.frame = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, gDesk.width, gDesk.height);
connected_ = true;
panelHeight_ = height;
std::fprintf(stderr, "[preview] window %dx%d (the panel plus a %d px toolbar)\n", gDesk.width, gDesk.height, kBar);
redraw();
return ConnectResult::Ok;
}
void VrOverlay::shutdown() {
if (!connected_) return;
connected_ = false;
for (Image* image : {&gDesk.target, &gDesk.dots[0].image, &gDesk.dots[1].image}) {
if (image->surface != nullptr) cairo_surface_destroy(image->surface);
image->surface = nullptr;
}
if (gDesk.panel != nullptr) cairo_surface_destroy(gDesk.panel);
if (gDesk.frame != nullptr) cairo_surface_destroy(gDesk.frame);
gDesk.panel = gDesk.frame = nullptr;
if (gDesk.texture != nullptr) SDL_DestroyTexture(gDesk.texture);
if (gDesk.renderer != nullptr) SDL_DestroyRenderer(gDesk.renderer);
if (gDesk.window != nullptr) SDL_DestroyWindow(gDesk.window);
gDesk.texture = nullptr;
gDesk.renderer = nullptr;
gDesk.window = nullptr;
SDL_Quit();
std::fprintf(stderr, "[preview] window closed\n");
}
VrEvents VrOverlay::pollEvents() {
VrEvents events;
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_QUIT:
// Like the dashboard icon's "close": the panel quits
events.closeRequested = true;
break;
case SDL_KEYDOWN:
if (event.key.repeat == 0 && (event.key.keysym.sym == SDLK_SPACE || event.key.keysym.sym == SDLK_TAB ||
event.key.keysym.sym == SDLK_ESCAPE)) {
setDashboard(event.key.keysym.sym == SDLK_ESCAPE ? false : !gDesk.dashboardOpen, events);
}
break;
case SDL_MOUSEMOTION: {
const double x = event.motion.x, y = event.motion.y;
const bool hover = inButton(x, y);
if (hover != gDesk.buttonHover) {
gDesk.buttonHover = hover;
gDesk.dirty = true;
}
if (gDesk.dashboardOpen && y >= kBar) {
events.pointer.push_back({PointerInput::Type::Move, x, y - kBar});
gDesk.pointerInPanel = true;
} else if (gDesk.pointerInPanel) {
events.pointer.push_back({PointerInput::Type::Leave, 0, 0});
gDesk.pointerInPanel = false;
}
break;
}
case SDL_MOUSEBUTTONDOWN:
if (event.button.button != SDL_BUTTON_LEFT) break;
if (inButton(event.button.x, event.button.y)) {
setDashboard(!gDesk.dashboardOpen, events);
} else if (gDesk.dashboardOpen && event.button.y >= kBar) {
events.pointer.push_back({PointerInput::Type::Down, double(event.button.x), double(event.button.y - kBar)});
} else if (!gDesk.dashboardOpen && event.button.y >= kBar) {
// Clicking into the headset view opens the dashboard again, like the system button
setDashboard(true, events);
}
break;
case SDL_MOUSEBUTTONUP:
if (event.button.button == SDL_BUTTON_LEFT && gDesk.dashboardOpen && event.button.y >= kBar) {
events.pointer.push_back({PointerInput::Type::Up, double(event.button.x), double(event.button.y - kBar)});
}
break;
case SDL_WINDOWEVENT:
if (event.window.event == SDL_WINDOWEVENT_LEAVE && gDesk.pointerInPanel) {
events.pointer.push_back({PointerInput::Type::Leave, 0, 0});
gDesk.pointerInPanel = false;
}
gDesk.dirty = true;
break;
default: break;
}
}
runScript(events);
// The headset view moves (the fit's dot, the gaze dots) without the panel changing, so it is redrawn
// regularly; the panel view only when something changed.
const Uint32 now = SDL_GetTicks();
if (gDesk.dirty || (!gDesk.dashboardOpen && now - gDesk.lastDraw >= 11) || now - gDesk.lastDraw >= 500) redraw();
return events;
}
bool VrOverlay::steamVrAlive() const {
return true;
}
bool VrOverlay::panelVisible() const {
return gDesk.dashboardOpen;
}
void VrOverlay::showPanel() {
VrEvents ignored;
setDashboard(true, ignored);
if (gDesk.window != nullptr) SDL_RaiseWindow(gDesk.window);
}
bool VrOverlay::dashboardVisible() const {
return gDesk.dashboardOpen;
}
double VrOverlay::userIpdMeters() const {
return gaze_fit::kDefaultIpdM;
}
bool VrOverlay::showTarget(double yawDeg, double pitchDeg, const uint8_t* rgba, int size) {
if (rgba != nullptr) gDesk.target.set(rgba, size);
if (!gDesk.targetShown || yawDeg != gDesk.targetYaw || pitchDeg != gDesk.targetPitch || rgba != nullptr) gDesk.dirty = true;
gDesk.targetShown = true;
gDesk.targetYaw = yawDeg;
gDesk.targetPitch = pitchDeg;
return true;
}
bool VrOverlay::waitFrameSync(uint32_t /*timeoutMs*/) {
return false;
}
void VrOverlay::hideTarget() {
if (gDesk.targetShown) gDesk.dirty = true;
gDesk.targetShown = false;
}
bool VrOverlay::showDot(int index, double x, double y, double z, double /*yawDeg*/, double /*pitchDeg*/, const uint8_t* rgba,
int size, bool newImage, double widthM) {
if (index < 0 || index > 1) return false;
Dot& dot = gDesk.dots[index];
if (newImage || dot.image.size == 0) dot.image.set(rgba, size);
dot.shown = true;
dot.x = x;
dot.y = y;
dot.z = z;
dot.width = widthM;
return true;
}
void VrOverlay::hideDot(int index) {
if (index >= 0 && index <= 1) gDesk.dots[index].shown = false;
}
bool VrOverlay::submitThumbnail(const uint8_t* rgba, int size) {
if (gDesk.window == nullptr) return false;
// The dashboard thumbnail (the eye) becomes the window icon
SDL_Surface* icon = SDL_CreateRGBSurfaceWithFormatFrom(const_cast<uint8_t*>(rgba), size, size, 32, size * 4,
SDL_PIXELFORMAT_RGBA32);
if (icon == nullptr) return false;
SDL_SetWindowIcon(gDesk.window, icon);
SDL_FreeSurface(icon);
return true;
}
bool VrOverlay::submitPanel(const uint8_t* rgba) {
if (gDesk.panel != nullptr) cairo_surface_destroy(gDesk.panel);
gDesk.panel = toSurface(rgba, gDesk.width, gDesk.panelHeight);
gDesk.dirty = true;
return true;
}
void VrOverlay::logOverlayState(const char* when) const {
std::fprintf(stderr, "[preview] panel shown in a desktop window (%s)\n", when);
}
int VrOverlay::probe() {
std::printf("--probe needs SteamVR; this is the desktop preview build\n");
return 1;
}
int VrOverlay::switchAway(double /*seconds*/) {
std::printf("--probe-switch-away needs SteamVR; this is the desktop preview build\n");
return 1;
}
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Try the Steam Frame's "Eye" panel (frameeyeosc-panel) on this Linux PC, with a pretend headset:
# fake-eye-server plays the Frame's eye tracker (made-up eyes that look around and blink)
# frameeyeosc the real program that runs on the headset (built for this PC)
# the Eye panel the real panel, in a window instead of the SteamVR dashboard
# Everything lives in a scratch folder (settings, status), so nothing on this PC is changed, and
# frameeyeosc runs in its own empty network, so it can't reach VRChat or anything else.
#
# ./run-panel-preview.sh start (close the window or press "Quit app" in the panel to stop)
# ./run-panel-preview.sh --reset start over with default settings
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
bin="$here/bin"
for program in fake-eye-server frameeyeosc frameeyeosc-panel-preview; do
if [[ ! -x "$bin/$program" ]]; then
echo "Missing $bin/$program. Build it first (see README.md)." >&2
exit 1
fi
done
runtime="${XDG_RUNTIME_DIR:-/tmp}"
sandbox="$runtime/frameeye-preview"
if [[ "${1:-}" == "--reset" ]]; then
rm -rf "$sandbox"
echo "Settings reset."
fi
mkdir -p "$sandbox/home" "$sandbox/config/frameeyeosc" "$sandbox/run"
chmod 700 "$sandbox" "$sandbox/run"
# One preview at a time: a second pretend eye tracker would replace the first one's shared memory
exec 9>"$sandbox/.lock"
if ! flock -n 9; then
echo "The Eye panel preview is already open."
exit 0
fi
# First start: send to "this PC" as a fixed address, as if the headset streamed to it
config="$sandbox/config/frameeyeosc/config.json"
if [[ ! -f "$config" ]]; then
printf '{\n "host": "127.0.0.1"\n}\n' > "$config"
fi
# The panel's update button calls this; in the preview it only ever says "up to date"
updater="$sandbox/home/.local/share/frameeyeosc/frame-update.sh"
mkdir -p "$(dirname "$updater")"
cat > "$updater" <<'EOF'
#!/bin/sh
# Preview stand-in for frameeyeosc's updater: never downloads or installs anything.
current=0.5.3
while [ $# -gt 0 ]; do [ "$1" = "--current" ] && current="$2"; shift; done
printf '{"status":"up-to-date","current":"%s","latest":"%s","url":"https://github.com/sasaken1102r/frameeyeosc/releases","cached":false,"checked_at":%s}\n' \
"$current" "$current" "$(date +%s)"
EOF
chmod 755 "$updater"
env_sandbox=(HOME="$sandbox/home" XDG_CONFIG_HOME="$sandbox/config" XDG_CACHE_HOME="$sandbox/home/.cache")
pids=()
cleanup() {
for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; done
wait 2>/dev/null || true
echo "Preview stopped."
}
trap cleanup EXIT INT TERM
echo "Starting the pretend eye tracker..."
env "${env_sandbox[@]}" XDG_RUNTIME_DIR="$sandbox/run" "$bin/fake-eye-server" &
pids+=($!)
sleep 0.3
echo "Starting frameeyeosc (in its own empty network, so nothing reaches VRChat)..."
isolate=(unshare --user --map-root-user --net sh -c 'ip link set lo up 2>/dev/null; exec "$0" "$@"')
if ! unshare --user --map-root-user --net true 2>/dev/null; then
# No user namespaces: pin the destination to a port nothing uses, and lock it from the panel instead
echo " (can't isolate its network here; sending to 127.0.0.1:9019 instead, locked)"
isolate=()
daemon_args=(--target 127.0.0.1:9019)
else
daemon_args=()
fi
env "${env_sandbox[@]}" XDG_RUNTIME_DIR="$sandbox/run" "${isolate[@]}" "$bin/frameeyeosc" "${daemon_args[@]}" &
pids+=($!)
sleep 0.5
echo "Opening the Eye panel. Space closes/opens the pretend dashboard."
# The panel keeps the real runtime dir for the window (Wayland) and sounds (PipeWire); its settings
# and status come from the scratch folder
env "${env_sandbox[@]}" "$bin/frameeyeosc-panel-preview" --status "$sandbox/run/frameeyeosc/status.json" || true