diff --git a/docs/user-guide/basic-usage.md b/docs/user-guide/basic-usage.md index 7973d9d..e4f30dd 100644 --- a/docs/user-guide/basic-usage.md +++ b/docs/user-guide/basic-usage.md @@ -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. diff --git a/docs/zh-CN/user-guide/basic-usage.md b/docs/zh-CN/user-guide/basic-usage.md index 0ede0ea..93ef661 100644 --- a/docs/zh-CN/user-guide/basic-usage.md +++ b/docs/zh-CN/user-guide/basic-usage.md @@ -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 窗口,后续仍进行启动存活检查。运行中的容器即使已隐藏应用窗口也会复用,不再仅因之前游戏退出就重启。 diff --git a/native/host.cpp b/native/host.cpp index e89f406..d8a5d90 100644 --- a/native/host.cpp +++ b/native/host.cpp @@ -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 #include #include +#include #include 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 launcher_prioritize_ui{true}; +static std::atomic launcher_priority_active{false}; +static std::string launcher_previous_dashboard; +static std::set 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 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 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 commands;{std::lock_guard 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());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());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 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 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()+"."+c.at("window").get();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_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_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "< commands;{std::lock_guard 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());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());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 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 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()+"."+c.at("window").get();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_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_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "<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"<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< 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: "< 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: "<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()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()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; } diff --git a/native/launcher_priority.h b/native/launcher_priority.h new file mode 100644 index 0000000..3ea0811 --- /dev/null +++ b/native/launcher_priority.h @@ -0,0 +1,67 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// 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 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 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);} +}; diff --git a/packaging/install.sh b/packaging/install.sh index 55ae67c..34ac5a4 100755 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -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 diff --git a/src/model.rs b/src/model.rs index ca5393d..633a613 100644 --- a/src/model.rs +++ b/src/model.rs @@ -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, diff --git a/src/session.rs b/src/session.rs index fbf5a8c..9b92c18 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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)) } diff --git a/src/tests.rs b/src/tests.rs index c46e6ee..9a53d4f 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -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::(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(); diff --git a/tests/launcher_fixture.tsx b/tests/launcher_fixture.tsx index 983656a..949becd 100644 --- a/tests/launcher_fixture.tsx +++ b/tests/launcher_fixture.tsx @@ -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={ @@ -8,7 +9,7 @@ const plugins:Record={ '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(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)&¶ms.launcherSession!==42)throw Error("Launch did not carry the current launcher session");if(method===stalledMethod)return new Promise(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('.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('[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('[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();wait(150).then(()=>console.log('FRAMELY_VIEW_READY')); diff --git a/tests/launcher_manager_fixture.tsx b/tests/launcher_manager_fixture.tsx index 2bfe9bb..95e66e8 100644 --- a/tests/launcher_manager_fixture.tsx +++ b/tests/launcher_manager_fixture.tsx @@ -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('.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('.launcher-settings [role=switch]')!;switcher.click();await until(()=>launcher.primaryTrigger);await until(()=>!switcher.disabled); - const categories=document.querySelectorAll('.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('.launcher-settings [role=switch][aria-label="Prioritize launcher resources"]')!;priority.click();await until(()=>launcher.prioritizeUi===false);await until(()=>!priority.disabled); + const switcher=document.querySelector('.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('.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('.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);}}; diff --git a/tests/launcher_priority.cpp b/tests/launcher_priority.cpp new file mode 100644 index 0000000..90f2427 --- /dev/null +++ b/tests/launcher_priority.cpp @@ -0,0 +1,21 @@ +#include "launcher_priority.h" +#include +#include + +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); +} diff --git a/tests/test_install_retry.py b/tests/test_install_retry.py index 3f07287..c411d22 100644 --- a/tests/test_install_retry.py +++ b/tests/test_install_retry.py @@ -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"}') diff --git a/tools/test-native.sh b/tools/test-native.sh index 4d79f4f..75bc6bc 100755 --- a/tools/test-native.sh +++ b/tools/test-native.sh @@ -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 diff --git a/ui/locales/en-US.json b/ui/locales/en-US.json index 12e27e9..f272a63 100644 --- a/ui/locales/en-US.json +++ b/ui/locales/en-US.json @@ -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." } } diff --git a/ui/locales/zh-CN.json b/ui/locales/zh-CN.json index f686b25..41723e6 100644 --- a/ui/locales/zh-CN.json +++ b/ui/locales/zh-CN.json @@ -910,6 +910,8 @@ "无法复制,请使用浏览器的复制功能。": "无法复制,请使用浏览器的复制功能。", "保存手柄设置": "保存手柄设置", "Frame 手柄输入": "Frame 手柄输入", - "默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。": "默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。" + "默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。": "默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。", + "启动台资源优先": "启动台资源优先", + "启动台显示时提高界面 CPU 调度优先级,关闭后恢复。": "启动台显示时提高界面 CPU 调度优先级,关闭后恢复。" } } diff --git a/ui/src/launcher.tsx b/ui/src/launcher.tsx index 06611df..391c331 100644 --- a/ui/src/launcher.tsx +++ b/ui/src/launcher.tsx @@ -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){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}} - return <>

{t('启动方式与菜单')}

void save({primaryTrigger})}/>void save({menuAutoClose})}/>{error&&

{error}

}

{t('全部包含的分类')}

{t('只影响全部分类,其他分类和收藏仍可访问。')}

{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}

{t('眼追校准')}

{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}

; + return <>

{t('启动方式与菜单')}

void save({prioritizeUi})}/>void save({primaryTrigger})}/>void save({menuAutoClose})}/>{error&&

{error}

}

{t('全部包含的分类')}

{t('只影响全部分类,其他分类和收藏仍可访问。')}

{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}

{t('眼追校准')}

{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}

; } 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&&next36){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((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 Mapvoid;reject:(e:Error)=>void;timer:ReturnType;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();}