Files
SteamFramelyHomebrew--framely/native/host.cpp
T
toorux d4af27d63b Add in-headset review for app-requested APK updates
Intercept content, file and PackageInstaller requests in managed Lepton containers and route retained APKs through the normal review, backup and installation flow. Persist presentation receipts, retry delivery, and reopen repeated requests without duplicating packages.

Use authenticated localized update windows with visible laser cursors, and switch native Steam runtime aliases back to stable context names for installation. Verified all three request paths and a live Steam-runtime reinstall on Frame with backup creation and unchanged save data.
2026-10-24 22:05:47 +08:00

1315 lines
130 KiB
C++

#include "include/capi/cef_dialog_handler_capi.h"
#include "include/capi/cef_app_capi.h"
#include "include/capi/cef_image_capi.h"
#include "include/capi/cef_parser_capi.h"
#include "include/capi/cef_values_capi.h"
#include "include/capi/cef_client_capi.h"
#include "include/cef_api_hash.h"
#include "vendor/json.hpp"
#include "dock_uv_geometry.h"
#include "menu_geometry.h"
#include "menu_input.h"
#include "entry_hold.h"
#include "launcher_input.h"
#include "launcher_fade.h"
#include "launcher_priority.h"
#include "launcher_pointer.h"
#include "launcher_gaze.h"
#include "gaze_geometry.h"
#include "page_scroll.h"
#include "page_scroll_injection.h"
#include "include/capi/cef_load_handler_capi.h"
#include <filesystem>
#include "overlay_placement.h"
#include "render_resolution.h"
#include "render_timing.h"
#include "paint_buffer.h"
#include "notification_geometry.h"
#include "notification_badge.h"
#include "keyboard_state.h"
#include "keyboard_input.h"
#include "brand_mask.h"
#include "ui_visibility.h"
#include "ui_feedback.h"
#include "recording_indicator.h"
#include "recording_region.h"
#include "recording_region_depth.h"
#include "system_radial.h"
#include "framing_lifecycle.h"
#include "plugin_navigation.h"
#include <GL/gl.h>
#include <GL/glx.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <algorithm>
#include <atomic>
#include <csignal>
#include <chrono>
#include <fstream>
#include <iostream>
#include <map>
#include <memory>
#include <mutex>
#include <thread>
#include <deque>
#include <set>
#include <unistd.h>
using json=nlohmann::json;
static volatile sig_atomic_t running=1;
static void stop(int){running=0;}
static std::string origin,runtime,token,boot_url;
static vr::IVROverlay* overlays=nullptr;
static vr::IVRSystem* vr_system=nullptr;
static Display* xdisplay=nullptr;
static GLXContext glcontext=nullptr;
static GLXPbuffer glsurface=0;
static std::atomic<unsigned> notification_badge_count{0};
static bool launcher_open=false;
static bool ui_haptics=true,ui_sounds=false;
static SteamUiSounds ui_audio;
static LauncherFade launcher_fade;
static std::chrono::steady_clock::time_point launcher_close_at{};
static LauncherDismissGuard launcher_dismiss;
static LauncherDockGuard launcher_dock;
static EntryTrigger launcher_close_button;
static LauncherStick launcher_stick;
static PageScrollStick page_scroll_stick;
static vr::VROverlayHandle_t page_scroll_target=0;
static std::chrono::steady_clock::time_point launcher_outside_at{};
static bool launcher_selected=false;
static uint64_t launcher_session=0;
static std::atomic<bool> launcher_prioritize_ui{true};
static std::atomic<bool> launcher_priority_active{false};
static std::string launcher_previous_dashboard;
static std::set<std::string> dashboard_keys;
static bool launcher_restore_previous=true,launcher_previous_menu=false;
static std::chrono::steady_clock::time_point launcher_keyboard_back_until{};
static EntryHold entry_hold;
static uint64_t entry_source_buttons=0;
static std::map<unsigned,uint64_t> entry_previous_buttons;
static bool entry_actions_ready=false;
static vr::VRActionSetHandle_t entry_set=0,gaze_set=0;
static vr::VRActionHandle_t primary_action=0,upper_action=0,aim_action=0,close_action=0,navigate_action=0;
static vr::VRInputValueHandle_t hands[2]{};
static vr::VRActionHandle_t gaze_action=0,confirm_action=0,gaze_haptic_action=0;
static LauncherGazePress gaze_press;
static bool gaze_active=false,launcher_calibrating=false;
static json gaze_correction=nullptr,gaze_preview=nullptr;
static std::string gaze_target_id;
static float gaze_target_u=.5f,gaze_target_v=.5f;
static vr::HmdVector3_t gaze_target_world{};
static bool gaze_target_cached=false;
static bool gaze_matrix(const json& config,double (&m)[6]){
if(!config.is_object()||config.value("space",std::string{})!="headAngles"||!config.contains("matrix")||!config["matrix"].is_array()||config["matrix"].size()!=6)return false;
for(int i=0;i<6;i++){if(!config["matrix"][i].is_number())return false;m[i]=config["matrix"][i].get<double>();if(!std::isfinite(m[i])||std::abs(m[i])>4)return false;}
double det=m[0]*m[4]-m[1]*m[3];return det>=.25&&det<=4&&std::abs(m[2])<=.5&&std::abs(m[5])<=.5;
}
static int gaze_feedback_hand=1;
static std::chrono::steady_clock::time_point gaze_sent{},gaze_feedback_at{};
static EntryTrigger entry_buttons[2][2];
static EntryAnalogTrigger entry_analog[2];
static bool framing_trigger_known[2]{},framing_trigger_down[2]{};
static EntryPointer entry_pointer;
static bool menu_open=false,keyboard_open=false,keyboard_close_pending=false;
static float notification_curve_radius=1.3f;
static bool keyboard_toolbar=false,keyboard_chars=false,keyboard_done=false;
static MenuButtonInput menu_button;
static std::map<unsigned,uint64_t> menu_previous_buttons;
static cef_frame_t* keyboard_frame=nullptr;
static std::string keyboard_view;
static KeyboardMenuGuard keyboard_menu;
static uint64_t keyboard_token=0;static vr::VROverlayHandle_t keyboard_target=0;
constexpr const char* frame_keyboard_window_key="framely.window.tooru.frame-keyboard.keyboard";
struct ExternalKeyboard {
bool active=false,hide_pending=false;
vr::VROverlayHandle_t target=0;
uint64_t token=0;
std::string overlay_key,view_key;
std::chrono::steady_clock::time_point hide_pending_until{};
};
static ExternalKeyboard external_keyboard;
static std::mutex queue_mutex;
static std::mutex visibility_mutex;
static json visible_views=nullptr;
static std::deque<json> queue;
template<class T> struct Handler {
T api{};std::atomic<int> refs{1};
Handler(){api.base.size=sizeof(T);api.base.add_ref=[](cef_base_ref_counted_t* p){++reinterpret_cast<Handler*>(p)->refs;};api.base.release=[](cef_base_ref_counted_t* p){--reinterpret_cast<Handler*>(p)->refs;return 0;};api.base.has_one_ref=[](cef_base_ref_counted_t* p){return int(reinterpret_cast<Handler*>(p)->refs==1);};api.base.has_at_least_one_ref=[](cef_base_ref_counted_t* p){return int(reinterpret_cast<Handler*>(p)->refs>0);};}
T* acquire(){api.base.add_ref(&api.base);return &api;}
};
#include "cast_frame.h"
struct View {
CastFrame cast_frame;
json region_geometry=json::object();
std::string key,plugin,title,region_editor_id;int width=600,height=840;float render_scale=1.f;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false,framing_left_down=false;
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_at{},activation_at{};
cef_mouse_event_t pointer{};bool pointer_valid=false,stick_scrolling=false;vr::TrackedDeviceIndex_t pointer_device=vr::k_unTrackedDeviceIndexInvalid;std::chrono::steady_clock::time_point pointer_at{};
cef_file_dialog_callback_t* file_dialog=nullptr;std::string file_dialog_id;bool file_multiple=false,file_directory=false;
bool active_seen=false,notification_content=false,notification_requested=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
vr::VROverlayHandle_t overlay=0,thumbnail=0;GLuint textures[2]{};int texture_slot=0;bool texture_ready[2]{};PaintDamage texture_damage[2];std::vector<uint8_t> pixels;
};
static std::map<std::string,std::unique_ptr<View>> views;
static void close_view(View& v);
static bool permission_trigger_held[2]{};
static View* find_browser(cef_browser_t* b){int id=b->get_identifier(b);for(auto& [key,v]:views)if(v->browser_id==id)return v.get();return nullptr;}
static Handler<cef_client_t> client;
static Handler<cef_render_handler_t> render_handler;
static Handler<cef_dialog_handler_t> dialog_handler;
static unsigned long long file_dialog_sequence=0;
static void cancel_file_dialog(View& v){if(!v.file_dialog)return;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();callback->cancel(callback);callback->base.release(&callback->base);}
static Handler<cef_life_span_handler_t> life_handler;
static Handler<cef_display_handler_t> display_handler;
static Handler<cef_request_handler_t> request_handler;
static Handler<cef_load_handler_t> load_handler;
static void str(cef_string_t& o,const std::string& s){cef_string_utf8_to_utf16(s.data(),s.size(),&o);}
static std::string text(const cef_string_t* s){cef_string_utf8_t o{};cef_string_utf16_to_utf8(s->str,s->length,&o);std::string r(o.str,o.length);cef_string_utf8_clear(&o);return r;}
static void execute(cef_frame_t* f,const std::string& code){if(!f||!f->is_valid(f))return;cef_string_t script{},url{};str(script,code);f->execute_java_script(f,&script,&url,1);cef_string_utf16_clear(&script);}
// Steam Frame's BarSurface/BarTab dimensions and state colors (80px, radius 6px).
static std::vector<uint8_t> icon(bool hover,bool active){
std::vector<uint8_t> p(128*128*4);
auto rounded=[](float x,float y,float half,float radius){float a=std::abs(x)-half+radius,b=std::abs(y)-half+radius;return std::hypot(std::max(a,0.f),std::max(b,0.f))+std::min(std::max(a,b),0.f)-radius;};
for(int y=0;y<128;y++)for(int x=0;x<128;x++){
float coverage=std::clamp(.5f-rounded(x-63.5f,y-63.5f,64.f,9.6f),0.f,1.f);
int gx=std::min(47,int((x+.5f)*48/128)),gy=std::min(47,int((y+.5f)*48/128));
float mark=framely_brand::mask[gy*48+gx]/255.f;
int i=(y*128+x)*4;
const int normal[3]={23,25,28},highlight[3]={32,35,40},blue[3]={147,197,237};
for(int c=0;c<3;c++){float bg=(hover||active)?highlight[c]:normal[c];p[i+c]=uint8_t(bg+(blue[c]-bg)*mark);}
// 32px-wide, 6px-high tab indicator is clipped to its upper 3px by the BarSurface.
if(active){float qx=std::abs(x-63.5f)-20.8f,qy=std::abs(y-129.6f);float line=std::clamp(5.3f-std::hypot(std::max(qx,0.f),qy),0.f,1.f);const int blue[3]={26,159,255};for(int c=0;c<3;c++)p[i+c]=uint8_t(p[i+c]+(blue[c]-p[i+c])*line);}
p[i+3]=uint8_t(255*coverage);
}
return p;
}
static std::vector<std::string> closed_casts;
// Protected by visibility_mutex (outgoing request) / queue_mutex (status).
static std::string pending_recording_stop;
static json recording_state=json::object();
static json recording_region_state=json::object();
static json framing_editor_state=nullptr;
static json screenshot_editor_state=nullptr;
static json screenshot_shortcut_preview=nullptr;
static bool screenshot_radial_held=false;
static bool system_radial_available=false;
static std::atomic<bool> system_radial_enabled{true};
static SystemRadial system_radial;
static bool system_radial_preview_prepared=false;
static bool system_radial_progress_visible=false;
static vr::HmdVector3_t system_radial_origin{};
static vr::HmdVector3_t system_radial_right{};
static vr::HmdVector3_t system_radial_up{};
static bool system_radial_origin_valid=false;
static vr::HmdMatrix34_t system_radial_pose[2]{};
static SystemRadial::Clock::time_point system_radial_pose_at[2]{};
static vr::TrackedDeviceIndex_t system_radial_pose_device[2]{vr::k_unTrackedDeviceIndexInvalid,vr::k_unTrackedDeviceIndexInvalid};
static std::chrono::steady_clock::time_point system_radial_sent{};
static std::string pending_screenshot_hotkey,pending_screenshot_close,pending_system_action;
static uint64_t system_action_sequence=0;
static RecordingRegionOverlay screenshot_region_overlay{"framely.screenshot.region"};
static std::string pending_region_close;
static std::string pending_apk_update_close;
static bool apk_prompt(const std::string& key){return key=="framely.apk.permission"||key=="framely.apk.update";}
static RecordingRegionOverlay recording_region_overlay;
static RecordingIndicator recording_indicator;
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;std::string stop_id,region_closed,screenshot_closed,hotkey,system_action,apk_update_closed;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();stop_id=pending_recording_stop;region_closed=pending_region_close;apk_update_closed=pending_apk_update_close;screenshot_closed=pending_screenshot_close;hotkey=pending_screenshot_hotkey;system_action=pending_system_action;payload=json{{"views",visible_views},{"closedCasts",closed_casts},{"stopRecording",stop_id},{"framingClosed",region_closed},{"screenshotClosed",screenshot_closed},{"apkUpdateClosed",apk_update_closed},{"screenshotHotkey",hotkey},{"radialAction",system_action}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard<std::mutex> lock(visibility_mutex);if(pending_region_close==region_closed)pending_region_close.clear();if(pending_apk_update_close==apk_update_closed)pending_apk_update_close.clear();if(pending_screenshot_close==screenshot_closed)pending_screenshot_close.clear();if(pending_screenshot_hotkey==hotkey)pending_screenshot_hotkey.clear();if(pending_system_action==system_action)pending_system_action.clear();if(pending_recording_stop==stop_id)pending_recording_stop.clear();closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
static json service_call(const std::string& method,const json& params){
const char* configured=std::getenv("FRAMELY_CONTROL_SOCKET");
const std::string path=configured&&*configured?configured:"/run/framely/control.sock";
sockaddr_un address{};address.sun_family=AF_UNIX;
if(path.size()>=sizeof(address.sun_path))throw std::runtime_error("Control socket path is too long");
std::strncpy(address.sun_path,path.c_str(),sizeof(address.sun_path)-1);
const int fd=socket(AF_UNIX,SOCK_STREAM|SOCK_CLOEXEC,0);
if(fd<0)throw std::runtime_error("Control socket failed");
timeval timeout{2,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));
if(connect(fd,reinterpret_cast<sockaddr*>(&address),sizeof(address))<0){close(fd);throw std::runtime_error("Control service unavailable");}
const std::string request=json{{"method",method},{"params",params}}.dump()+"\n";
size_t sent=0;
while(sent<request.size()){
const auto count=send(fd,request.data()+sent,request.size()-sent,MSG_NOSIGNAL);
if(count<=0){close(fd);throw std::runtime_error("Control request failed");}
sent+=static_cast<size_t>(count);
}
std::string response;char buffer[4096];
while(response.find('\n')==std::string::npos){
const auto count=recv(fd,buffer,sizeof(buffer),0);
if(count<=0){close(fd);throw std::runtime_error("Control response failed");}
response.append(buffer,static_cast<size_t>(count));
if(response.size()>1024*1024){close(fd);throw std::runtime_error("Control response is too large");}
}
close(fd);
auto reply=json::parse(response.substr(0,response.find('\n')));
if(reply.contains("error"))throw std::runtime_error(reply["error"].get<std::string>());
return reply.value("result",json(nullptr));
}
static bool frame_keyboard_command(const std::string& method,const json& params){
try{
auto result=service_call("plugin.call",json{{"plugin","tooru.frame-keyboard"},{"method",method},{"params",params}});
if(result.is_null())return false;
if(result.is_object()&&result.contains("ok")&&result["ok"].is_boolean()&&!result["ok"].get<bool>()){
std::cerr<<"Frame keyboard "<<method<<" returned ok=false";
if(result.contains("error")&&result["error"].is_string())std::cerr<<": "<<result["error"].get<std::string>();
std::cerr<<"\n";
return false;
}
return true;
}catch(const std::exception& error){
std::cerr<<"Frame keyboard command failed: "<<error.what()<<"\n";
return false;
}
}
static bool plugin_handles_external_keyboard(){
// Version 0.3 of the plugin listens to OpenVR global keyboard events itself.
// Cache the capability briefly so a missing plugin still falls back quickly.
static std::atomic<int> cached{-1};static std::atomic<long long> checked_at{0};
const auto now=std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
if(cached.load()>=0&&now-checked_at.load()<5000)return cached.load()==1;
bool enabled=false;
try{
auto result=service_call("plugin.call",json{{"plugin","tooru.frame-keyboard"},{"method","keyboardInterception"},{"params",json::object()}});
enabled=result.is_object()&&result.value("enabled",false);
}catch(...){}
cached.store(enabled?1:0);checked_at.store(now);return enabled;
}
// Frame renders the native laser cursor in the default overlay sort group.
// Raising a panel above that group can obscure the runtime cursor.
// OpenVR owns and renders the laser cursor. Coordinates must remain in the
// runtime's overlay mouse space, before conversion to CEF's top-left origin.
// Keep a wider curved mesh around the narrow button; transparent margins never hit input.
static constexpr int dock_canvas_width=640,dock_canvas_height=128,dock_icon_left=256;
static std::vector<uint8_t> dock_icon(bool hover,bool active,float progress=0){
auto source=icon(hover,active);entry_progress_border(source,progress);notification_badge(source,notification_badge_count.load());std::vector<uint8_t> canvas(dock_canvas_width*dock_canvas_height*4,0);
for(int row=0;row<dock_canvas_height;row++)std::copy_n(source.data()+row*128*4,128*4,canvas.data()+(row*dock_canvas_width+dock_icon_left)*4);
return canvas;
}
// Keep texture storage alive while OpenVR samples the preceding frame. Raw
// overlay replacement during a hold can disturb the image and hover target.
struct DockIconTexture {
GLuint textures[2]{};int slot=0;std::vector<uint8_t> upload;
void init(){
glGenTextures(2,textures);
for(auto texture:textures){
glBindTexture(GL_TEXTURE_2D,texture);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,dock_canvas_width,dock_canvas_height,0,GL_RGBA,GL_UNSIGNED_BYTE,nullptr);
}
}
bool submit(vr::VROverlayHandle_t overlay,const std::vector<uint8_t>& pixels){
glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);
const int next=1-slot;glBindTexture(GL_TEXTURE_2D,textures[next]);
glPixelStorei(GL_UNPACK_ALIGNMENT,4);glPixelStorei(GL_UNPACK_ROW_LENGTH,0);
glPixelStorei(GL_UNPACK_SKIP_PIXELS,0);glPixelStorei(GL_UNPACK_SKIP_ROWS,0);
// Raster icons use top-left rows; OpenVR OpenGL textures use bottom-left.
merge_paint(upload,pixels.data(),dock_canvas_width,dock_canvas_height,0,static_cast<const cef_rect_t*>(nullptr),true);
glTexSubImage2D(GL_TEXTURE_2D,0,0,0,dock_canvas_width,dock_canvas_height,GL_RGBA,GL_UNSIGNED_BYTE,upload.data());
glFinish();vr::Texture_t texture{reinterpret_cast<void*>(uintptr_t(textures[next])),vr::TextureType_OpenGL,vr::ColorSpace_Auto};
const auto error=overlays->SetOverlayTexture(overlay,&texture);glFlush();
if(error){std::cerr<<"Dock texture submission failed "<<error<<"\n";return false;}
slot=next;return true;
}
void destroy(){glDeleteTextures(2,textures);}
};
static vr::VROverlayHandle_t active_main_window(){
static auto refreshed=std::chrono::steady_clock::time_point{};static vr::VROverlayHandle_t selected=0;
auto now=std::chrono::steady_clock::now();if(now-refreshed<std::chrono::milliseconds(500)&&selected&&overlays->IsActiveDashboardOverlay(selected))return selected;
refreshed=now;selected=0;
overlays->FindOverlay("valve.steam.gamepadui.main",&selected);
return selected;
}
struct RecordingRegionPresentation {json view;double distance=2.;};
static RecordingRegionPresentation recording_region_presentation(const json& original,bool freeze_depth=false){
RecordingRegionPresentation result{original,2.};
// This changes stereo presentation only, never the recorded angular crop.
// A center-ray hit measures real UI depth, including tilted/curved windows.
static auto sampled=std::chrono::steady_clock::time_point{};
static vr::HmdMatrix34_t left{},right{};static bool eyes_valid=false,measured=false;
static int previous_eye=-1;static double distance=2.;
try{
int eye=original.at("eye");if(eye<0||eye>1)return result;
vr::HmdMatrix34_t capture{};
for(int i=0;i<3;i++)for(int j=0;j<4;j++)capture.m[i][j]=original.at("captureToHead").at(i).at(j).get<float>();
auto now=std::chrono::steady_clock::now();bool probe=now-sampled>=std::chrono::milliseconds(60);
if(probe){left=vr_system->GetEyeToHeadTransform(vr::Eye_Left);right=vr_system->GetEyeToHeadTransform(vr::Eye_Right);eyes_valid=true;sampled=now;}
if(eyes_valid&&recording_region_refresh_eye_origin(static_cast<vr::EVREye>(eye),left,right,capture)){
for(int i=0;i<3;i++)result.view["captureToHead"][i][3]=capture.m[i][3];
}
if(eye!=previous_eye){previous_eye=eye;distance=2.;measured=false;probe=true;}
if(probe&&!freeze_depth){
vr::TrackedDevicePose_t poses[1]{};
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,1);
if(poses[0].bPoseIsValid&&poses[0].bDeviceIsConnected){
using namespace recording_region_depth_detail;
const auto& head=poses[0].mDeviceToAbsoluteTracking;
const double cx=original.at("centerTanX"),cy=original.at("centerTanY");
auto origin=add(translation(head),rotate(head,translation(capture)));
auto direction=rotate(head,rotate(capture,{cx,-cy,-1.}));
double length=std::sqrt(dot(direction,direction));
if(std::isfinite(length)&&length>1e-6){
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;
for(int i=0;i<3;i++){ray.vSource.v[i]=origin[i];ray.vDirection.v[i]=direction[i]/length;}
std::set<vr::VROverlayHandle_t> candidates;
for(const char* key:{"valve.steam.gamepadui.main","valve.steam.gamepadui.fallback"}){vr::VROverlayHandle_t handle=0;if(!overlays->FindOverlay(key,&handle))candidates.insert(handle);}
for(const auto& key:dashboard_keys)if(key.rfind("valve.steam.",0)==0&&key!="valve.steam.gamepadui.bar"){vr::VROverlayHandle_t handle=0;if(!overlays->FindOverlay(key.c_str(),&handle))candidates.insert(handle);}
for(const auto& [key,v]:views)if(!v->closing&&v->shown&&(v->dashboard||key.rfind("framely.window.",0)==0||key.rfind("framely.cast.",0)==0)&&key!="launcher")candidates.insert(v->overlay);
std::optional<double> nearest;
for(auto handle:candidates){
if(!overlays->IsOverlayVisible(handle))continue;
vr::VROverlayIntersectionResults_t hit{};
if(!overlays->ComputeOverlayIntersection(handle,&ray,&hit)||!std::isfinite(hit.vUVs.v[0])||!std::isfinite(hit.vUVs.v[1])||hit.vUVs.v[0]<0||hit.vUVs.v[0]>1||hit.vUVs.v[1]<0||hit.vUVs.v[1]>1)continue;
auto depth=recording_region_hit_depth(head,capture,cx,cy,hit.vPoint);
if(depth&&(!nearest||*depth<*nearest))nearest=depth;
}
if(nearest){
if(!measured){std::cout<<"Recording frame measured UI depth "<<*nearest<<" m, IPD "<<std::sqrt(dot(subtract(translation(right),translation(left)),subtract(translation(right),translation(left))))*1000<<" mm\n";distance=*nearest;measured=true;}
else distance+=(*nearest-distance)*.4;
}
// If a center ray temporarily leaves a window, retain its last measured
// depth rather than snapping back to 2 m. Without any UI measurement,
// 2 m remains an explicit approximation for arbitrary game scenes.
}
}
}
result.distance=distance;
}catch(...){return {original,2.};}
return result;
}
static void update_framing_placement(View& v){
if(v.closing||!v.region_geometry.is_object()||!v.region_geometry.contains("view"))return;
auto presentation=recording_region_presentation(v.region_geometry["view"],v.framing_left_down);
RecordingRegionPlane plane;
if(!recording_region_plane(presentation.view,v.region_geometry.at("referenceTan"),0,0,1,plane,presentation.distance))return;
overlays->SetOverlayWidthInMeters(v.overlay,plane.width);
overlays->SetOverlayTransformTrackedDeviceRelative(v.overlay,vr::k_unTrackedDeviceIndex_Hmd,&plane.transform);
}
static void hover_haptic(vr::VROverlayHandle_t overlay,std::chrono::steady_clock::time_point& last,bool activation=false){
auto now=std::chrono::steady_clock::now();if(now-last<std::chrono::milliseconds(80)||!overlays->IsOverlayVisible(overlay)||!overlays->IsHoverTargetOverlay(overlay))return;
last=now;if(ui_sounds)ui_audio.play(activation);
if(ui_haptics&&!activation){auto e=overlays->TriggerLaserMouseHapticVibration(overlay,.012f,120.f,.15f);if(e)std::cerr<<"Hover haptic failed "<<overlay<<" "<<e<<"\n";}
}
static void laser_cursor(vr::VROverlayHandle_t overlay,const vr::VREvent_t& event){
if(event.eventType==vr::VREvent_MouseMove){vr::HmdVector2_t position{{event.data.mouse.x,event.data.mouse.y}};overlays->SetOverlayCursorPositionOverride(overlay,&position);}
else if(event.eventType==vr::VREvent_FocusLeave)overlays->ClearOverlayCursorPositionOverride(overlay);
}
static void update_menu_modal(){
if(!views.count("menu"))return;
auto& menu=*views.at("menu");
bool toast=views.count("notifications")&&views.at("notifications")->shown&&!views.at("notifications")->closing;
// A modal quick menu must not monopolize the laser while a toast needs input.
overlays->SetOverlayFlag(menu.overlay,vr::VROverlayFlags_WantsModalBehavior,menu.shown&&!toast&&!(keyboard_open&&keyboard_view=="menu"));
}
static void show(View& v,bool shown){
if(v.closing)return;
bool actual=overlays->IsOverlayVisible(v.overlay);
if(v.key=="notifications"&&(shown!=v.shown||shown!=actual)){
auto e=overlays->SetOverlayInputMethod(v.overlay,shown?vr::VROverlayInputMethod_Mouse:vr::VROverlayInputMethod_None);
if(!e)e=overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,shown);
if(e){std::cerr<<"Notification input state failed "<<e<<"\n";shown=false;overlays->SetOverlayInputMethod(v.overlay,vr::VROverlayInputMethod_None);}
}
if(shown!=actual){auto e=shown?overlays->ShowOverlay(v.overlay):overlays->HideOverlay(v.overlay);if(e){std::cerr<<"Overlay visibility failed "<<v.key<<" "<<e<<"\n";return;}}
if(shown!=v.shown){
if(!shown){overlays->ClearOverlayCursorPositionOverride(v.overlay);if(v.key=="notifications"&&v.notification_click.active){cef_mouse_event_t outside{};outside.x=outside.y=-1;v.host->send_mouse_click_event(v.host,&outside,MBT_LEFT,1,1);v.notification_click.clear();}}
if(v.key!="notifications"&&v.key!="framely.system.radial"&&v.key!="framely.system.radial.progress")v.host->was_hidden(v.host,!shown);
v.shown=shown;if(shown)v.host->invalidate(v.host,PET_VIEW);
if(v.key=="menu"||v.key=="notifications")update_menu_modal();
}
}
static void keyboard_state(bool open){if(keyboard_frame)execute(keyboard_frame,std::string("window.dispatchEvent(new CustomEvent('framely.keyboard',{detail:")+(open?"true":"false")+"}))");}
static void close_keyboard(bool hide=true){keyboard_state(false);if(keyboard_open&&hide)overlays->HideKeyboard();keyboard_open=false;keyboard_close_pending=false;keyboard_toolbar=false;keyboard_chars=false;keyboard_done=false;keyboard_target=0;keyboard_menu.end(std::chrono::steady_clock::now());if(keyboard_view=="menu")update_menu_modal();if(keyboard_frame){keyboard_frame->base.release(&keyboard_frame->base);keyboard_frame=nullptr;}keyboard_view.clear();}
static void external_keyboard_clear(bool notify_backend){
const bool notify=notify_backend&&external_keyboard.active&&running;
external_keyboard={};
if(notify)std::thread([]{frame_keyboard_command("dashboardClose",json::object());}).detach();
}
static void close_external_keyboard(bool close_frame);
static void external_keyboard_tick(std::chrono::steady_clock::time_point now){
// Hiding SteamVR's keyboard emits a close event which must not tear down the
// replacement. Bound that guard so a later real close is still observed.
if(external_keyboard.active&&external_keyboard.hide_pending&&now>=external_keyboard.hide_pending_until)external_keyboard.hide_pending=false;
// Closing the SteamVR dashboard also invalidates the X11 text target.
if(external_keyboard.active&&!external_keyboard.hide_pending&&!overlays->IsDashboardVisible())close_external_keyboard(true);
}
static void close_external_keyboard(bool close_frame){
const std::string view_key=external_keyboard.view_key;
external_keyboard_clear(true);
if(close_frame&&!view_key.empty()){
auto found=views.find(view_key);
if(found!=views.end()&&!found->second->closing)close_view(*found->second);
}
}
static bool open_external_keyboard(const std::string& overlay_key,vr::VROverlayHandle_t target,uint64_t token){
const json params={{"overlayKey",overlay_key},{"overlayHandle",target},{"token",token}};
if(!frame_keyboard_command("dashboardOpen",params))return false;
external_keyboard.active=true;
external_keyboard.hide_pending=true;
external_keyboard.target=target;
external_keyboard.token=token;
external_keyboard.overlay_key=overlay_key;
external_keyboard.view_key=frame_keyboard_window_key;
external_keyboard.hide_pending_until=std::chrono::steady_clock::now()+std::chrono::milliseconds(1500);
overlays->HideKeyboard();
try{
service_call("window.open",json{{"plugin","tooru.frame-keyboard"},{"window","keyboard"}});
}catch(const std::exception& error){
std::cerr<<"Frame keyboard window failed: "<<error.what()<<"\n";
external_keyboard_clear(true);
return false;
}
return true;
}
static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_overlay=0){
// The runtime toolbar opens the keyboard without a host.keyboard request.
if(e.eventType==vr::VREvent_KeyboardOpened_Global){
auto target=e.data.keyboard.overlayHandle;
if(keyboard_open&&target==keyboard_target)return true;
View* owner=nullptr;for(auto&[key,v]:views)if(v->overlay==target&&!v->closing){owner=v.get();break;}
if(!owner){
char key[1024]{};vr::EVROverlayError error{};overlays->GetOverlayKey(target,key,sizeof(key),&error);
const std::string overlay_key=error?"":key;
if(overlay_key.empty()||overlay_key.rfind("framely.",0)==0)return false;
if(plugin_handles_external_keyboard())return true;
if(external_keyboard.active&&external_keyboard.target==target){
// A repeated Opened event during the native hide is part of the same
// transition. After that grace period treat it as a second keyboard click.
if(!external_keyboard.hide_pending)close_external_keyboard(true);
overlays->HideKeyboard();
return true;
}
if(!open_external_keyboard(overlay_key,target,e.data.keyboard.uUserValue))return false;
return true;
}
if(owner->key==frame_keyboard_window_key){return true;}
close_keyboard(false);owner->host->set_focus(owner->host,1);
keyboard_frame=owner->browser->get_focused_frame(owner->browser);keyboard_view=owner->key;
keyboard_target=target;keyboard_token=e.data.keyboard.uUserValue;keyboard_open=true;keyboard_toolbar=true;
keyboard_menu.begin(owner->key=="menu");keyboard_state(true);return true;
}
if(e.eventType!=vr::VREvent_KeyboardCharInput&&e.eventType!=vr::VREvent_KeyboardDone&&e.eventType!=vr::VREvent_KeyboardClosed&&e.eventType!=vr::VREvent_KeyboardClosed_Global)return false;
const bool external_target=external_keyboard.active&&e.data.keyboard.overlayHandle==external_keyboard.target;
const bool external_close=e.eventType==vr::VREvent_KeyboardClosed||e.eventType==vr::VREvent_KeyboardClosed_Global;
const bool external_input=!e.data.keyboard.uUserValue||!external_keyboard.token||e.data.keyboard.uUserValue==external_keyboard.token;
if(external_target&&(external_close||external_input)){
if(external_close){
if(external_keyboard.hide_pending){external_keyboard.hide_pending=false;return true;}
close_external_keyboard(true);
}
return true;
}
if(!keyboard_open)return true;
if(e.data.keyboard.uUserValue&&e.data.keyboard.uUserValue!=keyboard_token)return true;
if(e.data.keyboard.overlayHandle&&e.data.keyboard.overlayHandle!=keyboard_target)return true;
if(e.eventType==vr::VREvent_KeyboardCharInput){
// OpenVR duplicates character events globally. Only the owner Overlay edits.
if(keyboard_edit_source(source_overlay,keyboard_target)){keyboard_chars=true;keyboard_edit(keyboard_frame,std::string(e.data.keyboard.cNewInput,strnlen(e.data.keyboard.cNewInput,sizeof(e.data.keyboard.cNewInput))));}
}else {keyboard_done=keyboard_done||e.eventType==vr::VREvent_KeyboardDone;keyboard_close_pending=true;}
return true;
}
static void close_view(View& v){if(v.closing)return;if(external_keyboard.active&&v.key==external_keyboard.view_key)external_keyboard_clear(true);if(v.key==frame_keyboard_window_key){std::lock_guard<std::mutex> lock(queue_mutex);queue.erase(std::remove_if(queue.begin(),queue.end(),[](const json& command){return command.value("kind",std::string{})=="window.open"&&command.value("plugin",std::string{})=="tooru.frame-keyboard";}),queue.end());if(running)std::thread([]{frame_keyboard_command("dashboardClose",json::object());}).detach();}if(v.key.rfind("framely.cast.",0)==0){std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}if(v.key=="framely.framing"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_region_close=v.region_editor_id;}if(v.key=="framely.screenshot"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_screenshot_close=v.region_editor_id;}if(v.key=="framely.apk.update"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_apk_update_close=v.region_editor_id;}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu"||apk_prompt(v.key))overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
static void anchor_to_head(View& v,float width,float x=0,float y=0,float z=-1.35f){vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);if(!poses[0].bPoseIsValid)return;auto m=poses[0].mDeviceToAbsoluteTracking;for(int r=0;r<3;r++)m.m[r][3]+=m.m[r][0]*x+m.m[r][1]*y+m.m[r][2]*z;overlays->SetOverlayWidthInMeters(v.overlay,width);overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&m);}
static void anchor_frame_keyboard(View& v){
// A keyboard button must not move keyboard focus away from the edited app.
// SteamVR owns the keyboard's spatial container and its native grab handle.
vr::VROverlayHandle_t native=0;vr::HmdVector2_t scale{};float width=0;
vr::HmdMatrix34_t pose{};
if(!overlays->FindOverlay("valve.steam.gamepadui.keyboard",&native)&&overlays->IsOverlayVisible(native)&&
!overlays->GetOverlayMouseScale(native,&scale)&&scale.v[0]>0&&
!overlays->GetOverlayWidthInMeters(native,&width)&&width>0&&
!overlays->GetTransformForOverlayCoordinates(native,vr::TrackingUniverseStanding,{{scale.v[0]*.5f,scale.v[1]*.5f}},&pose)){
const float dy=(width*float(v.height)/v.width-width*scale.v[1]/scale.v[0])*.5f;
for(int r=0;r<3;++r)pose.m[r][3]+=pose.m[r][1]*dy+pose.m[r][2]*.003f;
overlays->SetOverlayWidthInMeters(v.overlay,width);
overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&pose);
float curvature=0,pitch=0;
overlays->GetOverlayCurvature(native,&curvature);overlays->GetOverlayPreCurvePitch(native,&pitch);
overlays->SetOverlayCurvature(v.overlay,curvature);overlays->SetOverlayPreCurvePitch(v.overlay,pitch);
}else anchor_to_head(v,1.35f,0,-.38f,-1.2f);
}
static void anchor_permission(View& v,float width=.85f){
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);
if(!poses[0].bPoseIsValid)return;
const auto& head=poses[0].mDeviceToAbsoluteTracking;
float fx=head.m[0][2],fz=head.m[2][2],length=std::hypot(fx,fz);
if(length<.01f)return;
fx/=length;fz/=length;
vr::HmdMatrix34_t upright{};
upright.m[0][0]=fz;upright.m[1][1]=1;upright.m[2][0]=-fx;
upright.m[0][2]=fx;upright.m[2][2]=fz;
upright.m[0][3]=head.m[0][3]-.65f*fx;
upright.m[1][3]=head.m[1][3];
upright.m[2][3]=head.m[2][3]-.65f*fz;
overlays->SetOverlayWidthInMeters(v.overlay,width);
overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&upright);
}
static void anchor_notification(View& v){
// Let the compositor follow the HMD's render pose; a captured standing-space
// transform leaves the toast behind when the user turns or moves their head.
vr::HmdMatrix34_t head{{{1,0,0,0},{0,1,0,0},{0,0,1,0}}};
NotificationPlacement toast{};
if(!notification_placement(head,notification_curve_radius,toast))return;
auto error=overlays->SetOverlayWidthInMeters(v.overlay,toast.width);
if(!error)error=overlays->SetOverlayTransformTrackedDeviceRelative(v.overlay,vr::k_unTrackedDeviceIndex_Hmd,&toast.transform);
if(error){std::cerr<<"Notification head attachment failed "<<error<<"\n";return;}
v.placement.transform=toast.transform;v.placement.width=toast.width;v.placement.valid=true;
v.placement.curve(overlays,v.overlay,toast.width,toast.radius);
}
static View& create_view(const std::string& key,const std::string& title,const std::string& url,bool dock,int width,int height,const std::string& plugin="",float physical_width=0,bool keyboard_surface=false){
// Plugin windows always use Steam window controls; dock only controls the tab.
keyboard_surface=keyboard_surface||key==frame_keyboard_window_key;
const bool dashboard=!keyboard_surface&&(dock||!plugin.empty()||key.rfind("framely.cast.",0)==0);
if(views.count(key)){auto& v=*views.at(key);if(v.dashboard&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="notifications"&&key!="launcher")show(v,true);return v;}
// Use 1.5x for launcher artwork and 2x for scrolling panels. CEF readback,
// texture upload and launcher mipmap generation scale with the pixel count.
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dashboard;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications"||key=="framely.framing"||key=="framely.screenshot"||key=="framely.system.radial"||key=="framely.system.radial.progress")?2:1;
auto error=dashboard?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
const bool radial=key=="framely.system.radial"||key=="framely.system.radial.progress";
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"||radial?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,radial||key=="framely.apk.update");overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,key==frame_keyboard_window_key||apk_prompt(key)||radial);overlays->SetOverlaySortOrder(v->overlay,key==frame_keyboard_window_key?20:apk_prompt(key)?100:radial?200:0);if(key=="framely.framing"||key=="framely.screenshot")recording_region_foreground(overlays,v->overlay);if(apk_prompt(key))overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true);
if(key=="framely.system.radial"){
vr::VROverlayIntersectionMaskPrimitive_t mask{};
mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Circle;
mask.m_Primitive.m_Circle={width*.5f,height*.5f,260.f};
if(auto e=overlays->SetOverlayIntersectionMask(v->overlay,&mask,1))std::cerr<<"Radial input mask failed "<<e<<"\n";
}
if(apk_prompt(key)){
vr::VROverlayIntersectionMaskPrimitive_t mask{};
mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;
mask.m_Primitive.m_Rectangle={0,0,float(width),float(height)};
if(auto e=overlays->SetOverlayIntersectionMask(v->overlay,&mask,1))std::cerr<<"Permission input mask failed "<<e<<"\n";
}
// Keep the runtime's full dashboard controls, including its keyboard and close buttons.
if(dashboard){
overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_NoDashboardTab,keyboard_surface||!dock||key=="launcher");
auto result=overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MinimalControlBar,key=="launcher");
if(result)std::cerr<<"Dashboard controls unavailable "<<key<<" "<<result<<"\n";
for(auto flag:{vr::VROverlayFlags_EnableControlBarKeyboard,vr::VROverlayFlags_EnableControlBarClose}){
result=overlays->SetOverlayFlag(v->overlay,flag,!keyboard_surface||flag==vr::VROverlayFlags_EnableControlBarClose);
if(result)std::cerr<<"Dashboard control unavailable "<<key<<" "<<result<<"\n";
}
}
if(keyboard_surface)anchor_frame_keyboard(*v);
else if(dashboard){auto pixels=icon(false,true);overlays->SetOverlayRaw(v->thumbnail,pixels.data(),128,128,4);overlays->SetOverlayWidthInMeters(v->overlay,physical_width>0?physical_width:(key=="framely.manager"?3.2f:key=="launcher"?3.6f:1.2f));}else if(key!="menu"&&key!="notifications"&&key!="launcher"&&!radial){anchor_to_head(*v,physical_width>0?physical_width:1.0f);}
cef_window_info_t info{};info.size=sizeof(info);info.windowless_rendering_enabled=1;info.runtime_style=CEF_RUNTIME_STYLE_ALLOY;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=browser_frame_rate();bs.background_color=0;std::cout<<"Browser frame rate "<<key<<" "<<bs.windowless_frame_rate<<"\n";cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&info,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);
if(!browser){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);throw std::runtime_error("Browser creation failed");}v->browser=browser;v->browser_id=browser->get_identifier(browser);v->host=browser->get_host(browser);glGenTextures(2,v->textures);auto* ptr=v.get();views.emplace(key,std::move(v));ptr->host->notify_screen_info_changed(ptr->host);ptr->host->was_resized(ptr->host);ptr->host->was_hidden(ptr->host,key!="notifications"&&!radial);if(ptr->dashboard&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications"&&key!="launcher"&&!radial)show(*ptr,true);return *ptr;
}
static void plugin_thumbnail(View& view,const std::string& encoded){
if(!view.thumbnail||encoded.empty()||encoded.size()>1400000)return;
cef_string_t value{};str(value,encoded);auto* data=cef_base64_decode(&value);cef_string_utf16_clear(&value);if(!data)return;
std::vector<uint8_t> png(data->get_size(data));data->get_data(data,png.data(),png.size(),0);data->base.release(&data->base);
auto* image=cef_image_create();if(!image)return;
if(!image->add_png(image,1,png.data(),png.size())||image->get_width(image)>1024||image->get_height(image)>1024){image->base.release(&image->base);return;}
int width=0,height=0;auto* bitmap=image->get_as_bitmap(image,1,CEF_COLOR_TYPE_RGBA_8888,CEF_ALPHA_TYPE_POSTMULTIPLIED,&width,&height);image->base.release(&image->base);
if(!bitmap)return;if(width<=0||height<=0||width>1024||height>1024){bitmap->base.release(&bitmap->base);return;}
std::vector<uint8_t> source(bitmap->get_size(bitmap));bitmap->get_data(bitmap,source.data(),source.size(),0);bitmap->base.release(&bitmap->base);
if(source.size()!=size_t(width*height*4))return;
std::vector<uint8_t> pixels(128*128*4,0);float scale=std::min(112.f/width,112.f/height);int w=std::max(1,int(width*scale)),h=std::max(1,int(height*scale));
for(int y=0;y<h;y++)for(int x=0;x<w;x++){int sx=std::min(width-1,int(x/scale)),sy=std::min(height-1,int(y/scale));std::copy_n(source.data()+(sy*width+sx)*4,4,pixels.data()+((y+(128-h)/2)*128+x+(128-w)/2)*4);}
overlays->SetOverlayRaw(view.thumbnail,pixels.data(),128,128,4);
}
static void navigate(View& view,const std::string& hash){auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"location.hash="+json(hash).dump()+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}
static bool launcher_dock_interaction(){
// These are Steam's Dock bar and native window controls on Frame, not app
// content. The runtime keeps ownership of their positioning and interaction.
bool hovered=false;
for(const char* key:{"valve.steam.gamepadui.bar","valve.steam.gamepadui.floatingfooter","system.systemui"}){
vr::VROverlayHandle_t overlay=0;
if(!overlays->FindOverlay(key,&overlay)&&overlays->IsOverlayVisible(overlay)&&overlays->IsHoverTargetOverlay(overlay))hovered=true;
}
bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held;
auto now=std::chrono::steady_clock::now();launcher_dock.observe(hovered,held,now);
if(launcher_dock.holds(now)){launcher_outside_at={};return true;}
return false;
}
static std::string selected_dashboard(){
std::set<std::string> keys=dashboard_keys;keys.insert("valve.steam.gamepadui.main");keys.insert("system.systemui");
for(auto& [key,v]:views)if(v->dashboard&&!v->closing)keys.insert(key);
for(auto& key:keys){if(key=="launcher")continue;vr::VROverlayHandle_t handle=0;if(!overlays->FindOverlay(key.c_str(),&handle)&&overlays->IsActiveDashboardOverlay(handle))return key;}
return {};
}
static void observe_dashboard(const vr::VREvent_t& event){
if(event.eventType!=vr::VREvent_OverlayCreated&&event.eventType!=vr::VREvent_OverlayShown&&event.eventType!=vr::VREvent_DashboardOverlayCreated)return;
char key[1024]{};vr::EVROverlayError error{};overlays->GetOverlayKey(event.data.overlay.overlayHandle,key,sizeof(key),&error);
if(!error&&key[0]&&dashboard_keys.size()<512)dashboard_keys.insert(key);
}
static void finish_launcher_close(){
launcher_fade.reset();
if(!views.count("launcher"))return;auto& v=*views.at("launcher");
overlays->SetOverlayAlpha(v.overlay,0.f);
// Do not steal focus if Steam or the user already selected another window.
if(!overlays->IsActiveDashboardOverlay(v.overlay)){overlays->HideOverlay(v.overlay);return;}
if(launcher_restore_previous&&!launcher_previous_dashboard.empty()){
vr::VROverlayHandle_t previous=0;if(!overlays->FindOverlay(launcher_previous_dashboard.c_str(),&previous)){
overlays->ShowDashboard(launcher_previous_dashboard.c_str());menu_open=launcher_previous_menu;return;
}
}
// Unknown/destroyed previous windows are left to Steam's window selection.
overlays->HideOverlay(v.overlay);
if(!launcher_restore_previous)overlays->ShowDashboard("valve.steam.gamepadui.main");
if(launcher_restore_previous)menu_open=launcher_previous_menu;
}
static void hide_launcher(bool restore=true,bool immediate=false){
if(keyboard_view=="launcher")close_keyboard();
if(launcher_calibrating&&views.count("launcher")){auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);execute(frame,"location.hash='';window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}
const bool was_open=launcher_open;if(was_open)launcher_restore_previous=restore;launcher_calibrating=false;launcher_open=false;launcher_selected=false;launcher_dock.clear();launcher_stick={};
if(immediate){launcher_close_at={};finish_launcher_close();return;}
if(was_open&&views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay)){
auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);
execute(frame,"window.dispatchEvent(new Event('framely.launcher.closing'))");frame->base.release(&frame->base);
auto now=std::chrono::steady_clock::now();launcher_fade.close(now);launcher_close_at=now+std::chrono::milliseconds(launcher_fade.reduced?0:240);
}
}
static void launcher_back(){
auto now=std::chrono::steady_clock::now();if(now<launcher_keyboard_back_until)return;
if(keyboard_open){launcher_keyboard_back_until=now+std::chrono::milliseconds(180);close_keyboard();return;}
if(!launcher_open||!views.count("launcher"))return;
auto& view=*views.at("launcher");auto* frame=view.browser->get_main_frame(view.browser);
execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);
}
static bool launcher_accepts(const vr::VREvent_t& e){
bool held=false;for(auto& hand:entry_buttons)for(auto& button:hand)held|=button.held;
return launcher_dismiss.allows(std::chrono::steady_clock::now(),held,e.eventAgeSeconds);
}
static void toggle_entry(bool launcher){
if(launcher){
if(launcher_open){hide_launcher();return;}
if(!launcher_close_at.time_since_epoch().count()||!views.count("launcher")||!overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay)){launcher_previous_dashboard=selected_dashboard();launcher_previous_menu=menu_open;}launcher_restore_previous=true;
menu_open=false;if(views.count("menu"))show(*views.at("menu"),false);
auto& view=create_view("launcher","Framely launcher",origin+"/launcher",true,1440,800);
vr::VREvent_t stale{};while(overlays->PollNextOverlayEvent(view.overlay,&stale,sizeof(stale))){}
++launcher_session;launcher_close_at={};launcher_dock.clear();launcher_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={};
// Wake CEF before activation. A small nonzero alpha keeps Steam activation
// working without exposing the previous session's full-opacity texture.
launcher_fade.open(std::chrono::steady_clock::now());
view.host->was_hidden(view.host,0);view.host->invalidate(view.host,PET_VIEW);
overlays->SetOverlayAlpha(view.overlay,.02f);overlays->ShowOverlay(view.overlay);
overlays->ShowDashboard("launcher");
auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"window.framelyLauncherSession="+std::to_string(launcher_session)+";window.dispatchEvent(new Event('framely.launcher.open'))");frame->base.release(&frame->base);
}else{hide_launcher(false);menu_open=!menu_open;if(menu_open)menu_button.opened(std::chrono::steady_clock::now());}
}
static void setup_entry_actions(){
char exe[4096]{};auto n=readlink("/proc/self/exe",exe,sizeof(exe)-1);if(n<=0)return;
auto base=std::filesystem::path(exe).parent_path();auto manifest=base/"../../share/input/actions.json";
if(!std::filesystem::exists(manifest))manifest=base/"../../native/input/actions.json";
auto* input=vr::VRInput();if(!input||!std::filesystem::exists(manifest))return;
auto error=input->SetActionManifestPath(std::filesystem::absolute(manifest).lexically_normal().c_str());if(error!=vr::VRInputError_None){std::cerr<<"Action manifest failed: "<<error<<std::endl;return;}
entry_actions_ready=input->GetActionSetHandle("/actions/entry",&entry_set)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/primary",&primary_action)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/upper",&upper_action)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/aim",&aim_action)==vr::VRInputError_None;
input->GetActionHandle("/actions/entry/in/close",&close_action);input->GetActionHandle("/actions/entry/in/navigate",&navigate_action);
input->GetActionSetHandle("/actions/gaze",&gaze_set);input->GetActionHandle("/actions/gaze/in/eye",&gaze_action);input->GetActionHandle("/actions/entry/in/confirm",&confirm_action);input->GetActionHandle("/actions/entry/out/gazeHaptic",&gaze_haptic_action);
input->GetInputSourceHandle("/user/hand/left",&hands[0]);input->GetInputSourceHandle("/user/hand/right",&hands[1]);
std::cout<<"Framely independent trigger bindings "<<(entry_actions_ready?"ready":"unavailable")<<std::endl;
}
static void page_scroll(float x,float y,bool active,vr::TrackedDeviceIndex_t device,std::chrono::steady_clock::time_point now){
View* target=nullptr;
if(!launcher_open)for(auto&[key,v]:views){
if(key=="launcher"||(keyboard_open&&keyboard_view!=key)||!v->pointer_valid||!capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay))||!overlays->IsHoverTargetOverlay(v->overlay))continue;
if(!target||(v->pointer_device==device&&target->pointer_device!=device)||(v->pointer_device==target->pointer_device&&v->pointer_at>target->pointer_at))target=v.get();
}
if(!target){page_scroll_stick.clear();page_scroll_target=0;return;}
if(page_scroll_target!=target->overlay){page_scroll_stick.clear();page_scroll_target=target->overlay;}
target->stick_scrolling=active&&std::isfinite(x)&&std::isfinite(y)&&std::max(std::abs(x),std::abs(y))>.18f;
auto delta=page_scroll_stick.update(x,y,target->stick_scrolling,now);
if(delta.x||delta.y){auto mouse=target->pointer;mouse.modifiers=0;target->host->send_mouse_wheel_event(target->host,&mouse,delta.x,delta.y);}
}
static bool launcher_gaze_allowed(){
if(!launcher_open||keyboard_open||!views.count("launcher")||launcher_dock_interaction())return false;
auto& v=*views.at("launcher");return !v.closing&&overlays->IsActiveDashboardOverlay(v.overlay)&&overlays->IsOverlayVisible(v.overlay)&&(launcher_calibrating||(!overlays->IsHoverTargetOverlay(v.overlay)&&!v.launcher_pointer.active));
}
static void launcher_gaze_event(const json& sample){
if(!views.count("launcher"))return;auto& v=*views.at("launcher");if(v.closing||!v.browser)return;
auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.launcher.gaze',{detail:"+sample.dump()+"}))");frame->base.release(&frame->base);
}
static void launcher_gaze(vr::IVRInput* input,bool allowed,const bool (&connected)[2],const bool (&buttons)[2],std::chrono::steady_clock::time_point now){
allowed=allowed&&launcher_gaze_allowed();
vr::VREyeTrackingData_t eye{};vr::VROverlayIntersectionParams_t ray{};vr::VROverlayIntersectionResults_t hit{};
auto sampled_at=std::chrono::steady_clock::now();
bool tracked=allowed&&gaze_action&&input->GetEyeTrackingDataRelativeToNow(gaze_action,vr::TrackingUniverseStanding,0,&eye,sizeof(eye))==vr::VRInputError_None&&launcher_gaze_ray(eye,ray);
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
// Use the same nominal time for gaze and head pose, rather than pairing gaze
// with a later/predicted rendering pose while the user turns their head.
if(tracked)vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,std::chrono::duration<float>(sampled_at-std::chrono::steady_clock::now()).count(),poses,vr::k_unMaxTrackedDeviceCount);
GazeAngles raw{},expected{};tracked=tracked&&poses[0].bPoseIsValid&&gaze_head_angles(poses[0].mDeviceToAbsoluteTracking,ray.vDirection,raw);
bool target_valid=false;
if(tracked&&launcher_calibrating&&!gaze_target_id.empty()){
auto& view=*views.at("launcher");auto& head=poses[0].mDeviceToAbsoluteTracking;
auto sample=[&](GazeAngles angles,float& u,float& v){auto candidate=gaze_world_ray(head,ray.vSource,angles);vr::VROverlayIntersectionResults_t result{};bool valid=overlays->ComputeOverlayIntersection(view.overlay,&candidate,&result);u=result.vUVs.v[0];v=result.vUVs.v[1];return valid&&std::isfinite(u)&&std::isfinite(v);};
if(gaze_target_cached&&gaze_angles_to_point(head,ray.vSource,gaze_target_world,expected)){float u,v;target_valid=sample(expected,u,v)&&std::hypot(u-gaze_target_u,v-gaze_target_v)<.001f;}
if(!target_valid){
vr::HmdMatrix34_t seed_pose{};GazeAngles seed{};
if(!overlays->GetTransformForOverlayCoordinates(view.overlay,vr::TrackingUniverseStanding,{{gaze_target_u*view.width,gaze_target_v*view.height}},&seed_pose)){
vr::HmdVector3_t seed_point{{seed_pose.m[0][3],seed_pose.m[1][3],seed_pose.m[2][3]}};
bool solved=gaze_angles_to_point(head,ray.vSource,seed_point,seed)&&gaze_target_inverse(seed,gaze_target_u,gaze_target_v,sample,expected);
if(!solved){vr::HmdMatrix34_t center{};if(!overlays->GetTransformForOverlayCoordinates(view.overlay,vr::TrackingUniverseStanding,{{view.width*.5f,view.height*.5f}},&center)){vr::HmdVector3_t point{{center.m[0][3],center.m[1][3],center.m[2][3]}};solved=gaze_angles_to_point(head,ray.vSource,point,seed)&&gaze_target_inverse(seed,gaze_target_u,gaze_target_v,sample,expected);}}
if(solved){
auto candidate=gaze_world_ray(head,ray.vSource,expected);vr::VROverlayIntersectionResults_t result{};
target_valid=overlays->ComputeOverlayIntersection(view.overlay,&candidate,&result);if(target_valid){gaze_target_world=result.vPoint;gaze_target_cached=true;}
}
}
}
}
if(tracked){double matrix[6];if(gaze_matrix(launcher_calibrating?gaze_preview:gaze_correction,matrix))ray=gaze_world_ray(poses[0].mDeviceToAbsoluteTracking,ray.vSource,gaze_correct(raw,matrix));}
bool valid=tracked&&overlays->ComputeOverlayIntersection(views.at("launcher")->overlay,&ray,&hit)&&std::isfinite(hit.vUVs.v[0])&&std::isfinite(hit.vUVs.v[1])&&hit.vUVs.v[0]>=0&&hit.vUVs.v[0]<=1&&hit.vUVs.v[1]>=0&&hit.vUVs.v[1]<=1;
// Looking away during a press keeps the locked target. Tracking loss cancels.
auto edge=gaze_press.update(!launcher_calibrating&&tracked&&(valid||gaze_press.held),connected,buttons);
if(edge==LauncherGazePress::Down)gaze_feedback_hand=gaze_press.owner;
if(edge==LauncherGazePress::Cancel||(!tracked&&gaze_active)){launcher_gaze_event(!allowed||edge==LauncherGazePress::Cancel?json{{"valid",false},{"phase","cancel"}}:json{{"valid",false}});gaze_active=false;return;}
if(!tracked)return;
if(edge==LauncherGazePress::Down||edge==LauncherGazePress::Up){launcher_gaze_event({{"valid",true},{"phase",edge==LauncherGazePress::Down?"down":"up"}});gaze_sent=now;}
else if(now-gaze_sent>=std::chrono::milliseconds(16)){
auto& v=*views.at("launcher");json event=valid?json{{"valid",true},{"x",hit.vUVs.v[0]*v.width},{"y",(1-hit.vUVs.v[1])*v.height}}:json{{"valid",bool(gaze_press.held)}};
if(launcher_calibrating){event["valid"]=tracked&&target_valid;event["targetId"]=gaze_target_id;event["targetUnavailable"]=tracked&&!target_valid;event["rawAngles"]={raw.yaw,raw.pitch};event["targetAngles"]={expected.yaw,expected.pitch};event["headPose"]=json::array();for(int r=0;r<3;r++)for(int c=0;c<4;c++)event["headPose"].push_back(poses[0].mDeviceToAbsoluteTracking.m[r][c]);}
launcher_gaze_event(event);gaze_sent=now;
}
gaze_active=true;
}
static const char* radial_direction(SystemRadialDirection direction){
switch(direction){
case SystemRadialDirection::Up:return "up";
case SystemRadialDirection::UpRight:return "upRight";
case SystemRadialDirection::Right:return "right";
case SystemRadialDirection::DownRight:return "downRight";
case SystemRadialDirection::Down:return "down";
case SystemRadialDirection::DownLeft:return "downLeft";
case SystemRadialDirection::Left:return "left";
case SystemRadialDirection::UpLeft:return "upLeft";
default:return "center";
}
}
static void radial_screenshot(bool active,bool capture){
if(active==screenshot_radial_held)return;
screenshot_radial_held=active;
if(!active){
screenshot_region_overlay.destroy();
if(auto preview=views.find("framely.screenshot.preview");preview!=views.end())close_view(*preview->second);
}
std::lock_guard<std::mutex> lock(visibility_mutex);
if(active)pending_screenshot_hotkey="radialPress";
else if(pending_screenshot_hotkey=="radialPress")pending_screenshot_hotkey=capture?"radialCapture":"";
else pending_screenshot_hotkey=capture?"release":"cancel";
}
static void system_radial_laser(vr::VROverlayHandle_t overlay,bool enabled){
auto method=enabled?vr::VROverlayInputMethod_Mouse:vr::VROverlayInputMethod_None;
if(auto e=overlays->SetOverlayInputMethod(overlay,method))std::cerr<<"Radial laser input failed "<<e<<"\n";
overlays->SetOverlayFlag(overlay,vr::VROverlayFlags_HideLaserIntersection,!enabled);
overlays->SetOverlayFlag(overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,enabled);
}
static std::optional<SystemRadialDirection> system_radial_stick(const char* model,vr::VRInputValueHandle_t hand){
vr::RenderModel_ControllerMode_State_t mode{};
vr::RenderModel_ComponentState_t rest{},full_x{},full_y{},current{};
vr::VRControllerState_t neutral{},sx{},sy{};sx.rAxis[0].x=1;sy.rAxis[0].y=1;
auto* models=vr::VRRenderModels();float x=0,y=0;
if(!models->GetComponentStateForDevicePath(model,"thumbstick",hand,&mode,&current)||
!models->GetComponentState(model,"thumbstick",&neutral,&mode,&rest)||
!models->GetComponentState(model,"thumbstick",&sx,&mode,&full_x)||
!models->GetComponentState(model,"thumbstick",&sy,&mode,&full_y)||
!launcher_stick_axes(rest,full_x,full_y,current,x,y)||x*x+y*y<.35f*.35f)return std::nullopt;
return SystemRadial::select(x,y);
}
static void system_radial_tick(){
if(!system_radial_available||!views.count("framely.system.radial")||!views.count("framely.system.radial.progress"))return;
auto& progress_view=*views.at("framely.system.radial.progress");
if(!system_radial_enabled.load()){
auto& view=*views.at("framely.system.radial");
if(system_radial.active||system_radial_progress_visible||system_radial.open){
show(view,false);show(progress_view,false);system_radial_laser(view.overlay,false);overlays->ClearOverlayTexture(view.overlay);
system_radial={};system_radial_progress_visible=false;
if(screenshot_radial_held)radial_screenshot(false,false);
}
return;
}
auto now=SystemRadial::Clock::now();
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);
int first=system_radial.active?system_radial.hand:0,last=system_radial.active?first:1;
for(int hand=first;hand<=last;hand++){
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
if(device!=system_radial_pose_device[hand]){system_radial_pose_device[hand]=device;system_radial_pose_at[hand]={};}
if(device!=vr::k_unTrackedDeviceIndexInvalid&&poses[device].bPoseIsValid){system_radial_pose[hand]=poses[device].mDeviceToAbsoluteTracking;system_radial_pose_at[hand]=now;}
const bool pose_recent=system_radial_pose_at[hand].time_since_epoch().count()&&now-system_radial_pose_at[hand]<std::chrono::milliseconds(750);
bool valid=device!=vr::k_unTrackedDeviceIndexInvalid&&poses[0].bPoseIsValid&&hands[hand];
bool down=false;
char model[256]{};
if(valid){
vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));
vr::RenderModel_ControllerMode_State_t mode{};
vr::RenderModel_ComponentState_t state{};
bool component=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_system",hands[hand],&mode,&state);
down=component&&(state.uProperties&vr::VRComponentProperty_IsPressed);
// Another overlay (notably the Frame keyboard) can own the active input
// action while its panel is visible. The legacy controller state still
// reports the physical button, so use it if the action-path state is idle.
if(!down){
vr::VRControllerState_t controller{};
if(vr_system->GetControllerState(device,&controller,sizeof(controller))){
vr::RenderModel_ComponentState_t raw{};
down=bool(controller.ulButtonPressed&vr::ButtonMaskFromId(vr::k_EButton_System));
if(!down&&vr::VRRenderModels()->GetComponentState(model,"button_system",&controller,&mode,&raw))down=bool(raw.uProperties&vr::VRComponentProperty_IsPressed);
}
}
valid=component||down;
}
float right=0,up=0;
if(valid&&system_radial.active&&pose_recent){
const auto& point=system_radial_pose[hand];
if(!system_radial_origin_valid){for(int i=0;i<3;i++)system_radial_origin.v[i]=point.m[i][3];system_radial_origin_valid=true;}
for(int i=0;i<3;i++){
float delta=point.m[i][3]-system_radial_origin.v[i];
right+=delta*system_radial_right.v[i];
up+=delta*system_radial_up.v[i];
}
}
auto& view=*views.at("framely.system.radial");
std::optional<SystemRadialDirection> hover;
if(system_radial.open&&view.pointer_valid&&overlays->IsHoverTargetOverlay(view.overlay))hover=SystemRadial::hit(view.pointer.x,view.pointer.y);
if(system_radial.open&&!pose_recent&&!hover&&valid)hover=system_radial_stick(model,hands[hand]);
if(system_radial.open&&!pose_recent&&!hover)hover=system_radial.direction;
auto event=system_radial.update(hand,valid,down,right,up,now,hover);
if(event==SystemRadialEvent::Idle)continue;
if(event==SystemRadialEvent::Begin){
show(view,false);
show(progress_view,false);
system_radial_laser(view.overlay,false);
view.pointer_valid=false;
system_radial_preview_prepared=false;
system_radial_progress_visible=false;
const auto& point=system_radial_pose[hand];
const auto& head=poses[0].mDeviceToAbsoluteTracking;
system_radial_origin_valid=pose_recent;
for(int i=0;i<3;i++){
if(pose_recent)system_radial_origin.v[i]=point.m[i][3];
system_radial_right.v[i]=head.m[i][0];
system_radial_up.v[i]=head.m[i][1];
}
vr::HmdMatrix34_t transform{{{1,0,0,0},{0,1,0,0},{0,0,1,-.38f}}};
overlays->SetOverlayWidthInMeters(progress_view.overlay,.68f);
overlays->SetOverlayTransformTrackedDeviceRelative(progress_view.overlay,vr::k_unTrackedDeviceIndex_Hmd,&transform);
overlays->SetOverlayWidthInMeters(view.overlay,.68f);
overlays->ClearOverlayTexture(view.overlay);
system_radial_sent={};
}
if(event==SystemRadialEvent::Open){
show(progress_view,false);
system_radial_progress_visible=false;
if(pose_recent){
const auto& point=system_radial_pose[hand];
const auto& head=poses[0].mDeviceToAbsoluteTracking;
auto transform=head;
for(int i=0;i<3;i++){
if(!system_radial_origin_valid)system_radial_origin.v[i]=point.m[i][3];
system_radial_right.v[i]=head.m[i][0];
system_radial_up.v[i]=head.m[i][1];
transform.m[i][3]=point.m[i][3]-head.m[i][2]*.22f;
}
system_radial_origin_valid=true;
overlays->SetOverlayTransformAbsolute(view.overlay,vr::TrackingUniverseStanding,&transform);
}else{
vr::HmdMatrix34_t transform{{{1,0,0,0},{0,1,0,0},{0,0,1,-.55f}}};
overlays->SetOverlayTransformTrackedDeviceRelative(view.overlay,vr::k_unTrackedDeviceIndex_Hmd,&transform);
}
show(view,true);
system_radial_laser(view.overlay,true);
}
if(event==SystemRadialEvent::Change){
if(system_radial.direction==SystemRadialDirection::Right)radial_screenshot(true,false);
else if(screenshot_radial_held)radial_screenshot(false,false);
}
if(event==SystemRadialEvent::Cancel||event==SystemRadialEvent::Commit){
const auto direction=system_radial.direction;
if(event==SystemRadialEvent::Cancel||direction!=SystemRadialDirection::Right){
std::lock_guard<std::mutex> lock(visibility_mutex);
if(pending_screenshot_hotkey.empty()||pending_screenshot_hotkey=="radialPrepare")pending_screenshot_hotkey="radialCancel";
}
show(view,false);
show(progress_view,false);
system_radial_laser(view.overlay,false);
overlays->ClearOverlayTexture(view.overlay);
system_radial_progress_visible=false;
if(screenshot_radial_held)radial_screenshot(false,event==SystemRadialEvent::Commit&&direction==SystemRadialDirection::Right);
if(event==SystemRadialEvent::Commit){
if(direction==SystemRadialDirection::Center){
if(auto* chaperone=vr::VRChaperone())chaperone->ResetZeroPose(vr::TrackingUniverseStanding);
}else if(direction==SystemRadialDirection::Up)toggle_entry(true);
else if(direction==SystemRadialDirection::Down){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back({{"kind","manager.open"}});}
else if(direction==SystemRadialDirection::Left){std::lock_guard<std::mutex> lock(visibility_mutex);pending_system_action="record:"+std::to_string(++system_action_sequence);}
else if(direction==SystemRadialDirection::Right){
// A fast move can reach the sector between two host polls.
std::lock_guard<std::mutex> lock(visibility_mutex);
if(pending_screenshot_hotkey.empty())pending_screenshot_hotkey="radialCapture";
}
}
return;
}
}
if(system_radial.active&&!system_radial_preview_prepared&&system_radial.progress(now)>=.25f){
std::lock_guard<std::mutex> lock(visibility_mutex);
if(pending_screenshot_hotkey.empty()){pending_screenshot_hotkey="radialPrepare";system_radial_preview_prepared=true;}
}
if(system_radial.active&&!system_radial.open&&!system_radial_progress_visible&&system_radial.progress(now)>=.4f){
show(progress_view,true);
system_radial_progress_visible=true;
}
if(system_radial.active&&now-system_radial_sent>=std::chrono::milliseconds(33)){
system_radial_sent=now;
bool recording=false;{std::lock_guard<std::mutex> lock(queue_mutex);recording=recording_state.value("active",false);}
json detail={{"progress",std::clamp((system_radial.progress(now)-.4f)/.6f,0.f,1.f)},{"open",system_radial.open},{"direction",radial_direction(system_radial.direction)},{"recording",recording}};
for(auto* display:{views.at("framely.system.radial.progress").get(),views.at("framely.system.radial").get()}){
auto* frame=display->browser->get_main_frame(display->browser);
execute(frame,"window.dispatchEvent(new CustomEvent('framely.systemMenu',{detail:"+detail.dump()+"}))");
frame->base.release(&frame->base);
}
}
}
static void entry_actions(vr::VROverlayHandle_t button,bool available){
for(int hand=0;hand<2;hand++){framing_trigger_known[hand]=false;framing_trigger_down[hand]=false;}
const bool pointer_available=available||recording_indicator.active();
if(!entry_actions_ready){recording_indicator.cancel_input();return;}auto* input=vr::VRInput();vr::VRActiveActionSet_t sets[2]{};sets[0].ulActionSet=entry_set;sets[1].ulActionSet=gaze_set;
// Only the eye action participates in overlay priority. Controller bindings
// keep their existing priority so Steam laser and Dock controls are preserved.
if(launcher_gaze_allowed()&&!entry_hold.pressed&&!(available&&(entry_pointer.inside||overlays->IsHoverTargetOverlay(button))))sets[1].nPriority=vr::k_nActionSetOverlayGlobalPriorityMin;
if(input->UpdateActionState(sets,sizeof(sets[0]),gaze_set?2:1)!=vr::VRInputError_None){recording_indicator.cancel_input();return;}
auto now=std::chrono::steady_clock::now();
// Dock ownership must be checked before reading confirm edges. Gaze focus
// alone does not own a trigger aimed at the entry, even before mouse-down
// arrives. Disabling gaze disarms it until all confirm inputs are released.
bool gaze_allowed=launcher_gaze_allowed()&&!entry_hold.pressed&&!(available&&(entry_pointer.inside||overlays->IsHoverTargetOverlay(button)));bool gaze_connected[2]{},gaze_buttons[2]{};
float stick_x=0,stick_y=0;bool stick_active=false;vr::TrackedDeviceIndex_t stick_device=vr::k_unTrackedDeviceIndexInvalid;
for(int hand=0;hand<2;hand++){
vr::InputPoseActionData_t pose{};auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);bool hit=available&&entry_pointer.hit(device);
bool pose_ok=pointer_available&&input->GetPoseActionDataRelativeToNow(aim_action,vr::TrackingUniverseStanding,0,&pose,sizeof(pose),hands[hand])==vr::VRInputError_None&&pose.bActive&&pose.pose.bPoseIsValid;
if(pointer_available&&!pose_ok&&device!=vr::k_unTrackedDeviceIndexInvalid){
vr::TrackedDevicePose_t tracked[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,tracked,vr::k_unMaxTrackedDeviceCount);
char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));
vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t tip{};
if(tracked[device].bDeviceIsConnected&&tracked[device].bPoseIsValid&&vr::VRRenderModels()->GetComponentStateForDevicePath(model,"tip",hands[hand],&mode,&tip)){
pose.pose.mDeviceToAbsoluteTracking=entry_tip_pose(tracked[device].mDeviceToAbsoluteTracking,tip.mTrackingToComponentLocal);pose_ok=true;
}
}
if(available&&pose_ok){auto& m=pose.pose.mDeviceToAbsoluteTracking;vr::VROverlayIntersectionParams_t params{};params.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){params.vSource.v[r]=m.m[r][3];params.vDirection.v[r]=-m.m[r][2];}vr::VROverlayIntersectionResults_t result{};hit=hit||(overlays->IsHoverTargetOverlay(button)&&overlays->ComputeOverlayIntersection(button,&params,&result)&&result.vUVs.v[0]>=float(dock_icon_left)/dock_canvas_width&&result.vUVs.v[0]<=float(dock_icon_left+128)/dock_canvas_width);}
if(hit)gaze_allowed=false;
gaze_connected[hand]=device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device);
if(gaze_connected[hand]){vr::InputDigitalActionData_t confirm{};bool active=input->GetDigitalActionData(confirm_action,&confirm,sizeof(confirm),hands[hand])==vr::VRInputError_None&&confirm.bActive;gaze_buttons[hand]=active&&confirm.bState;
if(!active){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};
if(hand==1)gaze_buttons[hand]=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_a",hands[hand],&mode,&state)&&(state.uProperties&vr::VRComponentProperty_IsPressed);
else{vr::VRControllerState_t controller{};gaze_buttons[hand]=vr_system->GetControllerState(device,&controller,sizeof(controller))&&(controller.ulButtonPressed&(vr::ButtonMaskFromId(vr::k_EButton_DPad_Down)|vr::ButtonMaskFromId(vr::k_EButton_A)));}
}
}
for(int kind=0;kind<2;kind++){vr::InputDigitalActionData_t data{};bool active=input->GetDigitalActionData(kind?upper_action:primary_action,&data,sizeof(data),hands[hand])==vr::VRInputError_None&&data.bActive;
bool down=data.bState;if(kind==0&&active)entry_analog[hand].held=down;
// Dashboard focus suppresses action data. Frame's render-model components
// read the separate physical input paths, without changing Steam bindings
// or the user's global input-override setting.
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){
char type[256]{},model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_ControllerType_String,type,sizeof(type));
if(std::string(type)=="frame_controller"){
vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));
vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};
active=vr::VRRenderModels()->GetComponentStateForDevicePath(model,kind?"bumper":"trigger",hands[hand],&mode,&state);
down=active&&(state.uProperties&vr::VRComponentProperty_IsPressed);
if(active&&kind==0){
// Frame's primary trigger is analog: its animated component has no
// IsPressed flag. Calibrate its travel from the model's legacy axis 1,
// then match Steam's 50% press / 45% release thresholds.
vr::VRControllerState_t rest_input{},full_input{};full_input.rAxis[1].x=1;
vr::RenderModel_ComponentState_t rest{},full{};float pull=0;
if(vr::VRRenderModels()->GetComponentState(model,"trigger",&rest_input,&mode,&rest)&&vr::VRRenderModels()->GetComponentState(model,"trigger",&full_input,&mode,&full)&&entry_trigger_pull(rest,full,state,pull))down=entry_analog[hand].update(pull)||down;
}
}
}
if(kind==0&&!active)entry_analog[hand].held=false;
if(kind==0&&active)gaze_buttons[hand]|=down;
if(kind==0){
framing_trigger_known[hand]=active;framing_trigger_down[hand]=down||gaze_buttons[hand];
// Reuse the same physical tip / analog-trigger fallback as the Dock.
// No second action poll or input-focus request for the recording HUD.
auto stop_recording=recording_indicator.pointer(hand,pose.pose.mDeviceToAbsoluteTracking,active&&pose_ok,down);
if(!stop_recording.empty()){std::lock_guard<std::mutex> lock(visibility_mutex);pending_recording_stop=stop_recording;}
auto permission=views.find("framely.apk.update");
if(permission==views.end()||permission->second->closing)permission=views.find("framely.apk.permission");
if(permission!=views.end()&&!permission->second->closing&&permission->second->shown&&pose_ok){
auto& view=*permission->second;
const auto& m=pose.pose.mDeviceToAbsoluteTracking;
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;
for(int row=0;row<3;row++){ray.vSource.v[row]=m.m[row][3];ray.vDirection.v[row]=-m.m[row][2];}
vr::VROverlayIntersectionResults_t point{};
if(overlays->ComputeOverlayIntersection(view.overlay,&ray,&point)&&point.vUVs.v[0]>=0&&point.vUVs.v[0]<=1&&point.vUVs.v[1]>=0&&point.vUVs.v[1]<=1){
cef_mouse_event_t mouse{};
mouse.x=std::clamp(int(point.vUVs.v[0]*view.width),0,view.width-1);
mouse.y=std::clamp(int((1-point.vUVs.v[1])*view.height),0,view.height-1);
if(!overlays->IsHoverTargetOverlay(view.overlay)){
view.host->send_mouse_move_event(view.host,&mouse,0);
if(active&&down&&!permission_trigger_held[hand]){
view.host->set_focus(view.host,1);
mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;
view.host->send_mouse_click_event(view.host,&mouse,MBT_LEFT,0,1);
mouse.modifiers=0;
view.host->send_mouse_click_event(view.host,&mouse,MBT_LEFT,1,1);
}
}
}
}
permission_trigger_held[hand]=active&&down;
}
bool was_held=entry_buttons[hand][kind].held;
entry_buttons[hand][kind].update(active,down,hit,now);
if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!recording_indicator.hovered(hand)&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
}
if(launcher_open&&hand==1){vr::InputDigitalActionData_t close{};bool active=input->GetDigitalActionData(close_action,&close,sizeof(close),hands[hand])==vr::VRInputError_None&&close.bActive;bool down=close.bState;
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};active=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_b",hands[hand],&mode,&state);down=active&&(state.uProperties&vr::VRComponentProperty_IsPressed);}
if(launcher_close_button.update(active,down,true,now))launcher_back();
}
vr::InputAnalogActionData_t axis{};bool active=input->GetAnalogActionData(navigate_action,&axis,sizeof(axis),hands[hand])==vr::VRInputError_None&&axis.bActive;float x=axis.x,y=axis.y;
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t rest{},full_x{},full_y{},current{};vr::VRControllerState_t neutral{},sx{},sy{};sx.rAxis[0].x=1;sy.rAxis[0].y=1;auto* models=vr::VRRenderModels();active=models->GetComponentStateForDevicePath(model,"thumbstick",hands[hand],&mode,&current)&&models->GetComponentState(model,"thumbstick",&neutral,&mode,&rest)&&models->GetComponentState(model,"thumbstick",&sx,&mode,&full_x)&&models->GetComponentState(model,"thumbstick",&sy,&mode,&full_y)&&launcher_stick_axes(rest,full_x,full_y,current,x,y);}
// Pick a single controller and a single axis; never emit two directions.
if(active&&(!stick_active||std::max(std::abs(x),std::abs(y))>std::max(std::abs(stick_x),std::abs(stick_y)))){stick_active=true;stick_x=x;stick_y=y;stick_device=device;}
}
launcher_gaze(input,gaze_allowed,gaze_connected,gaze_buttons,now);
if(launcher_open){const bool blocked=keyboard_open||launcher_dock_interaction();int direction=launcher_stick.update(blocked?0:stick_x,blocked?0:stick_y,!blocked&&stick_active);if(direction&&views.count("launcher")){auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:"+std::to_string(direction)+"}))");frame->base.release(&frame->base);}}
else{launcher_stick={};launcher_close_button={};}
page_scroll(stick_x,stick_y,stick_active,stick_device,now);
if(gaze_allowed&&gaze_active)launcher_outside_at={};
if(launcher_open&&launcher_outside_at.time_since_epoch().count()&&now>=launcher_outside_at){launcher_outside_at={};if(!keyboard_open&&!launcher_dock_interaction())launcher_back();}
}
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="cast.screenshot.open"){
auto key="framely.screenshot";
if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}
auto id=c.at("id").get<std::string>();bool spatial=c.value("spatial",false);
if(spatial){auto geometry=c.at("geometry");RecordingRegionPlane plane;if(!recording_region_plane(geometry.at("view"),geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Screenshot frame",origin+"/cast/screenshot",false,1600,1600);v.region_editor_id=id;v.region_geometry=geometry;update_framing_placement(v);}
else{auto& v=create_view(key,"Screenshot frame",origin+"/cast/screenshot",false,1120,780,"",1.25f);v.region_editor_id=id;}
menu_open=false;hide_launcher(false);
}else if(kind=="cast.screenshot.close"){
if(views.count("framely.screenshot")&&views.at("framely.screenshot")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.screenshot"));
}else if(kind=="cast.screenshot.shortcutPreview.open"){
if(screenshot_radial_held){auto key="framely.screenshot.preview";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}double aspect=c.value("aspect",1.0);if(!std::isfinite(aspect)||aspect<.3||aspect>4)aspect=1.0;int width=960,height=std::clamp(int(960/aspect),240,960);auto& v=create_view(key,"Screenshot preview",origin+"/cast/screenshot/shortcut",false,width,height,"",.95f);overlays->SetOverlayInputMethod(v.overlay,vr::VROverlayInputMethod_None);}
}else if(kind=="cast.screenshot.shortcutPreview.close"){
if(auto preview=views.find("framely.screenshot.preview");preview!=views.end())close_view(*preview->second);
}else if(kind=="cast.framing.open"){auto key="framely.framing";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto geometry=c.at("geometry");RecordingRegionPlane plane;auto view=geometry.at("view");if(!recording_region_plane(view,geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Recording frame",origin+"/cast/framing",false,1600,1600);v.region_editor_id=geometry.at("id");v.region_geometry=geometry;update_framing_placement(v);menu_open=false;hide_launcher(false);}else if(kind=="cast.framing.reference"){auto found=views.find("framely.framing");if(found!=views.end()&&!found->second->closing&&found->second->region_editor_id==c.value("id",std::string{})){double cx=c.at("centerTanX"),cy=c.at("centerTanY");if(std::isfinite(cx)&&std::isfinite(cy)&&std::abs(cx)<=1.733&&std::abs(cy)<=1.733){found->second->region_geometry["view"]["centerTanX"]=cx;found->second->region_geometry["view"]["centerTanY"]=cy;}}}else if(kind=="cast.framing.close"){if(views.count("framely.framing")&&views.at("framely.framing")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.framing"));}else if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="apk.update.open"){auto key="framely.apk.update";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto id=c.at("id").get<std::string>();if(views.count(key)){if(views.at(key)->region_editor_id!=id){close_view(*views.at(key));std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}show(*views.at(key),true);}else{std::cout<<"APK update request="<<id<<" creating confirmation window\n";auto& v=create_view(key,"Application update",boot_url+"?view=apk-update&id="+id,false,1000,1100,"",.72f);v.region_editor_id=id;anchor_permission(v,.72f);}}else if(kind=="apk.update.close"){if(views.count("framely.apk.update")&&views.at("framely.apk.update")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.apk.update"));}else if(kind=="apk.permission.open"){auto key="framely.apk.permission";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto& v=create_view(key,"Android permission",origin+"/apk/permission",false,1000,560,"",.85f);anchor_permission(v);}else if(kind=="apk.permission.close"){if(views.count("framely.apk.permission"))close_view(*views.at("framely.apk.permission"));}else if(kind=="cast.screenshot.sound"){ui_audio.screenshot();}else if(kind=="cast.window"){auto id=c.at("id").get<std::string>();auto key="framely.cast."+id;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}int width=c.value("width",640),height=c.value("height",240);if(width<160||width>1920||height<112||height>8192)continue;auto& v=create_view(key,"Framely casting",origin+"/cast/window/"+id,false,width,height,"",2.f);v.cast_frame.path=c.at("frame").get<std::string>();if(v.width!=width||v.height!=height){v.width=width;v.height=height;v.pixels.clear();v.texture_ready[0]=v.texture_ready[1]=false;v.host->was_resized(v.host);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v.overlay,&scale);}menu_open=false;launcher_open=false;}else if(kind=="cast.close"){auto key="framely.cast."+c.at("id").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="window.open"){std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;const bool keyboard_surface=key==frame_keyboard_window_key;if(!keyboard_surface)hide_launcher(false);if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));if(!keyboard_surface){menu_open=false;launcher_open=false;}}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){bool requested=c.value("visible",false);if(!requested){if(views.count("notifications")){auto& v=*views.at("notifications");v.notification_requested=false;v.notification_content=false;show(v,false);}continue;}auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);v.notification_requested=true;anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing||key==frame_keyboard_window_key)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");keyboard_state(keyboard_open);if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_requested=false;views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "<<e.what()<<"\n";}}
}
static void release_framing_pointer(View& v){
if(!v.framing_left_down)return;v.framing_left_down=false;
auto mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_click_event(v.host,&mouse,MBT_LEFT,1,1);
auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.pointerRelease'))");frame->base.release(&frame->base);
}
static void reconcile_framing_pointer(View& v){
if((v.key!="framely.framing"&&!(v.key=="framely.screenshot"&&v.region_geometry.contains("view")))||!v.framing_left_down)return;
bool known=true,held=false,found=false,owner=false;
for(int hand=0;hand<2;hand++){
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
if(device==vr::k_unTrackedDeviceIndexInvalid)continue;
if(device==v.pointer_device){known=framing_trigger_known[hand];held=framing_trigger_down[hand];owner=true;break;}
}
if(!owner)for(int hand=0;hand<2;hand++){
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
if(device==vr::k_unTrackedDeviceIndexInvalid)continue;found=true;known&=framing_trigger_known[hand];held|=framing_trigger_down[hand];
}
if((owner||found)&&known&&!held)release_framing_pointer(v);
}
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if(v.key=="framely.system.radial"&&e.eventType==vr::VREvent_FocusLeave)v.pointer_valid=false;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;if(menu_open&&v.key!="menu"&&e.eventType==vr::VREvent_MouseButtonDown&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now()))menu_open=false;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))mouse.modifiers=v.framing_left_down?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{if(v.key!=frame_keyboard_window_key)v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view")))&&button==MBT_LEFT)v.framing_left_down=e.eventType==vr::VREvent_MouseButtonDown;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||(keyboard_open&&keyboard_view!=v.key)||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(!keyboard_open&&!v.dashboard&&v.key!=frame_keyboard_window_key&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key!="framely.system.radial"&&v.key.rfind("framely.cast.",0)!=0&&!apk_prompt(v.key)&&v.key!="framely.framing"&&v.key!="framely.screenshot")close_view(v);break;default:break;}}
int main(int argc,char** argv){
std::cout.setf(std::ios::unitbuf);std::string initial;int port=0;for(int i=1;i+1<argc;i++){std::string s=argv[i];if(s=="--url")initial=argv[++i];else if(s=="--port")port=std::stoi(argv[++i]);else if(s=="--runtime")runtime=argv[++i];}
const char* key=std::getenv("FRAMELY_NATIVE_TOKEN");if(key)token=key;
cef_main_args_t args{argc,argv};if(std::strcmp(cef_api_hash(CEF_API_VERSION,0),CEF_API_HASH_PLATFORM)){std::cerr<<"CEF ABI mismatch\n";return 2;}int child=cef_execute_process(&args,nullptr,nullptr);if(child>=0)return child;
if(initial.empty()||runtime.empty()||!port||token.empty())return 2;boot_url=initial;origin="http://127.0.0.1:"+std::to_string(port);std::signal(SIGINT,stop);std::signal(SIGTERM,stop);std::signal(SIGPIPE,SIG_IGN);
// Check the existing runtime without bootstrapping it before opening the renderer.
vr::EVRInitError vr_error;vr_system=vr::VR_Init(&vr_error,vr::VRApplication_Background);if(vr_error){std::cerr<<"SteamVR unavailable "<<vr_error<<"\n";return 3;}overlays=vr::VROverlay();
vr::VROverlayHandle_t native_main=0,native_dock=0;
if(!overlays||overlays->FindOverlay("valve.steam.gamepadui.main",&native_main)||overlays->FindOverlay("valve.steam.gamepadui.bar",&native_dock)){std::cerr<<"Waiting for native Steam UI\n";vr::VR_Shutdown();return 3;}
vr::VR_Shutdown();
// Frame's compositor requires the Overlay application type for visible UI.
// The session agent and service gate this renderer on all native services.
vr_system=vr::VR_Init(&vr_error,vr::VRApplication_Overlay);if(vr_error){std::cerr<<"SteamVR renderer unavailable "<<vr_error<<"\n";return 3;}overlays=vr::VROverlay();setup_entry_actions();system_radial_available=std::getenv("FRAMELY_STEAM_MENU_ENABLED")&&std::strcmp(std::getenv("FRAMELY_STEAM_MENU_ENABLED"),"1")==0;
xdisplay=XOpenDisplay(nullptr);if(!xdisplay){vr::VR_Shutdown();return 4;}int n=0;int attrs[]={GLX_DRAWABLE_TYPE,GLX_PBUFFER_BIT,GLX_RENDER_TYPE,GLX_RGBA_BIT,None};auto* configs=glXChooseFBConfig(xdisplay,DefaultScreen(xdisplay),attrs,&n);if(!configs||!n){vr::VR_Shutdown();return 4;}int pba[]={GLX_PBUFFER_WIDTH,16,GLX_PBUFFER_HEIGHT,16,None};glsurface=glXCreatePbuffer(xdisplay,configs[0],pba);glcontext=glXCreateNewContext(xdisplay,configs[0],GLX_RGBA_TYPE,nullptr,True);XFree(configs);if(!glcontext||!glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext)){vr::VR_Shutdown();return 4;}
cef_settings_t settings{};settings.size=sizeof(settings);settings.windowless_rendering_enabled=1;std::string locale;for(const char* key:{"LC_ALL","LC_MESSAGES","LANG"}){const char* value=std::getenv(key);if(value&&*value){locale=value;break;}}auto suffix=locale.find_first_of(".@");if(suffix!=std::string::npos)locale.erase(suffix);std::replace(locale.begin(),locale.end(),'_','-');if(locale=="C"||locale=="POSIX"||locale.empty())locale="en-US";str(settings.accept_language_list,locale+",en-US,en");str(settings.root_cache_path,runtime+"/cef-cache");str(settings.log_file,runtime+"/cef.log");if(!cef_initialize(&args,&settings,nullptr,nullptr)){vr::VR_Shutdown();return 5;}
render_handler.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t* b,cef_rect_t* r){auto* v=find_browser(b);*r={0,0,v?v->width:600,v?v->height:840};};
render_handler.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t* b,cef_screen_info_t* info){auto* v=find_browser(b);return render_screen_info(info,v?v->width:600,v?v->height:840,v?v->render_scale:1);};
render_handler.api.on_paint=[](cef_render_handler_t*,cef_browser_t* b,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){auto* v=find_browser(b);if(!v||v->closing||type!=PET_VIEW||w!=v->width*v->render_scale||h!=v->height*v->render_scale)return;merge_paint(v->pixels,data,w,h,dirty_count,dirty,true);for(auto& damage:v->texture_damage)damage.add(w,h,dirty_count,dirty);v->dirty=true;};
life_handler.api.on_before_popup=[](cef_life_span_handler_t*,cef_browser_t*,cef_frame_t*,int,const cef_string_t*,const cef_string_t*,cef_window_open_disposition_t,int,const cef_popup_features_t*,cef_window_info_t*,cef_client_t**,cef_browser_settings_t*,cef_dictionary_value_t**,int*){return 1;};
life_handler.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t* b){auto* v=find_browser(b);if(v){cancel_file_dialog(*v);v->closed=true;v->host->base.release(&v->host->base);v->host=nullptr;v->browser->base.release(&v->browser->base);v->browser=nullptr;}};
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);if(v&&v->key=="launcher"){const auto value=text(message);if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=1")launcher_fade.reduced=true;else if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=0")launcher_fade.reduced=false;else if(value=="FRAMELY_GAZE_FOCUS"&&gaze_active&&launcher_gaze_allowed()){
auto now=std::chrono::steady_clock::now();if(now-gaze_feedback_at>=std::chrono::milliseconds(180)){gaze_feedback_at=now;if(ui_sounds)ui_audio.play();int hand=gaze_feedback_hand;auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);if(device==vr::k_unTrackedDeviceIndexInvalid){hand=1-hand;device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);}if(ui_haptics&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRInput()->TriggerHapticVibrationAction(gaze_haptic_action,0,.012f,110.f,.16f,hands[hand])!=vr::VRInputError_None)vr_system->TriggerHapticPulse(device,0,1200);}
return 1;}}std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
load_handler.api.on_load_end=[](cef_load_handler_t*,cef_browser_t* browser,cef_frame_t* frame,int status){if(frame&&frame->is_main(frame)&&status>=400){auto* v=find_browser(browser);if(v&&v->key=="framely.apk.update"){std::cerr<<"APK update window HTTP error: "<<status<<"; closing modal to restore input\n";close_view(*v);return;}}inject_page_scroll(frame);};
client.api.get_load_handler=[](cef_client_t*){return load_handler.acquire();};
request_handler.api.on_before_browse=[](cef_request_handler_t*,cef_browser_t* b,cef_frame_t* f,cef_request_t* r,int,int){auto* uri=r->get_url(r);auto s=text(uri);cef_string_userfree_utf16_free(uri);auto* v=find_browser(b);return int(!allow_plugin_navigation(s,origin,!f||f->is_main(f),v&&!v->plugin.empty()));};
request_handler.api.on_render_process_terminated=[](cef_request_handler_t*,cef_browser_t* b,cef_termination_status_t,int,const cef_string_t*){auto* v=find_browser(b);if(v){std::cerr<<"Renderer crashed: "<<v->key<<"\n";close_view(*v);if(v->key=="menu")running=0;if(v->key=="launcher")launcher_open=false;}};
dialog_handler.api.on_file_dialog=[](cef_dialog_handler_t*,cef_browser_t* b,cef_file_dialog_mode_t mode,const cef_string_t*,const cef_string_t* default_path,cef_string_list_t filters,cef_string_list_t extensions,cef_string_list_t,cef_file_dialog_callback_t* callback){
auto* v=find_browser(b);if(!v||v->key!="framely.manager"||mode==FILE_DIALOG_SAVE)return 0;
cancel_file_dialog(*v);callback->base.add_ref(&callback->base);v->file_dialog=callback;v->file_multiple=mode==FILE_DIALOG_OPEN_MULTIPLE;v->file_directory=mode==FILE_DIALOG_OPEN_FOLDER;v->file_dialog_id=std::to_string(++file_dialog_sequence)+"-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
json accept=json::array();for(auto list:{filters,extensions}){if(!list)continue;for(size_t i=0;i<cef_string_list_size(list);++i){cef_string_t value{};cef_string_list_value(list,i,&value);auto filter=text(&value);cef_string_utf16_clear(&value);if(!filter.empty())accept.push_back(filter);}}
auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.fileDialog',{detail:"+json({{"id",v->file_dialog_id},{"multiple",v->file_multiple},{"directory",v->file_directory},{"accept",accept},{"defaultPath",default_path?text(default_path):std::string{}}}).dump()+"}))");frame->base.release(&frame->base);return 1;
};
client.api.get_dialog_handler=[](cef_client_t*){return dialog_handler.acquire();};client.api.get_render_handler=[](cef_client_t*){return render_handler.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life_handler.acquire();};client.api.get_display_handler=[](cef_client_t*){return display_handler.acquire();};client.api.get_request_handler=[](cef_client_t*){return request_handler.acquire();};
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);DockIconTexture icon_texture;icon_texture.init();if(!icon_texture.submit(button,pixels)){icon_texture.destroy();overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayInputMethod(button,vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{dock_canvas_width,dock_canvas_height}};overlays->SetOverlayMouseScale(button,&scale);vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;mask.m_Primitive.m_Rectangle={dock_icon_left,0,128,128};if(overlays->SetOverlayIntersectionMask(button,&mask,1)){std::cerr<<"Dock input mask unavailable\n";overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayFlag(button,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(button,0);overlays->SetOverlayFlag(button,vr::VROverlayFlags_HideLaserIntersection,false);
try{create_view("menu","Framely",initial,false,600,840);}catch(const std::exception& e){std::cerr<<e.what()<<"\n";running=0;}
if(system_radial_available)try{create_view("framely.system.radial.progress","Framely radial progress",initial+"?view=radial-progress",false,720,720);create_view("framely.system.radial","Framely radial menu",initial+"?view=radial",false,720,720);}catch(const std::exception& e){std::cerr<<"Radial menu unavailable: "<<e.what()<<"\n";system_radial_available=false;}
const char* steam_root=std::getenv("FRAMELY_STEAM_ROOT");if(!ui_audio.prepare(steam_root?steam_root:""))std::cerr<<"Steam UI sounds unavailable\n";
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));const auto launcher=data.value("launcher",json::object());launcher_prioritize_ui.store(launcher.value("prioritizeUi",true));system_radial_enabled.store(launcher.value("systemRadialMenu",true));std::lock_guard<std::mutex> lock(queue_mutex);recording_state=data.value("recording",json::object());recording_region_state=data.value("recordingRegion",json::object());framing_editor_state=data.value("framingEditor",json(nullptr));screenshot_editor_state=data.value("screenshotEditor",json(nullptr));screenshot_shortcut_preview=data.value("screenshotShortcutPreview",json(nullptr));if(!recording_state.is_object())recording_state=json::object();auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",false)}});last_feedback=feedback;}auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
std::thread priority_worker([]{LauncherPriority priority;while(running){priority.update(launcher_priority_active.load());std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;int icon_progress=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();json recording,region,framing,screenshot_editor,screenshot_preview;{std::lock_guard<std::mutex> lock(queue_mutex);recording=recording_state;region=recording_region_state;framing=framing_editor_state;screenshot_editor=screenshot_editor_state;screenshot_preview=screenshot_shortcut_preview;}if(auto editor=views.find("framely.framing");editor!=views.end()&&framing_editor_should_close(framing,editor->second->region_editor_id))close_view(*editor->second);if(auto editor=views.find("framely.screenshot");editor!=views.end()&&framing_editor_should_close(screenshot_editor,editor->second->region_editor_id))close_view(*editor->second);recording_indicator.tick(overlays,recording.value("active",false)&&recording.value("indicatorVisible",true)?recording.value("id",std::string{}):std::string{},recording.value("elapsedSeconds",uint64_t(0)));if(auto editor=views.find("framely.framing");editor!=views.end())update_framing_placement(*editor->second);if(auto editor=views.find("framely.screenshot");editor!=views.end()&&editor->second->region_geometry.is_object()&&editor->second->region_geometry.contains("view"))update_framing_placement(*editor->second);if(screenshot_radial_held&&screenshot_preview.is_object()&&screenshot_preview.value("visible",false))screenshot_region_overlay.tick(overlays,screenshot_preview);else screenshot_region_overlay.destroy();if(region.value("visible",false)&&region.contains("view")){auto presentation=recording_region_presentation(region["view"]);recording_region_overlay.tick(overlays,region,presentation.distance,&presentation.view);}else recording_region_overlay.tick(overlays,region);external_keyboard_tick(std::chrono::steady_clock::now());vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}observe_dashboard(global);keyboard_event(global);}launcher_priority_active.store(launcher_open&&launcher_prioritize_ui.load());vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
if(visible&&(!was_visible||dock!=last_dock)){uv={};anchored_at={};sampled_at={};}last_dock=dock;
// Width and aspect are only ray-solver seeds; physical size comes from intersections.
float width=0,height=0;bool fresh=visible&&!overlays->GetOverlayWidthInMeters(dock,&width)&&std::isfinite(width)&&width>0&&width<10&&std::isfinite(mouse_scale.v[0])&&std::isfinite(mouse_scale.v[1])&&mouse_scale.v[0]>0&&mouse_scale.v[1]>0;
if(fresh){height=width*mouse_scale.v[1]/mouse_scale.v[0];fresh=std::isfinite(height)&&height>.005f&&height<2;}
vr::HmdMatrix34_t center{},anchor{};vr::HmdVector3_t head{};DockCurveEvidence evidence{};float size=0;bool anchor_ok=false;
vr::HmdMatrix34_t frame{};bool frame_ok=visible&&fresh&&!overlays->GetTransformForOverlayCoordinates(dock,vr::TrackingUniverseStanding,{{mouse_scale.v[0]*.5f,mouse_scale.v[1]*.5f}},&center)&&dock_rigid_frame(center,frame);
auto sample_now=std::chrono::steady_clock::now();
if(frame_ok&&sample_now-sampled_at>=std::chrono::milliseconds(100)){sampled_at=sample_now;anchor_ok=dock_probe_origin(center,head)&&dock_uv_anchor(overlays,dock,center,head,width,height,anchor,evidence,size,uv);}
auto now=std::chrono::steady_clock::now();if(anchor_ok){if(evidence.radius>0)notification_curve_radius=evidence.radius;saved_anchor=anchor;saved_frame=frame;saved_size=size;saved_evidence=evidence;anchored_at=now;}else if(frame_ok&&now-anchored_at<std::chrono::milliseconds(350)){anchor=dock_follow_pose(saved_anchor,saved_frame,frame);size=saved_size;evidence=saved_evidence;anchor_ok=true;}
if(keyboard_menu.holds(now)){if(views.count("menu"))show(*views.at("menu"),menu_open);}else if(anchor_ok){// Apply the button tilt in its pose: nonzero API pre-curve pitch hid it on Frame.
auto icon_pose=anchor;float cp=std::cos(evidence.pre_curve_pitch),sp=std::sin(evidence.pre_curve_pitch);for(int r=0;r<3;r++){icon_pose.m[r][1]=anchor.m[r][1]*cp+anchor.m[r][2]*sp;icon_pose.m[r][2]=anchor.m[r][2]*cp-anchor.m[r][1]*sp;}button_placement.apply(overlays,button,icon_pose,size*5);button_placement.curve(overlays,button,size*5,evidence.radius,0);if(!overlays->IsOverlayVisible(button))overlays->ShowOverlay(button);if(views.count("menu")){MenuPlacement popup{};auto& v=*views.at("menu");vr::HmdMatrix34_t main_pose{};vr::VROverlayHandle_t main_overlay=0;vr::HmdVector2_t main_scale{};const vr::HmdMatrix34_t* main=nullptr;
for(auto&[key,view]:views)if(view->dashboard&&view->shown&&!view->closing)main_overlay=view->overlay;
if(!main_overlay)main_overlay=active_main_window();
if(main_overlay&&!overlays->GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose;
if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);}
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!=frame_keyboard_window_key&&key!="menu"&&key!="notifications"&&key!="launcher"&&key!="framely.system.radial"&&key!="framely.system.radial.progress"&&key.rfind("framely.cast.",0)!=0&&!apk_prompt(key))close_view(*v);}was_visible=visible;
if(launcher_close_at.time_since_epoch().count()&&now>=launcher_close_at){launcher_close_at={};if(!launcher_open)finish_launcher_close();}
// The runtime owns dashboard placement, curvature, visibility and switching.
// Never move the launcher into standing space or hide another app's overlay.
if(launcher_open)launcher_dock_interaction();
if(views.count("launcher")){
bool selected=overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay);
if(launcher_open&&selected)launcher_selected=true;
if(launcher_open&&selected&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(views.at("launcher")->overlay)&&views.at("launcher")->texture_ready[views.at("launcher")->texture_slot])launcher_fade.ready(now);
if(launcher_open||launcher_close_at.time_since_epoch().count())overlays->SetOverlayAlpha(views.at("launcher")->overlay,launcher_open?std::max(.02f,launcher_fade.value(now)):launcher_fade.value(now));
if(launcher_open&&!selected&&overlays->IsDashboardVisible()&&!launcher_dock_interaction()&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(5))){hide_launcher(false,true);}
}
auto activate_entry=[&](EntryHold::Action action){
if(action==EntryHold::Action::Idle)return;
if(ui_sounds)ui_audio.play(true);
toggle_entry(action==EntryHold::Action::Launcher);
if(menu_open&&views.count("menu"))views.at("menu")->host->set_focus(views.at("menu")->host,1);
};
vr::VREvent_t event{};while(overlays->PollNextOverlayEvent(button,&event,sizeof(event))){
laser_cursor(button,event);
if(event.eventType==vr::VREvent_MouseMove||event.eventType==vr::VREvent_MouseButtonDown||event.eventType==vr::VREvent_MouseButtonUp)entry_pointer.move(event.trackedDeviceIndex,event.data.mouse.x,event.data.mouse.y);
if(event.eventType==vr::VREvent_FocusLeave)entry_pointer.leave();
if(event.eventType==vr::VREvent_MouseButtonDown||event.eventType==vr::VREvent_MouseButtonUp){
// Cancel any existing gaze gesture before toggling views. This also
// covers a complete down/up pair drained in one frame and unknown devices.
if(event.eventType==vr::VREvent_MouseButtonDown&&anchor_ok&&entry_pointer.inside){
auto edge=gaze_press.cancel();if(gaze_active||edge==LauncherGazePress::Cancel)launcher_gaze_event({{"valid",false},{"phase","cancel"}});gaze_active=false;
}
const auto received=std::chrono::steady_clock::now();const bool was_pressed=entry_hold.pressed;const auto previous_started=entry_hold.started;
activate_entry(entry_hold.event(event.eventType==vr::VREvent_MouseButtonDown,event.trackedDeviceIndex,event.data.mouse.button,anchor_ok&&(event.trackedDeviceIndex==vr::k_unTrackedDeviceIndexInvalid||vr_system->IsTrackedDeviceConnected(event.trackedDeviceIndex)),entry_pointer.inside,entry_event_time(received,event.eventAgeSeconds),received));
if(entry_hold.pressed&&(!was_pressed||entry_hold.started!=previous_started)){
entry_source_buttons=0;vr::VRControllerState_t state{};
if(entry_hold.device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->GetControllerState(entry_hold.device,&state,sizeof(state))){
const auto previous=entry_previous_buttons.find(entry_hold.device);
if(previous!=entry_previous_buttons.end())entry_source_buttons=state.ulButtonPressed&~previous->second;
// An unambiguous single button is safe even if its down event arrived late.
if(!entry_source_buttons&&state.ulButtonPressed&&(state.ulButtonPressed&(state.ulButtonPressed-1))==0)entry_source_buttons=state.ulButtonPressed;
}
}
}
}
if(!anchor_ok)entry_pointer.leave();
const bool entry_connected=entry_hold.device==vr::k_unTrackedDeviceIndexInvalid||vr_system->IsTrackedDeviceConnected(entry_hold.device);
const bool entry_inside=entry_pointer.inside&&entry_pointer.device==entry_hold.device;
if(entry_hold.pressed&&entry_source_buttons){vr::VRControllerState_t state{};if(vr_system->GetControllerState(entry_hold.device,&state,sizeof(state))&&!(state.ulButtonPressed&entry_source_buttons))activate_entry(entry_hold.released(anchor_ok&&entry_connected,entry_inside));}
activate_entry(entry_hold.tick(std::chrono::steady_clock::now(),anchor_ok&&entry_connected,entry_inside));
for(auto&[key,v]:views)v->stick_scrolling=false;
entry_actions(button,anchor_ok);
system_radial_tick();
// Some dashboard runtimes do not emit Modal_Cancel for clicks on their own
// overlays. Observe the same controller click edge as a fallback, without
// consuming the click destined for the other surface.
for(int hand=0;hand<2;hand++){
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
if(device==vr::k_unTrackedDeviceIndexInvalid)continue;
vr::VRControllerState_t state{};
if(!vr_system->GetControllerState(device,&state,sizeof(state))){menu_previous_buttons.erase(device);continue;}
auto previous=menu_previous_buttons.find(device);
uint64_t pressed=previous==menu_previous_buttons.end()?0:state.ulButtonPressed&~previous->second;
menu_previous_buttons[device]=state.ulButtonPressed;
const uint64_t click=vr::ButtonMaskFromId(vr::k_EButton_SteamVR_Trigger)|vr::ButtonMaskFromId(vr::k_EButton_A)|vr::ButtonMaskFromId(vr::k_EButton_DPad_Down);
if(menu_open&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now())&&(pressed&click)&&views.count("menu")&&!overlays->IsHoverTargetOverlay(views.at("menu")->overlay)&&!overlays->IsHoverTargetOverlay(button))menu_open=false;
}
if(launcher_open){bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held;launcher_dismiss.observe(held);}
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){if(!active&&key=="launcher"){if(v->launcher_pointer.active){cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;v->host->send_mouse_move_event(v->host,&outside,0);outside.modifiers=0;v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);}v->launcher_pointer.clear();auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->base.release(&frame->base);}v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;if(key!="launcher")for(auto&[other,t]:views)if(!t->dashboard&&other!=frame_keyboard_window_key&&other!="menu"&&other!="notifications"&&other!="launcher"&&other!="framely.system.radial"&&other!="framely.system.radial.progress"&&other.rfind("framely.cast.",0)!=0&&other!="framely.apk.permission"&&!keyboard_open)close_view(*t);}}
reconcile_framing_pointer(*v);
while(overlays->PollNextOverlayEvent(v->overlay,&event,sizeof(event)))overlay_input(*v,event);
if(!v->cast_frame.path.empty()){glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);if(v->cast_frame.update())v->dirty=true;}
if(v->dirty&&!v->closing&&v->pixels.size()==size_t(v->width*v->render_scale)*size_t(v->height*v->render_scale)*4){
glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);
// Upload to the other texture; never redefine storage submitted this frame.
const int slot=1-v->texture_slot;const GLuint id=v->textures[slot];
auto& damage=v->texture_damage[slot];const int pixel_width=v->width*v->render_scale,pixel_height=v->height*v->render_scale;
static auto generate_mipmap=reinterpret_cast<PFNGLGENERATEMIPMAPPROC>(glXGetProcAddressARB(reinterpret_cast<const GLubyte*>("glGenerateMipmap")));
bool mipmapped=v->key=="launcher"&&generate_mipmap;
glBindTexture(GL_TEXTURE_2D,id);
glPixelStorei(GL_UNPACK_ALIGNMENT,4);glPixelStorei(GL_UNPACK_ROW_LENGTH,0);
glPixelStorei(GL_UNPACK_SKIP_PIXELS,0);glPixelStorei(GL_UNPACK_SKIP_ROWS,0);
if(!v->texture_ready[slot]){
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,mipmapped?GL_LINEAR_MIPMAP_LINEAR:GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,pixel_width,pixel_height,0,GL_BGRA,GL_UNSIGNED_BYTE,nullptr);
v->texture_ready[slot]=true;
damage.include(0,0,pixel_width,pixel_height);
}
if(!damage.empty()){
glPixelStorei(GL_UNPACK_ROW_LENGTH,pixel_width);
glTexSubImage2D(GL_TEXTURE_2D,0,damage.x,damage.y,damage.width,damage.height,GL_BGRA,GL_UNSIGNED_BYTE,v->pixels.data()+(size_t(damage.y)*pixel_width+damage.x)*4);
glPixelStorei(GL_UNPACK_ROW_LENGTH,0);damage.clear();
if(mipmapped)generate_mipmap(GL_TEXTURE_2D);
}
GLuint submitted=v->cast_frame.draw(id,pixel_width,pixel_height);
// glFlush queues the upload; glFinish makes the full image ready for sharing.
glFinish();vr::Texture_t texture{reinterpret_cast<void*>(uintptr_t(submitted)),vr::TextureType_OpenGL,vr::ColorSpace_Auto};
auto e=overlays->SetOverlayTexture(v->overlay,&texture);glFlush();
if(e)std::cerr<<"Texture submission failed "<<key<<" "<<e<<"\n";
else{v->texture_slot=slot;v->dirty=false;
if(key=="notifications"){
// Visibility follows the submitted frame, never a queue-change event.
// Keep CEF painting while the overlay is hidden so a later toast can appear.
// A transparent frame disables input even if the page stays mounted.
auto bounds=notification_bounds(v->pixels,v->width*v->render_scale,v->height*v->render_scale,v->render_scale);
bool visible=bounds.visible();
if(visible){
vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;
mask.m_Primitive.m_Rectangle={bounds.x,bounds.y,bounds.width,bounds.height};
auto error=overlays->SetOverlayIntersectionMask(v->overlay,&mask,1);
if(error){std::cerr<<"Notification input mask failed "<<error<<"\n";visible=false;}
}
v->notification_content=visible;
}
}
}
if(key=="notifications"){
if(v->notification_click.active&&std::chrono::steady_clock::now()-v->notification_click.pressed>std::chrono::milliseconds(800)){
// A release outside the overlay may not arrive. Never leave CEF pressed.
cef_mouse_event_t outside{};outside.x=outside.y=-1;
v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);v->notification_click.clear();
}
if(v->notification_content&&!v->placement.valid)anchor_notification(*v);
if(v->placement.valid)v->placement.curve(overlays,v->overlay,v->placement.width,notification_curve_radius);
show(*v,v->notification_requested&&v->notification_content&&v->placement.valid);
}
}
{json snapshot=json::object();for(auto&[key,v]:views){if(key=="menu"||key=="launcher"||key=="framely.manager"||key.rfind("framely.window.",0)==0)snapshot[key]=capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay));}std::lock_guard<std::mutex> lock(visibility_mutex);visible_views=std::move(snapshot);}
if(keyboard_close_pending){
// A non-minimal runtime keyboard may buffer text until Done. Drain overlay
// character events first so the global Done event cannot duplicate input.
if(keyboard_toolbar&&keyboard_done&&!keyboard_chars){char text[16385]{};overlays->GetKeyboardText(text,sizeof(text));keyboard_edit(keyboard_frame,std::string(text,strnlen(text,sizeof(text))));}
close_keyboard(false);
}
for(auto i=views.begin();i!=views.end();){auto& v=*i->second;if(v.closed){overlays->ClearOverlayTexture(v.overlay);overlays->DestroyOverlay(v.overlay);if(v.thumbnail)overlays->DestroyOverlay(v.thumbnail);glDeleteTextures(2,v.textures);i=views.erase(i);}else ++i;}
bool hovered=anchor_ok&&(entry_hold.pressed||overlays->IsHoverTargetOverlay(button));if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);const auto badge=notification_badge_count.load();const float ring=entry_hold.progress(std::chrono::steady_clock::now());const int progress=ring>0?std::max(1,int(std::lround(ring*120))):0;if(icon_progress!=progress||icon_active!=(menu_open||launcher_open)||icon_hover!=hovered||icon_badge!=badge){pixels=dock_icon(hovered,menu_open||launcher_open,progress/120.f);if(icon_texture.submit(button,pixels)){icon_progress=progress;icon_badge=badge;icon_active=menu_open||launcher_open;}}icon_hover=hovered;
// Remember source state only after draining events, to identify the actual
// newly pressed button rather than unrelated buttons held on the controller.
for(auto device:{entry_pointer.device,entry_hold.device})if(device!=vr::k_unTrackedDeviceIndexInvalid){vr::VRControllerState_t state{};if(vr_system->GetControllerState(device,&state,sizeof(state)))entry_previous_buttons[device]=state.ulButtonPressed;else entry_previous_buttons.erase(device);}
auto sync=overlays->WaitFrameSync(8);
// A timeout already waited: never append another sleep to it. Bound retries
// only for immediate API failures, so a broken sync call cannot busy-spin.
if(sync!=vr::VROverlayError_None&&sync!=vr::VROverlayError_TimedOut)std::this_thread::sleep_until(frame_start+std::chrono::milliseconds(8));
}
recording_indicator.destroy();
recording_region_overlay.destroy();
screenshot_region_overlay.destroy();
if(external_keyboard.active){frame_keyboard_command("dashboardClose",json::object());external_keyboard={};}
close_keyboard();for(auto&[key,v]:views)close_view(*v);auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(5);while(std::chrono::steady_clock::now()<deadline){cef_do_message_loop_work();bool all=true;for(auto&[key,v]:views)all&=v->closed;if(all)break;std::this_thread::sleep_for(std::chrono::milliseconds(5));}poller.join();priority_worker.join();for(auto&[key,v]:views)if(!v->closed){std::cerr<<"Browser shutdown timed out\n";_Exit(7);}for(auto&[key,v]:views){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);glDeleteTextures(2,v->textures);}views.clear();overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();glXMakeContextCurrent(xdisplay,None,None,nullptr);glXDestroyContext(xdisplay,glcontext);glXDestroyPbuffer(xdisplay,glsurface);XCloseDisplay(xdisplay);return 0;
}