fix(launcher): restore Dock windows and add bounded UI scheduling priority

This commit is contained in:
toorux committed 2026-10-07 00:02:06 +08:00
1 parent 831f3707d3
commit fcaaa561ce
16 files changed
+190 -30

No files matched your search

+6
View File
@@ -65,3 +65,9 @@ Hold the primary trigger and move the laser up or down to drag scrollable page c
Launcher blank-space clicks only dismiss the icon menu. Click outside the launcher window or press B to dismiss it (B exits arrangement first). Steam’s move/depth controls keep the window open and pause stick navigation until the stick returns to neutral. While dragging an icon in arrangement mode, horizontal stick input changes pages; vertical input does not change categories. The boundary rails remain an alternative: hold the pointer beyond a rail for 650 ms to move to the adjacent page.
## Launcher resource priority and closing
Settings → Launcher includes **Prioritize launcher resources**, enabled by default. While the launcher is open, Framely raises its native UI and CEF thread CPU priority to a bounded nice level of −5, then restores the previous priorities when closed or disabled. Games and terminal processes are excluded. This can help CPU contention; it does not reserve GPU capacity. The installed session service must include the matching priority limit.
Closing the launcher returns to the previously selected Dock window when it is still available. Starting an application hands focus to Steam instead of restoring the old window. APK windows are handed off after Android confirms the activity launch; startup health checks continue afterward. A running container with a hidden application window is reused rather than restarted solely because the previous game exited.
+6
View File
@@ -69,3 +69,9 @@ sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs
启动台窗口内部的空白点击只收起图标菜单。点击窗口外或按 B 关闭;调整模式下 B 先退出调整。Steam 的移动、推远和拉近控件会保持窗口打开,并暂停摇杆导航,摇杆回中后恢复。调整模式拖拽图标时可以用摇杆左右翻页,上下不会切换分类;也可将指针移过分页线并停留 650 毫秒跨页。
## 启动台资源优先与关闭
设置 → 启动台新增默认开启的**启动台资源优先**。启动台打开时,将 Framely 原生界面与 CEF 线程的 CPU 调度优先级提高到有界的 nice −5,关闭启动台或禁用设置后恢复原优先级。游戏与终端进程不参与提升。此设置可以改善 CPU 竞争,不预留 GPU 资源;安装后的会话服务需要包含对应的优先级限制。
关闭启动台时,之前选中的 Dock 窗口仍存在则返回该窗口。启动应用时由 Steam 接管焦点,不恢复旧窗口。Android 确认应用启动后即交接 APK 窗口,后续仍进行启动存活检查。运行中的容器即使已隐藏应用窗口也会复用,不再仅因之前游戏退出就重启。
+48 -12
View File
@@ -11,6 +11,7 @@
#include "menu_input.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"
@@ -43,6 +44,7 @@
#include <mutex>
#include <thread>
#include <deque>
#include <set>
#include <unistd.h>
using json=nlohmann::json;
@@ -66,6 +68,12 @@ 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 std::atomic<bool> launcher_primary{true};
static bool entry_actions_ready=false;
@@ -286,10 +294,36 @@ static bool launcher_dock_interaction(){
if(launcher_dock.holds(now)){launcher_outside_at={};return true;}
return false;
}
static void hide_launcher(){
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(){
if(!views.count("launcher"))return;auto& v=*views.at("launcher");
// Do not steal focus if Steam or the user already selected another window.
if(!overlays->IsActiveDashboardOverlay(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;launcher_calibrating=false;launcher_open=false;launcher_selected=false;launcher_dock.clear();launcher_stick={};
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);
@@ -309,18 +343,19 @@ static bool launcher_accepts(const vr::VREvent_t& e){
}
static void toggle_entry(bool launcher){
if(launcher){
menu_open=false;if(views.count("menu"))show(*views.at("menu"),false);
if(launcher_open){hide_launcher();return;}
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_close_at={};launcher_dock.clear();launcher_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={};
++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={};
// Steam must see a visible dashboard surface before activating it. Zero
// alpha can prevent activation, so never wait for selection to start a fade.
overlays->SetOverlayAlpha(view.overlay,1.f);
overlays->SetOverlayAlpha(view.overlay,1.f);overlays->ShowOverlay(view.overlay);
overlays->ShowDashboard("launcher");
launcher_fade.open(std::chrono::steady_clock::now());
auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.open'))");frame->base.release(&frame->base);
}else{hide_launcher();menu_open=!menu_open;if(menu_open)menu_button.opened(std::chrono::steady_clock::now());}
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;
@@ -470,7 +505,7 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
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=="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"){hide_launcher();}else if(kind=="menu.open"){hide_launcher();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)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){hide_launcher();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=="window.open"){hide_launcher();std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;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{}));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"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);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)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_EGamepadTLine truncated
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=="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)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}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=="window.open"){hide_launcher(false);std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;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{}));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"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);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)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","");ifLine truncated
}
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;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;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=="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=="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:{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;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||!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(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")close_view(v);break;default:break;}}
@@ -510,9 +545,10 @@ int main(int argc,char** argv){
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);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);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);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;}
std::thread poller([port]{int failures=0;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_primary.store(data.value("launcher",json::object()).value("primaryTrigger",true));std::lock_guard<std::mutex> lock(queue_mutex);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 poller([port]{int failures=0;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_primary.store(data.value("launcher",json::object()).value("primaryTrigger",true));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);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;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();vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}keyboard_event(global);}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);
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();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;
@@ -529,7 +565,7 @@ int main(int argc,char** argv){
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())launcher_open=false;for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher")close_view(*v);}was_visible=visible;
if(launcher_close_at.time_since_epoch().count()&&now>=launcher_close_at){launcher_close_at={};if(!launcher_open&&views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay))overlays->ShowDashboard("valve.steam.gamepadui.main");}
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();
@@ -626,5 +662,5 @@ int main(int argc,char** argv){
// 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));
}
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();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);cef_shutdown();vr::VR_Shutdown();glXMakeContextCurrent(xdisplay,None,None,nullptr);glXDestroyContext(xdisplay,glcontext);glXDestroyPbuffer(xdisplay,glsurface);XCloseDisplay(xdisplay);return 0;
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);cef_shutdown();vr::VR_Shutdown();glXMakeContextCurrent(xdisplay,None,None,nullptr);glXDestroyContext(xdisplay,glcontext);glXDestroyPbuffer(xdisplay,glsurface);XCloseDisplay(xdisplay);return 0;
}
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <algorithm>
#include <cerrno>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <map>
#include <sstream>
#include <sys/resource.h>
#include <sys/stat.h>
#include <unistd.h>
// Only the native renderer and its CEF children participate. Android, terminal
// shells and desktop applications belong to the session agent, not this tree.
class LauncherPriority {
struct Task { unsigned long long born; int nice; };
struct Process { int parent; unsigned long long born; };
std::map<int,Task> saved;
bool active=false,warned=false;
int baseline=getpriority(PRIO_PROCESS,0);
std::chrono::steady_clock::time_point scanned{};
static bool stat(int id,Process& result){
std::ifstream f("/proc/"+std::to_string(id)+"/stat");std::string line;std::getline(f,line);
auto end=line.rfind(')');if(end==std::string::npos)return false;
std::istringstream s(line.substr(end+2));std::string value;
for(int field=3;field<=22;field++){if(!(s>>value))return false;try{if(field==4)result.parent=std::stoi(value);if(field==22)result.born=std::stoull(value);}catch(...){return false;}}
return true;
}
public:
static int target(int original){return std::min(original,-5);}
void update(bool enabled){
auto now=std::chrono::steady_clock::now();if(enabled==active&&(!enabled||now-scanned<std::chrono::seconds(1)))return;
const bool continuing=active;
// Include threads born since the last scan before restoring their inherited priority.
if(!enabled&&active){scanned={};update(true);}
active=enabled;scanned=now;
if(!enabled){for(auto [id,task]:saved){Process p{};if(stat(id,p)&&p.born==task.born)setpriority(PRIO_PROCESS,id,task.nice);}saved.clear();return;}
try {
std::error_code ec;auto exe=std::filesystem::read_symlink("/proc/self/exe",ec);if(ec)return;
std::map<int,Process> processes;
for(auto& entry:std::filesystem::directory_iterator("/proc",ec)){
const auto name=entry.path().filename().string();if(name.empty()||name.find_first_not_of("0123456789")!=std::string::npos)continue;
struct ::stat info{};if(::stat(entry.path().c_str(),&info)||info.st_uid!=geteuid())continue;
if(std::filesystem::read_symlink(entry.path()/"exe",ec)!=exe){ec.clear();continue;}
Process p{};int id=std::stoi(name);if(stat(id,p))processes[id]=p;
}
for(auto [pid,process]:processes){
int parent=pid;for(int i=0;i<32&&parent!=getpid();i++){auto p=processes.find(parent);if(p==processes.end()){parent=0;break;}parent=p->second.parent;}
if(parent!=getpid())continue;
for(auto& entry:std::filesystem::directory_iterator("/proc/"+std::to_string(pid)+"/task",ec)){
int id;try{id=std::stoi(entry.path().filename().string());}catch(...){continue;}
Process p{};if(!stat(id,p))continue;
auto old=saved.find(id);if(old!=saved.end()&&old->second.born!=p.born){saved.erase(old);old=saved.end();}
if(old==saved.end()){errno=0;int nice=getpriority(PRIO_PROCESS,id);if(errno)continue;
// CEF threads created during a boost inherit it; restore the baseline.
if(continuing&&nice==target(baseline))nice=baseline;
old=saved.emplace(id,Task{p.born,nice}).first;
}
if(setpriority(PRIO_PROCESS,id,target(old->second.nice))&&!warned&&errno!=ESRCH){warned=true;std::cerr<<"Launcher CPU priority unavailable (requires service LimitNICE=25)\n";}
}
}
for(auto it=saved.begin();it!=saved.end();){Process p{};if(!stat(it->first,p)||p.born!=it->second.born)it=saved.erase(it);else++it;}
} catch(const std::filesystem::filesystem_error&) { /* Child may exit during enumeration; retry next tick. */ }
}
~LauncherPriority(){update(false);}
};
+2
View File
@@ -200,6 +200,8 @@ ExecStart=$root/current/bin/framely session --state $root --assets $root/current
Restart=on-failure
RestartSec=5
KillMode=control-group
# Allow a bounded, reversible UI-only boost; no realtime scheduling.
LimitNICE=25
TimeoutStopSec=10
[Install]
WantedBy=multi-user.target
+3
View File
@@ -179,6 +179,8 @@ pub struct LauncherAction {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LauncherSettings {
#[serde(default = "yes")]
pub prioritize_ui: bool,
#[serde(default = "yes")]
pub primary_trigger: bool,
#[serde(default = "yes")]
@@ -244,6 +246,7 @@ fn menu_timeout() -> u32 {
impl Default for LauncherSettings {
fn default() -> Self {
Self {
prioritize_ui: true,
primary_trigger: true,
menu_auto_close: true,
menu_timeout_seconds: 10,
+8 -3
View File
@@ -927,6 +927,7 @@ impl Agent {
"desktop.list" => Ok(json!(crate::desktop::discover(&crate::steam::home()?))),
"desktop.launch" => {
let id = p["app"].as_str().context("Missing desktop app")?;
let launcher_session = p["launcherSession"].as_u64();
if let Some(app) = id.strip_prefix("lepton:") {
let commands = self.commands.clone();
crate::apk::launch_app_with_progress(
@@ -934,7 +935,10 @@ impl Agent {
app,
Arc::new(move |event| {
if event["phase"] == "started" {
push(&commands, json!({"kind":"launcher.close","handoff":true}));
push(
&commands,
json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}),
);
}
}),
)?;
@@ -942,13 +946,14 @@ impl Agent {
crate::desktop::launch(&crate::steam::home()?, id)?;
push(
&self.commands,
json!({"kind":"launcher.close","handoff":true}),
json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}),
);
}
Ok(json!(true))
}
"steam.list" => Ok(json!(crate::steam::discover(&crate::steam::home()?))),
"steam.launch" => {
let launcher_session = p["launcherSession"].as_u64();
let id = p["app"]
.as_u64()
.filter(|v| *v > 0 && *v <= u32::MAX as u64)
@@ -956,7 +961,7 @@ impl Agent {
crate::steam::launch(id)?;
push(
&self.commands,
json!({"kind":"launcher.close","handoff":true}),
json!({"kind":"launcher.close","handoff":true,"launcherSession":launcher_session}),
);
Ok(json!(true))
}
+7 -1
View File
@@ -1973,13 +1973,14 @@ fn launcher_dispatch_preserves_old_open_semantics_and_window_context() {
core.handle(
"launcher.settings.save",
json!({"primaryTrigger":true,"menuAutoClose":false,"menuTimeoutSeconds":25}),
json!({"primaryTrigger":true,"menuAutoClose":false,"menuTimeoutSeconds":25,"prioritizeUi":false}),
)
.unwrap();
let loaded = Service::load(&root.path().join("state"), unsafe { libc::geteuid() }).unwrap();
assert!(loaded.db.launcher.primary_trigger);
assert!(!loaded.db.launcher.menu_auto_close);
assert_eq!(loaded.db.launcher.menu_timeout_seconds, 25);
assert!(!loaded.db.launcher.prioritize_ui);
let order = vec![
"framely",
"plugin:launch.routes",
@@ -2004,6 +2005,11 @@ fn launcher_dispatch_preserves_old_open_semantics_and_window_context() {
#[test]
fn launcher_categories_migrate_validate_and_favorites_survive_reload() {
assert!(
serde_json::from_value::<crate::model::LauncherSettings>(json!({}))
.unwrap()
.prioritize_ui
);
let root = tempfile::tempdir().unwrap();
let state = root.path().join("state");
let mut core = accepted_service(&state, unsafe { libc::geteuid() }).unwrap();
+3 -2
View File
@@ -1,6 +1,7 @@
import React,{useState} from 'react';import{createRoot}from'react-dom/client';
import{Launcher,LauncherSettings,type LauncherConfig}from'../ui/src/launcher';import{configureLanguage}from'../ui/src/i18n';import type{Plugin}from'../ui/src/main';import'../ui/src/style.css';
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<120;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,240)+' '+JSON.stringify(calls.slice(-4))+' edit='+!!document.querySelector('.launcher-editing'));};
(window as Window & {framelyLauncherSession?:number}).framelyLauncherSession=42;
let stalledMethod='',releaseStalled:((r:Response)=>void)|undefined;
let config:LauncherConfig={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:1};let order:string[]=[];let localApps:any[]=[];let redraw:()=>void;let settings=false;const calls:{method:string;params:any}[]=[];
const plugins:Record<string,Plugin>={
@@ -8,7 +9,7 @@ const plugins:Record<string,Plugin>={
'demo.beta':{manifest:{id:'demo.beta',icon:'icon.png',name:'Passthrough color',description:'Color',author:'Framely',version:'1.0.0',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:false,order:1},
'demo.off':{manifest:{id:'demo.off',name:'Disabled app',description:'',author:'Framely',version:'1.0.0',ui:{windows:{}}},enabled:false,favorite:false,order:2},
};
window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected fetch');const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});if(method===stalledMethod)return new Promise<Response>(resolve=>{releaseStalled=resolve;});let result:any=true;
window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected fetch');const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});if(["desktop.launch","steam.launch"].includes(method)&&params.launcherSession!==42)throw Error("Launch did not carry the current launcher session");if(method===stalledMethod)return new Promise<Response>(resolve=>{releaseStalled=resolve;});let result:any=true;
if(method==='steam.list')result=[{id:620980,name:'Beat Saber'},{id:546560,name:'Half-Life: Alyx'},{id:413150,name:'Stardew Valley'},{id:1245620,name:'Elden Ring'},{id:367520,name:'Hollow Knight'},{id:570,name:'Dota 2'},{id:440,name:'Team Fortress 2'}].map(a=>({...a,icon:`/preview-icons/${a.id}.jpg`,iconFit:'cover'}));
else if(method==='desktop.list')result=localApps;
else if(method==='status')result={database:{plugins,safeMode:false}};
@@ -104,6 +105,6 @@ function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-i
stalledMethod='';releaseStalled!(new Response(JSON.stringify({result:true})));window.setTimeout=realTimeout;await wait(100);
console.log('FRAMELY_LAUNCHER_TIMEOUT_RECOVERY_OK');
const closes=calls.filter(c=>c.method==='host.launcher.close').length;document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));window.dispatchEvent(new Event('framely.back'));await until(()=>calls.filter(c=>c.method==='host.launcher.close').length===closes+1);window.dispatchEvent(new Event('framely.launcher.closing'));await wait(80);const closingAnimation=document.querySelector('.launcher-shell')!.getAnimations()[0];if(!closingAnimation||Number((closingAnimation.effect as KeyframeEffect).getKeyframes().at(-1)?.opacity)!==0)throw Error('Closing animation missing');window.dispatchEvent(new Event('framely.launcher.open'));await wait(500);
settings=true;redraw();await wait();const switches=document.querySelectorAll<HTMLInputElement>('[role=switch]');switches[0].click();await wait();if(!config.primaryTrigger)throw Error('Trigger setting not saved');await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');configureLanguage('en-US',[]);redraw();await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');console.log('FRAMELY_BRIDGE_PASS');
settings=true;redraw();await wait();const switches=document.querySelectorAll<HTMLInputElement>('[role=switch]');switches[0].click();await wait();if(config.prioritizeUi!==false)throw Error("Resource priority setting not saved");switches[1].click();await wait();if(!config.primaryTrigger)throw Error('Trigger setting not saved');await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');configureLanguage('en-US',[]);redraw();await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
createRoot(document.getElementById('root')!).render(<Fixture/>);wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
+5 -4
View File
@@ -8,10 +8,11 @@ window.fetch=async(_url,init)=>{const {method,params}=JSON.parse(String(init?.bo
return new Response(JSON.stringify({result}));};
const nav=(label:string)=>[...document.querySelectorAll<HTMLButtonElement>('.workspace>nav button')].find(b=>b.textContent===label)!;
(window as any).runInstallReviewChecks=async()=>{try{
await until(()=>!!document.querySelector('.launcher-settings'));if(nav('Launcher').getAttribute('aria-current')!=='page')throw Error('Hash did not select Launcher');if(document.querySelectorAll('.launcher-settings [role=switch]').length!==6)throw Error('Missing launcher controls');
nav('Settings').click();await until(()=>!document.querySelector('.launcher-settings'));nav('Launcher').click();await until(()=>!!document.querySelector('.launcher-settings'));
const switcher=document.querySelector<HTMLInputElement>('.launcher-settings [role=switch]')!;switcher.click();await until(()=>launcher.primaryTrigger);await until(()=>!switcher.disabled);
const categories=document.querySelectorAll<HTMLInputElement>('.launcher-settings [role=switch]');categories[3].click();await until(()=>!launcher.allCategories.includes('steam'));await until(()=>!categories[3].disabled);
await until(()=>!!document.querySelector('.launcher-settings'));if(nav('Launcher').getAttribute('aria-current')!=='page')throw Error('Hash did not select Launcher');if(document.querySelectorAll('.launcher-settings [role=switch]').length!==7)throw Error('Missing launcher controls');
nav('General').click();await until(()=>!document.querySelector('.launcher-settings'));nav('Launcher').click();await until(()=>!!document.querySelector('.launcher-settings'));
const priority=document.querySelector<HTMLInputElement>('.launcher-settings [role=switch][aria-label="Prioritize launcher resources"]')!;priority.click();await until(()=>launcher.prioritizeUi===false);await until(()=>!priority.disabled);
const switcher=document.querySelector<HTMLInputElement>('.launcher-settings [role=switch][aria-label="Open launcher with the primary trigger"]')!;switcher.click();await until(()=>launcher.primaryTrigger);await until(()=>!switcher.disabled);
const categories=document.querySelectorAll<HTMLInputElement>('.launcher-settings [role=switch]');categories[4].click();await until(()=>!launcher.allCategories.includes('steam'));await until(()=>!categories[4].disabled);
await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_MANAGER_EN');const content=document.querySelector<HTMLElement>('.content')!;content.scrollTop=content.scrollHeight;await wait(300);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_CATEGORIES_EN');content.scrollTop=0;language='zh-CN';configureLanguage(language,[]);location.hash='#launcher-settings';window.dispatchEvent(new Event('hashchange'));await until(()=>document.documentElement.lang==='zh-CN'&&!!nav('启动台'));await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_MANAGER_ZH');content.scrollTop=content.scrollHeight;await wait(300);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_CATEGORIES_ZH');
const first=document.querySelector('.launcher-settings .switch-row')!.getBoundingClientRect();const second=document.querySelectorAll('.launcher-settings')[1].querySelector('.switch-row')!.getBoundingClientRect();if(Math.abs(first.width-second.width)>1)throw Error('Settings widths differ');console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
+21
View File
@@ -0,0 +1,21 @@
#include "launcher_priority.h"
#include <cassert>
#include <thread>
int main(){
assert(LauncherPriority::target(0)==-5);
assert(LauncherPriority::target(19)==-5);
assert(LauncherPriority::target(-10)==-10);
const int original=getpriority(PRIO_PROCESS,0);
{
LauncherPriority priority;priority.update(true);
// Without RLIMIT_NICE the boost fails safely; with it, verify the bounded boost.
int boosted=getpriority(PRIO_PROCESS,0);
assert(boosted==original||boosted==LauncherPriority::target(original));
std::thread newborn([]{std::this_thread::sleep_for(std::chrono::milliseconds(150));});
priority.update(false);
assert(getpriority(PRIO_PROCESS,0)==original);
newborn.join();
}
assert(getpriority(PRIO_PROCESS,0)==original);
}
+2
View File
@@ -123,6 +123,8 @@ class InstallRetry(unittest.TestCase):
self.assertTrue(state.is_symlink())
self.assertTrue((units / 'framely.service').is_file())
self.assertTrue((units / 'framely-session.service').is_file())
self.assertIn('LimitNICE=25', (units / 'framely-session.service').read_text())
self.assertNotIn('LimitNICE=', (units / 'framely.service').read_text())
self.assertFalse((base / 'useradd-args').exists())
self.assertEqual((state / 'previous-release').read_text(), 'releases/older\n')
self.assertEqual((state / 'state.json').read_text(), '{"settings":"preserved"}')
+2 -2
View File
@@ -3,7 +3,7 @@ set -euo pipefail
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
cd "$base"
mkdir -p target/tests
for test in menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing; do
"${CXX:-g++}" -std=c++17 -O2 -Inative -Inative/vendor/openvr "tests/$test.cpp" -o "target/tests/$test"
for test in menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority; do
"${CXX:-g++}" -std=c++17 -O2 -pthread -Inative -Inative/vendor/openvr "tests/$test.cpp" -o "target/tests/$test"
"target/tests/$test"
done
+3 -1
View File
@@ -910,6 +910,8 @@
"无法复制,请使用浏览器的复制功能。": "Unable to copy. Use the browser copy command.",
"保存手柄设置": "Save gamepad settings",
"Frame 手柄输入": "Frame gamepad input",
"默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。": "Off by default. Use Frame controllers as a standard gamepad. Enabling takes effect at the next APK launch and may restart its container. Disabling stops input immediately."
"默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。": "Off by default. Use Frame controllers as a standard gamepad. Enabling takes effect at the next APK launch and may restart its container. Disabling stops input immediately.",
"启动台资源优先": "Prioritize launcher resources",
"启动台显示时提高界面 CPU 调度优先级,关闭后恢复。": "Raise UI CPU scheduling priority while the launcher is visible; restore it when closed."
}
}
+3 -1
View File
@@ -910,6 +910,8 @@
"无法复制,请使用浏览器的复制功能。": "无法复制,请使用浏览器的复制功能。",
"保存手柄设置": "保存手柄设置",
"Frame 手柄输入": "Frame 手柄输入",
"默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。": "默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。"
"默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。": "默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。",
"启动台资源优先": "启动台资源优先",
"启动台显示时提高界面 CPU 调度优先级,关闭后恢复。": "启动台显示时提高界面 CPU 调度优先级,关闭后恢复。"
}
}
+4 -4
View File
@@ -10,8 +10,8 @@ import './launcher.css';
import {installLauncherGaze} from './launcher-gaze';
import {LauncherCalibration} from './launcher-calibration';
import {matchesSearch,type SearchIndex} from './launcher-search';
export type LauncherConfig={primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
const defaults:LauncherConfig={primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
export type LauncherConfig={prioritizeUi?:boolean;primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
const defaults:LauncherConfig={prioritizeUi:true,primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
type SteamApp={id:number;name:string;icon?:string;iconFit?:string;kind?:string};
function launcherCells(total:number){
const counts=[Math.min(Math.max(total-5,0),4),Math.min(total,5),Math.max(total-9,0)];
@@ -29,7 +29,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
const[busy,setBusy]=useState(false),[error,setError]=useState(''),[seconds,setSeconds]=useState(config.menuTimeoutSeconds);
useEffect(()=>setSeconds(config.menuTimeoutSeconds),[config.menuTimeoutSeconds]);
async function save(p:Partial<LauncherConfig>){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('主扳机打开启动台')} description={t('开启后,上扳机打开快捷面板。')} checked={config.primaryTrigger} disabled={busy} onChange={primaryTrigger=>void save({primaryTrigger})}/><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><SwitchRow label={t('主扳机打开启动台')} description={t('开启后,上扳机打开快捷面板。')} checked={config.primaryTrigger} disabled={busy} onChange={primaryTrigger=>void save({primaryTrigger})}/><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
}
type LocalApp={id:string;name:string;kind:'desktop'|'lepton';icon?:string;launchUnavailable?:string|null};
type LaunchItem={key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
@@ -106,7 +106,7 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
}else if(swipe.current){const gesture=swipe.current,dx=e.clientX-gesture.x,dy=e.clientY-gesture.y;if(gesture.active||(Math.abs(dx)>36&&Math.abs(dx)>Math.abs(dy)*1.25)){gesture.active=true;gesture.dx=dx;cancelHold();setMenu(null);long.current=true;pageAnimation.current?.cancel();const width=grid.current?.clientWidth??1300,direction=dx<0?1:-1,next=state.current.current+direction,valid=next>=0&&next<state.current.pages;setPanView({offset:valid?Math.max(-width,Math.min(width,dx)):dx*.22,direction,width,settling:false});}else if(holdTarget.current&&Math.hypot(dx,dy)>36){cancelHold();long.current=true;}}};
const up=(e:PointerEvent)=>{if(swipe.current){const gesture=swipe.current;swipe.current=null;settleSwipe(gesture);}cancelHold();stopEdge();const d=drag.current;drag.current=null;setDragging(null);setGhost(null);if(d){long.current=true;if(d.moved)void run('order',async()=>{try{await launcherRequest('launcher.order.save',{order:d.order});}catch(e){setManual(d.before);throw e;}});}};
const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up);window.addEventListener('pointercancel',cancel);return()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up);window.removeEventListener('pointercancel',cancel);};},[]);
function launch(a:LaunchItem){if(a.launchUnavailable)return Promise.reject(new Error(t(a.launchUnavailable)));return a.kind==='framely'?launcherRequest('host.manager.open'):a.kind==='plugin'?launcherRequest('plugin.launch',{plugin:a.id,source:'launcher'}):a.key.startsWith('steam:')?launcherRequest('steam.launch',{app:+a.id}):launcherRequest('desktop.launch',{app:a.key});}
function launch(a:LaunchItem){const launcherSession=(window as Window & {framelyLauncherSession?:number}).framelyLauncherSession;if(a.launchUnavailable)return Promise.reject(new Error(t(a.launchUnavailable)));return a.kind==='framely'?launcherRequest('host.manager.open'):a.kind==='plugin'?launcherRequest('plugin.launch',{plugin:a.id,source:'launcher'}):a.key.startsWith('steam:')?launcherRequest('steam.launch',{app:+a.id,launcherSession}):launcherRequest('desktop.launch',{app:a.key,launcherSession});}
async function custom(id:string,action:string){const key=`plugin:${id}`;await run(key,async active=>{const v=await launcherRequest('plugin.launcher.action',{plugin:id,action});if(!active())return;if(v.frontend){await new Promise<void>((resolve,reject)=>{const ticket=++actionSequence.current;const timer=setTimeout(()=>done(key,'Launcher action timed out',ticket),90000);pending.current.set(key,{resolve,reject,timer,ticket});setFrontends(items=>({...items,[key]:{plugin:id,action,context:v.context,ticket}}));});}if(active()&&v.opened)setMenu(current=>current===key?null:current);});}
const pending=useRef(new Map<string,{resolve:()=>void;reject:(e:Error)=>void;timer:ReturnType<typeof setTimeout>;ticket:number}>());
function done(key:string,error?:string,ticket?:number){const p=pending.current.get(key);if(!p||(ticket!==undefined&&p.ticket!==ticket))return;clearTimeout(p.timer);pending.current.delete(key);if(mounted.current)setFrontends(items=>{const next={...items};delete next[key];return next;});if(error)p.reject(new Error(error));else p.resolve();}