Compare commits

...
32 changed files with 409 additions and 67 deletions

No files matched your search

Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.5.0-preview.2"
version = "0.5.1-preview.3"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.5.0-preview.2"
version = "0.5.1-preview.3"
edition = "2021"
license = "AGPL-3.0-only"
+1 -1
View File
@@ -12,7 +12,7 @@ Plugin contains installed plugins, the plugin library, sources and notification
## Interaction feedback
In **Settings → General → Interaction feedback**, Haptic feedback and Sound feedback are enabled by default and saved across restarts. Haptic feedback controls laser hover and launcher gaze-focus vibration across Framely and plugin pages; it does not change APK game rumble. Sound feedback uses Steam’s installed navigation and activation sounds for focus and clicks. It requires the local Steam sound files and audio runtime; no sound files are bundled or downloaded.
In **Settings → General → Interaction feedback**, Haptic feedback is enabled by default; Sound feedback is disabled by default. Both preferences are saved across restarts. Haptic feedback controls laser hover and launcher gaze-focus vibration across Framely and plugin pages; it does not change APK game rumble. Sound feedback uses Steam’s installed keyboard sound at reduced volume for focus and clicks. It requires the local Steam sound files and audio runtime; no sound files are bundled or downloaded.
## Terminal
+1 -1
View File
@@ -12,7 +12,7 @@
## 交互反馈
在 **设置 → 通用 → 交互反馈** 中,振动反馈和提示音反馈默认开启,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机的导航和点击音效,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
在 **设置 → 通用 → 交互反馈** 中,振动反馈默认开启,提示音反馈默认关闭,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机键盘音效,以较低音量反馈聚焦和点击,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
## 终端
+2 -1
View File
@@ -21,7 +21,7 @@ static void stop(int){stopped=1;}
#include "gamepad_feedback.h"
static const int axes[]={ABS_X,ABS_Y,ABS_RX,ABS_RY,ABS_Z,ABS_RZ,ABS_HAT0X,ABS_HAT0Y};
static bool event(int fd,int type,int code,int value){input_event e{};e.type=type;e.code=code;e.value=value;return write(fd,&e,sizeof e)==sizeof e;}
static bool send(int fd,const int* state){bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;}
static bool send(int fd,const int* state){static GamepadTriggers triggers;bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<2;k++)ok=event(fd,EV_KEY,trigger_keys[k],triggers.update(k,state[14+k]))&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;}
int main(int argc,char**argv){
if((argc!=2&&argc!=4)||geteuid()==0){fprintf(stderr,"Run gamepad bridge as the Steam session user with an action manifest.\n");return 1;}
// A session request runs on a short-lived worker thread. PR_SET_PDEATHSIG
@@ -58,6 +58,7 @@ int main(int argc,char**argv){
int fd=open("/dev/uinput",O_RDWR|O_NONBLOCK|O_CLOEXEC);if(fd<0){perror("Gamepad /dev/uinput");shutdown();return 1;}
bool ok=ioctl(fd,UI_SET_EVBIT,EV_KEY)==0&&ioctl(fd,UI_SET_EVBIT,EV_ABS)==0&&ioctl(fd,UI_SET_EVBIT,EV_FF)==0&&ioctl(fd,UI_SET_FFBIT,FF_RUMBLE)==0;
for(auto key:keys)ok=ioctl(fd,UI_SET_KEYBIT,key)==0&&ok;
for(auto key:trigger_keys)ok=ioctl(fd,UI_SET_KEYBIT,key)==0&&ok;
for(int k=0;k<8;k++){ok=ioctl(fd,UI_SET_ABSBIT,axes[k])==0&&ok;uinput_abs_setup a{};a.code=axes[k];a.absinfo.minimum=k<4?-32768:(k<6?0:-1);a.absinfo.maximum=k<4?32767:(k<6?255:1);a.absinfo.flat=k<4?1024:0;ok=ioctl(fd,UI_ABS_SETUP,&a)==0&&ok;}
uinput_setup device{};device.id={BUS_VIRTUAL,framely_pad_vendor,framely_pad_product,0x0001};device.ff_effects_max=GamepadFeedback::capacity;strcpy(device.name,"Framely Android gamepad");
ok=ioctl(fd,UI_DEV_SETUP,&device)==0&&ioctl(fd,UI_DEV_CREATE)==0&&ok;
+8
View File
@@ -4,3 +4,11 @@
// Xbox/Android maps X to Linux 307 and Y to 308. The positional
// aliases WEST/NORTH do not match these letter names.
inline constexpr int keys[]={BTN_A,BTN_B,BTN_X,BTN_Y,BTN_TL,BTN_TR,BTN_SELECT,BTN_START,BTN_THUMBL,BTN_THUMBR};
inline constexpr int trigger_keys[]={BTN_TL2,BTN_TR2};
// Preserve analog travel while exposing Android's digital L2/R2 events.
// Press at 30/255 and release at 20/255 to avoid noisy edges.
struct GamepadTriggers {
bool down[2]{};
bool update(int side,int value){down[side]=down[side]?value>20:value>=30;return down[side];}
};
+6 -6
View File
@@ -60,7 +60,7 @@ static GLXContext glcontext=nullptr;
static GLXPbuffer glsurface=0;
static std::atomic<unsigned> notification_badge_count{0};
static bool launcher_open=false;
static bool ui_haptics=true,ui_sounds=true;
static bool ui_haptics=true,ui_sounds=false;
static SteamUiSounds ui_audio;
static LauncherFade launcher_fade;
static std::chrono::steady_clock::time_point launcher_close_at{};
@@ -123,7 +123,7 @@ template<class T> struct Handler {
T* acquire(){api.base.add_ref(&api.base);return &api;}
};
struct View {
std::string key,plugin,title;int width=600,height=840,render_scale=1;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false;
std::string key,plugin,title;int width=600,height=840;float render_scale=1.f;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false;
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_at{},activation_at{};
cef_mouse_event_t pointer{};bool pointer_valid=false,stick_scrolling=false;vr::TrackedDeviceIndex_t pointer_device=vr::k_unTrackedDeviceIndexInvalid;std::chrono::steady_clock::time_point pointer_at{};
cef_file_dialog_callback_t* file_dialog=nullptr;std::string file_dialog_id;bool file_multiple=false,file_directory=false;
@@ -281,9 +281,9 @@ static void anchor_notification(View& v){
}
static View& create_view(const std::string& key,const std::string& title,const std::string& url,bool dock,int width,int height,const std::string& plugin="",float physical_width=0){
if(views.count(key)){auto& v=*views.at(key);if(dock&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="notifications"&&key!="launcher")show(v,true);return v;}
// Use 3x for launcher artwork and 2x for scrolling panels. CEF readback,
// Use 1.5x for launcher artwork and 2x for scrolling panels. CEF readback,
// texture upload and launcher mipmap generation scale with the pixel count.
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dock;v->width=width;v->height=height;v->render_scale=key=="launcher"?3:(key=="framely.manager"||key=="menu"||key=="notifications")?2:1;
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dock;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications")?2:1;
auto error=dock?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(v->overlay,0);
@@ -547,7 +547,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=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",true);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="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 nLine 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=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="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 Line 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;}}
@@ -588,7 +588,7 @@ int main(int argc,char** argv){
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);DockIconTexture icon_texture;icon_texture.init();if(!icon_texture.submit(button,pixels)){icon_texture.destroy();overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayInputMethod(button,vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{dock_canvas_width,dock_canvas_height}};overlays->SetOverlayMouseScale(button,&scale);vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;mask.m_Primitive.m_Rectangle={dock_icon_left,0,128,128};if(overlays->SetOverlayIntersectionMask(button,&mask,1)){std::cerr<<"Dock input mask unavailable\n";overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayFlag(button,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(button,0);overlays->SetOverlayFlag(button,vr::VROverlayFlags_HideLaserIntersection,false);
try{create_view("menu","Framely",initial,false,600,840);}catch(const std::exception& e){std::cerr<<e.what()<<"\n";running=0;}
const char* steam_root=std::getenv("FRAMELY_STEAM_ROOT");if(!ui_audio.prepare(steam_root?steam_root:""))std::cerr<<"Steam UI sounds unavailable\n";
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",true)}});last_feedback=feedback;}auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",false)}});last_feedback=feedback;}auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
std::thread priority_worker([]{LauncherPriority priority;while(running){priority.update(launcher_priority_active.load());std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;int icon_progress=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();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);
@@ -5,6 +5,8 @@ key 307 BUTTON_X
key 308 BUTTON_Y
key 310 BUTTON_L1
key 311 BUTTON_R1
key 312 BUTTON_L2
key 313 BUTTON_R2
key 314 BUTTON_SELECT
key 315 BUTTON_START
key 317 BUTTON_THUMBL
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once
// CEF view/input coordinates stay in DIP; only the paint buffer uses this scale.
inline int render_screen_info(cef_screen_info_t* info,int width,int height,int scale){
inline int render_screen_info(cef_screen_info_t* info,int width,int height,float scale){
info->size=sizeof(*info);info->device_scale_factor=float(scale);info->depth=32;info->depth_per_component=8;info->is_monochrome=0;
info->rect={0,0,width,height};info->available_rect=info->rect;return 1;
}
+4 -3
View File
@@ -20,6 +20,7 @@ class SteamUiSounds {
bool (*resume)(void*)=nullptr;
bool (*put)(void*,const void*,int)=nullptr;
bool (*clear)(void*)=nullptr;
bool (*gain)(void*,float)=nullptr;
void (*destroy)(void*)=nullptr;
void (*release)(void*)=nullptr;
template<class T> bool bind(T& fn,const char* name){fn=reinterpret_cast<T>(dlsym(library,name));return fn!=nullptr;}
@@ -36,13 +37,13 @@ class SteamUiSounds {
if(root.empty())return false;
for(const auto* name:{"steamrtarm64/libSDL3.so.0","steamrt64/libSDL3.so.0"}){library=dlopen((root+"/"+name).c_str(),RTLD_NOW|RTLD_LOCAL);if(library)break;}
if(!library)return false;
if(!(bind(init,"SDL_InitSubSystem")&&bind(quit,"SDL_QuitSubSystem")&&bind(load,"SDL_LoadWAV")&&bind(open,"SDL_OpenAudioDeviceStream")&&bind(resume,"SDL_ResumeAudioStreamDevice")&&bind(put,"SDL_PutAudioStreamData")&&bind(clear,"SDL_ClearAudioStream")&&bind(destroy,"SDL_DestroyAudioStream")&&bind(release,"SDL_free"))){shutdown();return false;}
if(!(bind(init,"SDL_InitSubSystem")&&bind(quit,"SDL_QuitSubSystem")&&bind(load,"SDL_LoadWAV")&&bind(open,"SDL_OpenAudioDeviceStream")&&bind(resume,"SDL_ResumeAudioStreamDevice")&&bind(put,"SDL_PutAudioStreamData")&&bind(clear,"SDL_ClearAudioStream")&&bind(gain,"SDL_SetAudioStreamGain")&&bind(destroy,"SDL_DestroyAudioStream")&&bind(release,"SDL_free"))){shutdown();return false;}
if(!init(0x10)){shutdown();return false;}initialized=true;
const char* names[]={"deck_ui_navigation.wav","deck_ui_default_activation.wav"};
const char* names[]={"deck_ui_typing.wav","deck_ui_typing.wav"};
for(int i=0;i<2;++i){Spec spec{};auto& clip=clips[i];
if(!load((root+"/steamui/sounds/"+names[i]).c_str(),&spec,&clip.data,&clip.size)||!clip.size||clip.size>1024*1024){shutdown();return false;}
clip.stream=open(0xffffffffu,&spec,nullptr,nullptr);
if(!clip.stream||!resume(clip.stream)){shutdown();return false;}
if(!clip.stream||!gain(clip.stream,i==0?.30f:.45f)||!resume(clip.stream)){shutdown();return false;}
}
return true;
}
+168 -3
View File
@@ -545,6 +545,48 @@ fn restriction(c: &Container, package: &str) -> Option<(bool, bool, Vec<String>)
}
None
}
// Legacy installs may have reused a package-derived context for another app.
// Allocate around that occupied context instead of touching its files or records.
fn automatic_install_context(
home: &Path,
db: &Database,
cs: &[Container],
package: &str,
) -> Result<String> {
let base = format!("framely-{}", hash(package));
for suffix in 0..1000 {
let id = if suffix == 0 {
base.clone()
} else {
format!("{base}-{}", suffix + 1)
};
let records: Vec<_> = db.records.values().filter(|r| r.context == id).collect();
if let Some(c) = cs.iter().find(|c| c.id == id) {
let packages = if c.baked.exists() {
installed(home, c)?
} else {
Vec::new()
};
if packages.iter().any(|(m, _)| m.package == package) {
return Ok(id); // Updating an existing app also remains valid in legacy shared containers.
}
if packages.iter().any(|(m, _)| m.package != package)
|| records.iter().any(|r| r.metadata.package != package)
{
continue;
}
return Ok(id);
}
if !records.is_empty()
|| fs::symlink_metadata(home.join(".local/share/lepton/contexts").join(&id)).is_ok()
{
continue; // Preserve retained records and unreadable/dangling paths.
}
return Ok(id);
}
anyhow::bail!("No free independent APK container name is available")
}
fn installed(home: &Path, c: &Container) -> Result<Vec<(Metadata, PathBuf)>> {
let p = c.baked.join("data_overlay/system/packages.xml");
let bytes = fs::read(&p)?;
@@ -1305,7 +1347,21 @@ fi
user=$(am get-started-user-state 0)
echo FRAMELY_USER=$user
storage=0
if [ -d /storage/emulated/0 ] && sm list-volumes all | grep -Eq '^emulated(;0)? mounted( |$)'; then storage=1; fi
if [ -d /storage/emulated/0 ]; then
volumes=$(sm list-volumes all 2>/dev/null)
if printf '%s\n' "$volumes" | grep -Eq '^emulated(;0)? mounted( |$)'; then
storage=1
elif ! printf '%s\n' "$volumes" | grep -Eq '^emulated(;0)? '; then
# Some Lepton images return success but no emulated volume from sm.
# Inspect only user 0's emulated volume, never the mounted /data volume.
volume_state=$(dumpsys mount 2>/dev/null | awk '
/^[[:space:]]*VolumeInfo\{/ { target = ($0 ~ /^[[:space:]]*VolumeInfo\{emulated(;0)?\}:/) }
target && /state=/ { print; exit }
')
echo "FRAMELY_STORAGE_VOLUME=$volume_state"
if printf '%s\n' "$volume_state" | grep -Eq '(^|[[:space:]])state=MOUNTED([[:space:]]|$)'; then storage=1; fi
fi
fi
echo FRAMELY_STORAGE=$storage
[ "$boot" = 1 ] && [ "$pm_ready" = 1 ] && [ "$user" = RUNNING_UNLOCKED ] && [ "$storage" = 1 ] && echo FRAMELY_ANDROID_READY
exit 0
@@ -2577,7 +2633,7 @@ fn operate_internal(
let requested = p["context"].as_str().unwrap_or("");
let cs = containers(home, &db);
let context = if requested.is_empty() {
format!("framely-{}", hash(&metadata.package))
automatic_install_context(home, &db, &cs, &metadata.package)?
} else {
requested.into()
};
@@ -5049,6 +5105,71 @@ setup_props
assert!(f.save_file().exists());
assert!(!load(&f.home).unwrap().records["test/com.example.app"].removed);
}
#[test]
fn automatic_install_context_preserves_occupied_legacy_contexts() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let package = "com.example.new";
let base = format!("framely-{}", hash(package));
let baked = home
.join(".local/share/lepton/contexts")
.join(&base)
.join("baked");
fs::create_dir_all(baked.join("data_overlay/system")).unwrap();
let xml = b"<packages><package name=\"com.example.old\" codePath=\"/data/app/old\" version=\"1\"/></packages>";
fs::write(baked.join("data_overlay/system/packages.xml"), xml).unwrap();
let c = Container {
id: base.clone(),
name: base.clone(),
baked: baked.clone(),
running: false,
managed: true,
steam: false,
};
let mut db = Database::default();
assert_eq!(
automatic_install_context(home, &db, &[c.clone()], package).unwrap(),
format!("{base}-2")
);
assert_eq!(
fs::read(baked.join("data_overlay/system/packages.xml")).unwrap(),
xml
);
// An unreadable context must also be skipped, rather than overwritten.
fs::create_dir_all(
home.join(".local/share/lepton/contexts")
.join(format!("{base}-2")),
)
.unwrap();
assert_eq!(
automatic_install_context(home, &db, &[c.clone()], package).unwrap(),
format!("{base}-3")
);
// A retained foreign app reserves a name even if its directory is gone.
db.records.insert(
"retained".into(),
Record {
context: format!("{base}-3"),
metadata: Metadata {
package: "com.example.retained".into(),
..Default::default()
},
removed: true,
..Default::default()
},
);
assert_eq!(
automatic_install_context(home, &db, &[c.clone()], package).unwrap(),
format!("{base}-4")
);
// Same-package updates still reuse the existing legacy shared container.
fs::write(baked.join("data_overlay/system/packages.xml"), format!("<packages><package name=\"{package}\" codePath=\"/data/app/new\" version=\"1\"/><package name=\"com.example.old\" codePath=\"/data/app/old\" version=\"1\"/></packages>")).unwrap();
assert_eq!(
automatic_install_context(home, &db, &[c], package).unwrap(),
base
);
}
#[test]
fn shared_mount_loss_preserves_launcher_identity_until_confirmed_uninstall() {
let f = Fixture::new();
@@ -5481,7 +5602,7 @@ esac
fn android_ready_probe_uses_operation_specific_checks_and_accepts_volume_output() {
let dir = tempfile::tempdir().unwrap();
let run = |mode: &str, user: &str, volume: &str| {
let mocks = format!("getprop() {{ echo 1; }}; pm() {{ echo package:/system/framework/framework-res.apk; }}; am() {{ echo {user}; }}; sm() {{ echo '{volume}'; }};\n");
let mocks = format!("getprop() {{ echo 1; }}; pm() {{ echo package:/system/framework/framework-res.apk; }}; am() {{ echo {user}; }}; sm() {{ echo '{volume}'; }}; dumpsys() {{ :; }};\n");
let probe = ANDROID_READY_PROBE
.replace("/storage/emulated/0", &dir.path().display().to_string());
let mut command = Command::new("/bin/sh");
@@ -5504,6 +5625,50 @@ esac
.contains("FRAMELY_ANDROID_READY"));
}
#[test]
fn android_ready_probe_handles_lepton_empty_sm_without_accepting_other_volumes() {
let dir = tempfile::tempdir().unwrap();
let mounted = "Volumes:\n VolumeInfo{private}:\n state=MOUNTED\n VolumeInfo{emulated;0}:\n mountUserId=0 state=MOUNTED \n";
let run = |sm: &str, dump: &str, exists: bool| {
let mocks = format!("getprop() {{ echo 1; }}; pm() {{ echo package:/android; }}; am() {{ echo RUNNING_UNLOCKED; }}; sm() {{ printf '%s\n' '{sm}'; }}; dumpsys() {{ printf '%s\n' '{dump}'; }};\n");
let path = if exists {
dir.path().to_path_buf()
} else {
dir.path().join("missing")
};
let probe =
ANDROID_READY_PROBE.replace("/storage/emulated/0", &path.display().to_string());
let mut command = Command::new("/bin/sh");
command.args(["-c", &(mocks + &probe), "probe", "full"]);
output(command, Duration::from_secs(2), None)
.unwrap()
.contains("FRAMELY_ANDROID_READY")
};
assert!(run("", mounted, true));
assert!(run("", &mounted.replace("emulated;0", "emulated"), true));
assert!(!run("", mounted, false));
assert!(!run("emulated;0 unmounted null", mounted, true));
assert!(!run(
"",
&mounted.replace(
"mountUserId=0 state=MOUNTED",
"mountUserId=0 state=UNMOUNTED"
),
true
));
assert!(!run(
"",
&mounted.replace("emulated;0", "emulated;10"),
true
));
assert!(!run("", "VolumeInfo{private}:\n state=MOUNTED\n", true));
assert!(!run(
"",
"VolumeInfo{emulated;0}:\n state=CHECKING\nVolumeInfo{private}:\n state=MOUNTED\n",
true
));
assert!(!run("", "", true));
}
#[test]
fn android_ready_errors_identify_the_blocked_stage() {
assert!(
android_readiness_failure("probe timed out", AndroidReadiness::Full)
+32
View File
@@ -0,0 +1,32 @@
async function(p) {
const executable = d => d.strShortcutExe?.replace(/^"|"$/g, '').replace('/./', '/');
const details = id => new Promise((resolve, reject) => {
let sub, settled = false;
const timer = setTimeout(() => { sub?.unregister(); reject(Error('Details timed out')); }, 3000);
sub = SteamClient.Apps.RegisterForAppDetails(id, d => { settled = true; clearTimeout(timer); sub?.unregister(); resolve(d); });
// Steam may invoke a cached callback synchronously.
if (settled) sub?.unregister();
});
let apps, app;
for (let n = 0; n < 10; n++) {
apps = Array.from(appStore.m_mapApps.values());
const matches = apps.filter(a => a.display_name === 'Devkit Game: ' + p.game);
app = matches.length === 1 ? matches[0] : apps.find(a => a.appid === p.previous);
if (app) break;
if (n < 9) await new Promise(r => setTimeout(r, 100));
}
if (app) {
if (app.appid < 2147483648) throw Error('Missing owned shortcut');
const d = await details(app.appid);
if (executable(d) !== p.exe) throw Error('Shortcut target does not match owned APK');
return { id: app.appid };
}
// A previous attempt may have renamed the entry before persisting its ID.
// Recover by exact executable ownership, never by a user-visible app name.
const shortcuts = apps.filter(a => a.appid >= 2147483648);
if (shortcuts.length > 256) throw Error('Too many shortcuts to recover owned entry');
const rows = await Promise.allSettled(shortcuts.map(async a => ({ id: a.appid, details: await details(a.appid) })));
const owned = rows.filter(r => r.status === 'fulfilled' && executable(r.value.details) === p.exe);
if (owned.length !== 1) throw Error('Missing or ambiguous owned shortcut');
return { id: owned[0].value.id };
}
+4 -19
View File
@@ -105,24 +105,8 @@ pub(super) fn configure(
let expression = format!(
r#"(async()=>{{
const p={parameters};
let app;
for(let n=0;n<10;n++){{
const apps=Array.from(appStore.m_mapApps.values());
const matches=apps.filter(a=>a.display_name==='Devkit Game: '+p.game);
app=matches.length===1?matches[0]:apps.find(a=>a.appid===p.previous);
if(app) break;
await new Promise(r=>setTimeout(r,100));
}}
if(!app || app.appid<2147483648) throw Error('Missing owned shortcut');
const id=app.appid;
let sub;
const details=await new Promise((resolve,reject)=>{{
let timer=setTimeout(()=>{{sub?.unregister();reject(Error('Details timed out'))}},3000);
sub=SteamClient.Apps.RegisterForAppDetails(id,d=>{{clearTimeout(timer);sub?.unregister();resolve(d)}});
}});
sub?.unregister();
const exe=details.strShortcutExe?.replace(/^"|"$/g,'').replace('/./','/');
if(exe!==p.exe) throw Error('Shortcut target does not match owned APK');
const owned=await ({lookup})(p);
const id=owned.id;
SteamClient.Apps.SetShortcutLaunchOptions(id,p.launch);
SteamClient.Apps.SetShortcutName(id,p.name);
if(p.icon) SteamClient.Apps.SetShortcutIcon(id,p.icon);
@@ -134,7 +118,8 @@ pub(super) fn configure(
}}
}}
return id;
}})()"#
}})()"#,
lookup = include_str!("steam_shortcut_lookup.js")
);
let result = evaluate(&expression)?;
result
+2 -2
View File
@@ -181,14 +181,14 @@ pub struct LauncherAction {
pub struct FeedbackSettings {
#[serde(default = "yes")]
pub haptics: bool,
#[serde(default = "yes")]
#[serde(default)]
pub sounds: bool,
}
impl Default for FeedbackSettings {
fn default() -> Self {
Self {
haptics: true,
sounds: true,
sounds: false,
}
}
}
+23 -2
View File
@@ -2636,6 +2636,13 @@ mod notification_tests {
#[test]
fn framely_notification_preferences_control_update_reminders_and_survive_reload() {
let root = tempfile::tempdir().unwrap();
// Model an installed release independently of the build's package version.
fs::create_dir(root.path().join("current")).unwrap();
fs::write(
root.path().join("current/VERSION"),
"0.4.2-preview.12-012345abcdef\n",
)
.unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
plugin(&mut core);
send(&mut core, "saved", json!({"inbox":true}));
@@ -2823,6 +2830,13 @@ mod notification_tests {
#[test]
fn automatic_update_notifications_ignore_old_versions_and_deduplicate_after_reload() {
let root = tempfile::tempdir().unwrap();
// Model an installed release independently of the build's package version.
fs::create_dir(root.path().join("current")).unwrap();
fs::write(
root.path().join("current/VERSION"),
"0.4.2-preview.12-012345abcdef\n",
)
.unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
let source = UpdateSource {
url: "https://example.org/framely-release.json".into(),
@@ -2876,6 +2890,13 @@ mod notification_tests {
#[test]
fn automatic_update_schedule_settings_and_stale_completions() {
let root = tempfile::tempdir().unwrap();
// Model an installed release independently of the build's package version.
fs::create_dir(root.path().join("current")).unwrap();
fs::write(
root.path().join("current/VERSION"),
"0.4.2-preview.12-012345abcdef\n",
)
.unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
let source = UpdateSource {
url: "https://example.org/framely-release.json".into(),
@@ -2965,7 +2986,7 @@ mod feedback_tests {
fn feedback_defaults_persist_and_reject_invalid_settings() {
let root = tempfile::tempdir().unwrap();
let mut core = crate::tests::accepted_service(root.path(), 1000).unwrap();
assert!(core.db.feedback.haptics && core.db.feedback.sounds);
assert!(core.db.feedback.haptics && !core.db.feedback.sounds);
core.handle(
"feedback.settings.save",
json!({"haptics":false,"sounds":true}),
@@ -2990,6 +3011,6 @@ mod feedback_tests {
assert!(!reloaded.db.feedback.haptics && !reloaded.db.feedback.sounds);
let old: Database =
serde_json::from_value(json!({"plugins":{},"sources":[],"safeMode":false})).unwrap();
assert!(old.feedback.haptics && old.feedback.sounds);
assert!(old.feedback.haptics && !old.feedback.sounds);
}
}
+1 -1
View File
@@ -2854,7 +2854,7 @@ mod tests {
.unwrap()
.into_json()
.unwrap();
assert_eq!(poll["feedback"], json!({"haptics":true,"sounds":true}));
assert_eq!(poll["feedback"], json!({"haptics":true,"sounds":false}));
assert!(poll.get("gazeCalibration").is_some());
assert!(poll["launcher"].is_object());
assert!(agent
+30 -9
View File
@@ -13,12 +13,18 @@ pub struct App {
pub remote: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_client: Option<String>,
pub remote_targets: Vec<RemoteTarget>,
pub id: u32,
pub name: String,
pub kind: &'static str,
pub icon: Option<String>,
pub icon_fit: &'static str,
}
#[derive(Clone, Debug, Serialize)]
pub struct RemoteTarget {
pub client: String,
pub name: String,
}
#[derive(Debug)]
enum Value {
Text(String),
@@ -213,6 +219,7 @@ pub fn discover(home: &Path) -> Vec<App> {
id,
remote: false,
remote_client: None,
remote_targets: Vec::new(),
name: name.into(),
kind: if text(v, "installdir").is_some_and(|dir| {
fs::read_dir(library.join("steamapps/common").join(dir)).is_ok_and(
@@ -320,14 +327,7 @@ fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) ->
home.join(".steam/steam")
};
for a in remote {
if let Some(existing) = apps.get_mut(&a.id) {
// Keep local metadata and a single stable icon, but expose both launch targets.
if existing.remote_client.is_none() {
existing.remote_client = Some(a.client);
}
continue;
}
apps.entry(a.id).or_insert_with(|| {
let existing = apps.entry(a.id).or_insert_with(|| {
let icon = cached_artwork(&steam, a.id);
App {
id: a.id,
@@ -336,9 +336,23 @@ fn merge_remote(home: &Path, local: Vec<App>, remote: Vec<remote::RemoteApp>) ->
icon_fit: icon.as_ref().map(|v| v.1).unwrap_or("contain"),
icon: icon.map(|v| v.0),
remote: true,
remote_client: Some(a.client),
remote_client: None,
remote_targets: Vec::new(),
}
});
if existing.remote_targets.iter().any(|t| t.client == a.client) {
continue;
}
if existing.remote_client.is_none() {
existing.remote_client = Some(a.client.clone());
}
existing.remote_targets.push(RemoteTarget {
client: a.client,
name: a.device_name,
});
existing
.remote_targets
.sort_by(|a, b| a.name.cmp(&b.name).then(a.client.cmp(&b.client)));
}
apps.into_values().collect()
}
@@ -383,22 +397,26 @@ mod tests {
icon_fit: "contain",
remote: false,
remote_client: None,
remote_targets: Vec::new(),
}];
let rows = vec![
remote::RemoteApp {
id: 42,
name: "Same remote".into(),
client: "12".into(),
device_name: "Gaming PC".into(),
},
remote::RemoteApp {
id: 43,
name: "Remote".into(),
client: "12".into(),
device_name: "Gaming PC".into(),
},
remote::RemoteApp {
id: 43,
name: "Other host".into(),
client: "13".into(),
device_name: "Laptop".into(),
},
];
let apps = merge_remote(home.path(), local, rows);
@@ -406,6 +424,9 @@ mod tests {
assert!(!apps[0].remote);
assert_eq!(apps[0].name, "Local");
assert_eq!(apps[0].remote_client.as_deref(), Some("12"));
assert_eq!(apps[0].remote_targets[0].name, "Gaming PC");
assert_eq!(apps[1].remote_targets.len(), 2);
assert_eq!(apps[1].remote_targets[1].name, "Laptop");
assert!(apps[1].remote);
assert_eq!(apps[1].remote_client.as_deref(), Some("12"));
}
+2 -2
View File
@@ -4,14 +4,14 @@ async function(request) {
if(request.mode==='list')return [];
throw Error('Steam remote library is unavailable');
}
const connected=new Set(remote.devices.filter(d=>d.status==='Connected').map(d=>String(d.clientId)));
const connected=new Map(remote.devices.filter(d=>d.status==='Connected').map(d=>[String(d.clientId),String(d.clientName||'').trim().slice(0,256)]));
const rows=[];
for(const a of store.allApps) {
if(!Number.isInteger(a.appid)||a.appid<=0||a.appid>4294967295||a.visible_in_game_list===false||!(a.app_type&(1|2|8)))continue;
for(const r of a.remote_per_client_data||[]) {
const client=String(r.clientid);
if(r.installed===true&&/^\d{1,20}$/.test(client)&&connected.has(client)&&a.BIsPerClientDataLocal?.(r)===false)
rows.push({id:a.appid,name:String(a.display_name||''),client});
rows.push({id:a.appid,name:String(a.display_name||''),client,deviceName:connected.get(client)});
}
}
if(request.mode==='list')return rows.slice(0,10000);
+2
View File
@@ -11,6 +11,8 @@ pub(super) struct RemoteApp {
pub id: u32,
pub name: String,
pub client: String,
#[serde(default, rename = "deviceName")]
pub device_name: String,
}
fn query(request: serde_json::Value) -> Result<serde_json::Value> {
crate::apk::steam_ui::evaluate(&format!("({})({request})", include_str!("remote.js")))
+2 -2
View File
@@ -42,9 +42,9 @@ int main(int argc,char** argv){
cef_main_args_t args{argc,argv};if(std::strcmp(cef_api_hash(CEF_API_VERSION,0),CEF_API_HASH_PLATFORM))return 2;int child=cef_execute_process(&args,nullptr,nullptr);if(child>=0)return child;if(url.empty()||runtime.empty()||cef.empty())return 2;
cef_settings_t settings{};settings.size=sizeof(settings);settings.windowless_rendering_enabled=1;str(settings.root_cache_path,runtime+"/cache");str(settings.log_file,runtime+"/cef.log");str(settings.resources_dir_path,cef+"/Resources");str(settings.locales_dir_path,cef+"/Resources/locales");
if(!cef_initialize(&args,&settings,nullptr,nullptr))return 3;
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,3);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:600,std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?4320:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?2400:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);// Semi-transparent charcoal panels still need a readable surface and clear rounded corners.
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,1.5f);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:600,std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?2160:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?1200:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);// Semi-transparent charcoal panels still need a readable surface and clear rounded corners.
if(!notification_mode&&w>0&&h>0){auto alpha=[&](int x,int y){return pixels[(y*w+x)*4+3];};if(alpha(0,0)==0&&alpha(w-1,0)==0&&alpha(0,h-1)==0&&alpha(w-1,h-1)==0&&alpha(w/2,40)>200)rounded_paint=true;}};
display.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* m,const cef_string_t*,int){auto s=text(m);std::cout<<s<<"\n";if(s.rfind("FRAMELY_PAGE_WHEEL_",0)==0){int x=0,y=0,dx=0,dy=0;if(std::sscanf(s.c_str(),"FRAMELY_PAGE_WHEEL_%d_%d_%d_%d",&x,&y,&dx,&dy)==4){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;host->send_mouse_move_event(host,&mouse,0);host->send_mouse_wheel_event(host,&mouse,dx,dy);host->base.release(&host->base);}}if(s.rfind("FRAMELY_NATIVE_KEYBOARD_",0)==0){auto* frame=b->get_focused_frame(b);auto command=s.substr(24);if(command=="TEXT")keyboard_edit(frame,"汉😃");else if(command=="BACKSPACE")keyboard_edit(frame,"\b");else if(command=="ENTER")keyboard_edit(frame,"\n");else if(command=="X")keyboard_edit(frame,"x");else if(command=="Y")keyboard_edit(frame,"y");else if(command=="LEFT")keyboard_edit(frame,"\x1b[D");else if(command=="RIGHT")keyboard_edit(frame,"\x1b[C");frame->base.release(&frame->base);}if(s=="FRAMELY_LAUNCHER_CANCEL_PRESS"){auto* pointer_host=b->get_host(b);cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_move_event(pointer_host,&outside,0);outside.modifiers=0;pointer_host->send_mouse_click_event(pointer_host,&outside,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;auto* frame=b->get_main_frame(b);cef_string_t code{},source{};str(code,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->execute_java_script(frame,&code,&source,1);cef_string_utf16_clear(&code);frame->base.release(&frame->base);pointer_host->base.release(&pointer_host->base);}if(s.rfind("FRAMELY_LAUNCHER_DRAG_",0)==0){int x=0,y=0;if(press_pending&&std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_DRAG_%d_%d",&x,&y)==2){press_dx=x-test_pointer.x;press_dy=y-test_pointer.y;}}if(s.rfind("FRAMELY_LAUNCHER_PRESS_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_PRESS_%d_%d_%d_%d_%d",&x,&y,&press_dx,&press_dy,&press_duration)==5){auto* pointer_host=b->get_host(b);test_pointer.begin(x,y,std::chrono::steady_clock::now());press_pending=true;cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;pointer_host->set_focus(pointer_host,1);pointer_host->send_mouse_move_event(pointer_host,&mouse,0);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_click_event(pointer_host,&mouse,MBT_LEFT,0,1);pointer_host->base.release(&pointer_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_HOVER_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_HOVER_%d_%d",&x,&y)==2){auto* hover_host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;hover_host->send_mouse_move_event(hover_host,&mouse,0);hover_host->base.release(&hover_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_REGION_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_REGION_%d_%d_%d_%d",&x,&y,&w,&h)==4){const int scale=launcher_mode?3:4;int ink=0;for(int py=std::max(0,y*scale);py<std::min(pixel_height,(y+h)*scale);py++)for(int px=std::max(0,x*scale);px<std::min(pixel_width,(x+w)*scale);px++)if(pixels[(size_t(py)*pixel_width+px)*4+3]>128)ink++;if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted control "<<x<<","<<y<<"\n";}}}if(s.rfind("FRAMELY_LAUNCHER_INK_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_INK_%d_%d_%d_%d",&x,&y,&w,&h)==4){const int scale=launcher_mode?3:4;int ink=0;for(int py=std::max(0,y*scale);py<std::min(pixel_height,(y+h)*scale);py++)for(int px=std::max(0,x*scale);px<std::min(pixel_width,(x+w)*scale);px++){size_t i=(size_t(py)*pixel_width+px)*4;if(pixels[i+3]>128&&pixels[i]>180&&pixels[i+1]>180&&pixels[i+2]>180)ink++;}if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted label "<<x<<","<<y<<" ink="<<ink<<"\n";}}}
display.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* m,const cef_string_t*,int){auto s=text(m);std::cout<<s<<"\n";if(s.rfind("FRAMELY_PAGE_WHEEL_",0)==0){int x=0,y=0,dx=0,dy=0;if(std::sscanf(s.c_str(),"FRAMELY_PAGE_WHEEL_%d_%d_%d_%d",&x,&y,&dx,&dy)==4){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;host->send_mouse_move_event(host,&mouse,0);host->send_mouse_wheel_event(host,&mouse,dx,dy);host->base.release(&host->base);}}if(s.rfind("FRAMELY_NATIVE_KEYBOARD_",0)==0){auto* frame=b->get_focused_frame(b);auto command=s.substr(24);if(command=="TEXT")keyboard_edit(frame,"汉😃");else if(command=="BACKSPACE")keyboard_edit(frame,"\b");else if(command=="ENTER")keyboard_edit(frame,"\n");else if(command=="X")keyboard_edit(frame,"x");else if(command=="Y")keyboard_edit(frame,"y");else if(command=="LEFT")keyboard_edit(frame,"\x1b[D");else if(command=="RIGHT")keyboard_edit(frame,"\x1b[C");frame->base.release(&frame->base);}if(s=="FRAMELY_LAUNCHER_CANCEL_PRESS"){auto* pointer_host=b->get_host(b);cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_move_event(pointer_host,&outside,0);outside.modifiers=0;pointer_host->send_mouse_click_event(pointer_host,&outside,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;auto* frame=b->get_main_frame(b);cef_string_t code{},source{};str(code,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->execute_java_script(frame,&code,&source,1);cef_string_utf16_clear(&code);frame->base.release(&frame->base);pointer_host->base.release(&pointer_host->base);}if(s.rfind("FRAMELY_LAUNCHER_DRAG_",0)==0){int x=0,y=0;if(press_pending&&std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_DRAG_%d_%d",&x,&y)==2){press_dx=x-test_pointer.x;press_dy=y-test_pointer.y;}}if(s.rfind("FRAMELY_LAUNCHER_PRESS_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_PRESS_%d_%d_%d_%d_%d",&x,&y,&press_dx,&press_dy,&press_duration)==5){auto* pointer_host=b->get_host(b);test_pointer.begin(x,y,std::chrono::steady_clock::now());press_pending=true;cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;pointer_host->set_focus(pointer_host,1);pointer_host->send_mouse_move_event(pointer_host,&mouse,0);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_click_event(pointer_host,&mouse,MBT_LEFT,0,1);pointer_host->base.release(&pointer_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_HOVER_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_HOVER_%d_%d",&x,&y)==2){auto* hover_host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;hover_host->send_mouse_move_event(hover_host,&mouse,0);hover_host->base.release(&hover_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_REGION_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_REGION_%d_%d_%d_%d",&x,&y,&w,&h)==4){const float scale=launcher_mode?1.5f:4.f;int ink=0;for(int py=std::max(0,int(y*scale));py<std::min(pixel_height,int((y+h)*scale));py++)for(int px=std::max(0,int(x*scale));px<std::min(pixel_width,int((x+w)*scale));px++)if(pixels[(size_t(py)*pixel_width+px)*4+3]>128)ink++;if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted control "<<x<<","<<y<<"\n";}}}if(s.rfind("FRAMELY_LAUNCHER_INK_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_INK_%d_%d_%d_%d",&x,&y,&w,&h)==4){const float scale=launcher_mode?1.5f:4.f;int ink=0;for(int py=std::max(0,int(y*scale));py<std::min(pixel_height,int((y+h)*scale));py++)for(int px=std::max(0,int(x*scale));px<std::min(pixel_width,int((x+w)*scale));px++){size_t i=(size_t(py)*pixel_width+px)*4;if(pixels[i+3]>128&&pixels[i]>180&&pixels[i+1]>180&&pixels[i+2]>180)ink++;}if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted label "<<x<<","<<y<<" ink="<<ink<<"\n";}}}
if(s=="FRAMELY_DPI_CLICK_REQUEST"){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=50;mouse.y=45;host->set_focus(host,1);host->send_mouse_move_event(host,&mouse,0);host->send_mouse_click_event(host,&mouse,MBT_LEFT,0,1);host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);host->base.release(&host->base);}if(s=="FRAMELY_DPI_CLICK_PASS")dpi_click=true;if(s.rfind("FRAMELY_PREVIEW_INSTALL_",0)==0)snapshot(s.substr(16));if(s=="FRAMELY_PREVIEW_STORE")snapshot("store");if(s=="FRAMELY_PREVIEW_FAVORITES")snapshot("favorites");if(s=="FRAMELY_PREVIEW_INSTALLED")snapshot("installed");if(s=="FRAMELY_PREVIEW_SETTINGS")snapshot("settings");if(s=="FRAMELY_VIEW_READY")ready=true;if(s=="FRAMELY_BRIDGE_PASS")passed=true;if(s.rfind("FRAMELY_BRIDGE_FAIL",0)==0)failed=true;return 1;};life.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t*){closed=true;};
client.api.get_render_handler=[](cef_client_t*){return render.acquire();};client.api.get_display_handler=[](cef_client_t*){return display.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life.acquire();};
cef_window_info_t win{};win.size=sizeof(win);win.windowless_rendering_enabled=1;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=60;cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&win,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);if(!browser)return 4;auto* host=browser->get_host(browser);
+8
View File
@@ -14,4 +14,12 @@ int main(){
while(std::getline(file,line)){std::istringstream row(line);std::string kind,name;int code;if(row>>kind>>code>>name&&kind=="key")entries[code]=name;}
const char* names[]={"BUTTON_A","BUTTON_B","BUTTON_X","BUTTON_Y","BUTTON_L1","BUTTON_R1","BUTTON_SELECT","BUTTON_START","BUTTON_THUMBL","BUTTON_THUMBR"};
for(int i=0;i<10;i++)assert(entries[keys[i]]==names[i]);
assert(trigger_keys[0]==312&&trigger_keys[1]==313);
assert(entries[trigger_keys[0]]=="BUTTON_L2"&&entries[trigger_keys[1]]=="BUTTON_R2");
GamepadTriggers triggers;
assert(!triggers.update(0,0));assert(!triggers.update(0,29));
assert(triggers.update(0,30));assert(triggers.update(0,25));
assert(!triggers.down[1]);assert(triggers.update(1,255));
assert(!triggers.update(0,20));assert(triggers.down[1]);
assert(!triggers.update(1,0));assert(!triggers.down[0]&&!triggers.down[1]);
}
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {SteamLaunchChoice} from '../ui/src/steam-launch-choice';
import {configureLanguage,t} from '../ui/src/i18n';
import '../ui/src/style.css';
import '../ui/src/launcher.css';
const root=createRoot(document.getElementById('root')!);
const wait=()=>new Promise(r=>setTimeout(r,450));
let chosen:string|undefined='unset',closed=false;
async function show(locale:string){configureLanguage(locale,[]);root.render(<div className="app launcher-shell" style={{height:'100%',background:'#161618'}}><SteamLaunchChoice name="Hades" local targets={[{client:'12',name:'TOORU-PC'},{client:'13',name:'Living Room PC'}]} onChoose={client=>chosen=client} onClose={()=>closed=true}/></div>);await wait();return document.querySelector('.launch-choice')!;}
(window as any).runInstallReviewChecks=async()=>{try{
let dialog=await show('zh-CN');
if(!dialog.textContent?.includes('选择启动方式')||!dialog.textContent.includes('本机启动')||!dialog.textContent.includes('TOORU-PC')||!dialog.textContent.includes('Living Room PC'))throw Error('Missing localized labels or device names');
if(dialog.scrollWidth>dialog.clientWidth+1)throw Error('Horizontal overflow');
let buttons=dialog.querySelectorAll<HTMLButtonElement>('.launch-choice-target');buttons[2].click();if(chosen!=='13')throw Error('Wrong remote host selected');buttons[0].click();if(chosen!==undefined)throw Error('Local launch still passes remote host');
console.log('FRAMELY_PREVIEW_INSTALL_CHINESE');await wait();
dialog=await show('en-US');if(!dialog.textContent?.includes('Choose how to launch'))throw Error('English missing');
console.log('FRAMELY_PREVIEW_INSTALL_ENGLISH');await wait();
dialog.querySelector<HTMLButtonElement>('.icon-button')!.click();if(!closed)throw Error('Close action broken');
console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e)}};
show('zh-CN').then(()=>console.log('FRAMELY_VIEW_READY'));
+14
View File
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
const evaluate=eval(`(${await readFile('src/steam/remote.js','utf8')})`);
const launched=[];
globalThis.window={RemotePlayStore_SteamUI:{devices:[{clientId:'12',clientName:'Gaming PC',status:'Connected'},{clientId:'13',clientName:'Laptop',status:'Connected'},{clientId:'14',clientName:'Offline',status:'Disconnected'}]},appStore:{allApps:[{appid:42,display_name:'Game',app_type:1,remote_per_client_data:['12','13','14'].map(clientid=>({clientid,installed:true})),BIsPerClientDataLocal:()=>false}]}};
globalThis.SteamClient={Apps:{StreamGame:async(...args)=>launched.push(args)}};
const rows=await evaluate({mode:'list'});
assert.deepEqual(rows.map(r=>[r.client,r.deviceName]),[['12','Gaming PC'],['13','Laptop']]);
assert.equal(await evaluate({mode:'launch',id:42,client:'13'}),true);
assert.deepEqual(launched,[[42,'13',-1]]);
window.RemotePlayStore_SteamUI.devices[1].status='Disconnected';
assert.equal(await evaluate({mode:'launch',id:42,client:'13'}),false);
assert.equal(launched.length,1);
console.log('Remote device names, selection and disconnected-host validation passed.');
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
const lookup=eval(`(${await readFile('src/apk/steam_shortcut_lookup.js','utf8')})`);
const id=0xf1234567, exe='/home/steamos/devkit-game/owned/base.apk';
let targets=new Map([[id,{strShortcutExe:`"${exe}"`}]]),unregistered=0;
globalThis.appStore={m_mapApps:new Map([[id,{appid:id,display_name:'GamePad Tester'}]])};
globalThis.SteamClient={Apps:{RegisterForAppDetails:(id,cb)=>{queueMicrotask(()=>cb(targets.get(id)??{}));return {unregister(){unregistered++}}}}};
assert.deepEqual(await lookup({game:'owned',exe}),{id});
assert.ok(unregistered>0);
assert.deepEqual(await lookup({game:'owned',exe,previous:id}),{id});
// Never accept an identically named entry belonging to a different executable.
appStore.m_mapApps.set(id,{appid:id,display_name:'Devkit Game: owned'});
targets.set(id,{strShortcutExe:'/another/app.apk'});
await assert.rejects(lookup({game:'owned',exe}),/target does not match/);
appStore.m_mapApps.set(id,{appid:id,display_name:'GamePad Tester'});
await assert.rejects(lookup({game:'owned',exe}),/Missing or ambiguous/);
targets.set(id,{strShortcutExe:exe});
appStore.m_mapApps.set(id+1,{appid:id+1,display_name:'Another copy'});targets.set(id+1,{strShortcutExe:exe});
await assert.rejects(lookup({game:'owned',exe}),/ambiguous/);
// Cached details can call back before the subscription is returned.
appStore.m_mapApps.delete(id+1);
SteamClient.Apps.RegisterForAppDetails=(id,cb)=>{cb(targets.get(id));return {unregister(){unregistered++}}};
assert.deepEqual(await lookup({game:'owned',exe,previous:id}),{id});
console.log('Renamed registration recovery, executable ownership, ambiguity and cached callback checks passed.');
+2 -1
View File
@@ -14,8 +14,9 @@ static int loads=0, writes=0, streams=0, cleared=0;
extern "C" {
bool SDL_InitSubSystem(uint32_t flags){assert(flags==0x10);return true;}
void SDL_QuitSubSystem(uint32_t flags){assert(flags==0x10);assert(streams==0);}
bool SDL_LoadWAV(const char* path,void* spec,uint8_t** data,uint32_t* len){assert(strstr(path,"/steamui/sounds/deck_ui_"));++loads;*data=(uint8_t*)malloc(4);*len=4;return true;}
bool SDL_LoadWAV(const char* path,void* spec,uint8_t** data,uint32_t* len){assert(strstr(path,"/steamui/sounds/deck_ui_typing.wav"));++loads;*data=(uint8_t*)malloc(4);*len=4;return true;}
void* SDL_OpenAudioDeviceStream(uint32_t id,const void*,void* callback,void* userdata){assert(id==0xffffffffu&&!callback&&!userdata);++streams;return malloc(1);}
bool SDL_SetAudioStreamGain(void*,float gain){assert(gain==.30f||gain==.45f);return true;}
bool SDL_ResumeAudioStreamDevice(void*){return true;}
bool SDL_PutAudioStreamData(void*,const void*,int len){assert(loads==2&&len==4);++writes;return true;}
bool SDL_ClearAudioStream(void*){++cleared;return true;}
+4 -1
View File
@@ -974,6 +974,9 @@
"Android user did not unlock; export logs and retry.": "Android user did not unlock; export logs and retry.",
"Android external storage did not mount; export logs and retry.": "Android external storage did not mount; export logs and retry.",
"Android container stopped before becoming ready; export logs and retry.": "Android container stopped before becoming ready; export logs and retry.",
"Android readiness probe did not respond; export logs and retry.": "Android readiness probe did not respond; export logs and retry."
"Android readiness probe did not respond; export logs and retry.": "Android readiness probe did not respond; export logs and retry.",
"在此 Frame 上运行": "Run on this Frame",
"远程设备 {0}": "Remote device {0}",
"架构": "Architecture"
}
}
+14 -1
View File
@@ -964,6 +964,19 @@
"Android user did not unlock; export logs and retry.": "Android 用户未解锁,请导出日志后重试。",
"Android external storage did not mount; export logs and retry.": "Android 外部存储未挂载,请导出日志后重试。",
"Android container stopped before becoming ready; export logs and retry.": "Android 容器在就绪前退出,请导出日志后重试。",
"Android readiness probe did not respond; export logs and retry.": "Android 就绪状态查询无响应,请导出日志后重试。"
"Android readiness probe did not respond; export logs and retry.": "Android 就绪状态查询无响应,请导出日志后重试。",
"选择启动方式": "选择启动方式",
"远程畅玩": "远程畅玩",
"保存 Steam 入口设置": "保存 Steam 入口设置",
"保留安装的 APK 到 Steam": "保留安装的 APK 到 Steam",
"Steam UI operation failed": "Steam 界面操作失败,请保持 Steam 唤醒后重试。",
"Remote game or host is no longer available": "远程游戏或设备已不可用,请检查电脑连接后重试。",
"本机启动": "本机启动",
"通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。": "通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。",
"空容器": "空容器",
"默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。": "默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。",
"在此 Frame 上运行": "在此 Frame 上运行",
"远程设备 {0}": "远程设备 {0}",
"架构": "架构"
}
}
+4 -1
View File
@@ -199,4 +199,7 @@
.launch-remote-mark{position:absolute;right:18px;bottom:11px;width:29px;height:29px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:#252525e8;color:#f0f0f0;box-shadow:0 2px 6px #0005;pointer-events:none;z-index:2}
@media(max-width:900px){.launch-remote-mark{right:1px;bottom:1px}}
.launch-choice-actions{display:flex;gap:14px;margin:24px 0}.launch-choice-actions button{flex:1;display:flex;align-items:center;justify-content:center;gap:12px;min-height:64px}
.launcher-shell .launch-choice{width:600px;padding:28px;border:1px solid #ffffff20;background:rgba(24,24,26,.96);box-shadow:inset 0 1px 0 #ffffff0c,0 24px 72px #00000055;max-height:90%;overflow:auto}
.launch-choice-heading{display:flex;align-items:center;gap:16px;margin-bottom:26px}.launch-choice-heading>div{flex:1;min-width:0}.launch-choice-heading h2{margin:0 0 4px;font-size:25px;overflow-wrap:anywhere}.launch-choice-heading p{margin:0;color:var(--muted);font-size:18px}.launch-choice-heading .icon-button{align-self:flex-start;min-width:40px;min-height:40px;padding:8px}
.launch-choice-targets{display:flex;flex-direction:column;gap:10px}.launch-choice-target{width:100%;min-height:86px;padding:16px 18px;text-align:left;gap:18px;border:1px solid #ffffff12;border-radius:13px;background:#ffffff05;transition:background 140ms,border-color 140ms}.launch-choice-target:hover:not(:disabled){background:#ffffff10;border-color:#ffffff28}.launch-choice-symbol{display:flex;align-items:center;justify-content:center;width:44px;height:44px;flex:none;color:#d6d9de}.launch-choice-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}.launch-choice-copy b{font-size:21px;font-weight:550;overflow-wrap:anywhere}.launch-choice-copy small{font-size:16px}.launch-choice-arrow{color:#b7bbc2}
@media(prefers-reduced-motion:reduce){.launch-choice-target{transition:none}}
+7 -6
View File
@@ -1,3 +1,4 @@
import {SteamLaunchChoice} from './steam-launch-choice';
import {AppPlaceholder} from './app-placeholder';
import {SwitchRow} from './switch';
import React,{useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
@@ -13,7 +14,7 @@ import {LauncherCalibration} from './launcher-calibration';
import {matchesSearch,type SearchIndex} from './launcher-search';
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={remote?:boolean;remoteClient?:string;id:number;name:string;icon?:string;iconFit?:string;kind?:string};
type SteamApp={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name:string}[];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)];
return counts.flatMap((count,row)=>Array.from({length:count},(_,col)=>({row,column:(6-(total<=5?count:row===1?5:4))*2+1+col*4})));
@@ -32,7 +33,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
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})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><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={steamAppId?:number|null;id:string;name:string;kind:'desktop'|'lepton';icon?:string;launchUnavailable?:string|null};
type LaunchItem={remote?:boolean;remoteClient?:string;key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
type LaunchItem={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name:string}[];key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
const emptyOrder:string[]=[];
const categories=[{key:'favorites',label:'收藏分类',icon:IconStar},{key:'all',label:'全部',icon:IconLayoutGrid},{key:'plugin',label:'插件',icon:IconPuzzle},{key:'steam',label:'Steam',icon:IconBrandSteam},{key:'lepton',label:'Lepton',icon:IconBrandAndroid},{key:'desktop',label:'桌面程序',icon:IconDeviceDesktop}];
async function launcherRequest<T=any>(method:string,params:unknown={}):Promise<T>{
@@ -70,7 +71,7 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
// The native compositor owns surface fades; animating this high-resolution
// page too would multiply alpha and force a full CEF readback every frame.
useEffect(()=>{const open=()=>{if(shell.current)shell.current.style.pointerEvents='';};const closing=()=>{if(calibrationRef.current){location.hash='';setCalibrating(false);}if(shell.current)shell.current.style.pointerEvents='none';};window.addEventListener('framely.launcher.open',open);window.addEventListener('framely.launcher.closing',closing);return()=>{window.removeEventListener('framely.launcher.open',open);window.removeEventListener('framely.launcher.closing',closing);};},[]);
const all=useMemo(()=>{const entries:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:localFavorites.includes('framely')},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.filter(a=>!local.some(l=>l.steamAppId===a.id)).map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,remote:a.remote,remoteClient:a.remoteClient,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:localFavorites.includes(`${a.kind}:${a.id}`),launchUnavailable:a.launchUnavailable}))];
const all=useMemo(()=>{const entries:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:localFavorites.includes('framely')},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.filter(a=>!local.some(l=>l.steamAppId===a.id)).map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,remote:a.remote,remoteClient:a.remoteClient,remoteTargets:a.remoteTargets,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:localFavorites.includes(`${a.kind}:${a.id}`),launchUnavailable:a.launchUnavailable}))];
const rank=new Map(manual.map((key,index)=>[key,index]));entries.sort((a,b)=>(rank.get(a.key)??Infinity)-(rank.get(b.key)??Infinity)||a.name.localeCompare(b.name)||a.key.localeCompare(b.key));return entries;},[plugins,steam,local,favorites,localFavorites,manual]);
const indexKey=useMemo(()=>JSON.stringify(all.map(a=>a.name).sort()),[all]);
useEffect(()=>{let live=true;const names=Array.from(new Set(all.map(a=>a.name)));if(names.length)void launcherRequest<Record<string,SearchIndex>>('launcher.search.index',{names}).then(v=>{if(live)setIndexes(v);}).catch(()=>{if(live)setError(t('读音搜索暂不可用,仍可使用原文搜索。'));});return()=>{live=false;};},[indexKey]);
@@ -107,8 +108,8 @@ 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 openItem(a:LaunchItem){setMenu(null);if(a.key.startsWith('steam:')&&!a.remote&&a.remoteClient){setLaunchChoice(a);return;}void run(a.key,()=>launch(a),true);}
function chooseLaunch(remote:boolean){if(!launchChoice)return;const item={...launchChoice,remoteClient:remote?launchChoice.remoteClient:undefined};setLaunchChoice(null);void run(item.key,()=>launch(item),true);}
function openItem(a:LaunchItem){setMenu(null);if(a.key.startsWith('steam:')&&a.remoteClient&&(!a.remote||(a.remoteTargets?.length??0)>1)){setLaunchChoice(a);return;}void run(a.key,()=>launch(a),true);}
function chooseLaunch(client?:string){if(!launchChoice)return;const item={...launchChoice,remoteClient:client};setLaunchChoice(null);void run(item.key,()=>launch(item),true);}
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,remoteClient:a.remoteClient,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}>());
@@ -128,5 +129,5 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
{a.launchUnavailable&&<small role="status">{t(a.launchUnavailable)}</small>}{busyKeys.has(a.key)&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&&current+panView.direction>=0&&current+panView.direction<pages&&<div className="launcher-adjacent-page" aria-hidden="true" style={{translate:`${panView.offset+panView.direction*panView.width}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}}>{items.slice((current+panView.direction)*pageSize,(current+panView.direction+1)*pageSize).map((a,index)=>{const count=Math.min(pageSize,items.length-(current+panView.direction)*pageSize),{row,column:slot}=launcherCells(count)[index];return <div key={a.key} className="launch-item" style={{gridColumn:`${slot} / span 4`,gridRow:`${1+row*2} / span 2`}}><span className="launch-icon"><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/>{a.remoteClient&&<span className="launch-remote-mark"><IconDeviceDesktopShare size={19} stroke={1.7}/></span>}</span><span className="launch-name">{a.name}</span></div>;})}</div>}</div>
{ghost&&<div className="launcher-drag-ghost" style={{left:ghost.x,top:ghost.y}}><LauncherArtwork src={ghost.item.icon} name={ghost.item.name} kind={ghost.item.kind} fit={ghost.item.fit}/></div>}{ghost&&[-1,1].map(direction=><div key={direction} className={`launcher-drag-edge ${direction<0?'left':'right'} ${edge===direction?'active':''}`} style={{left:(direction<0?ghost.left:ghost.right)-4,top:ghost.top,height:ghost.bottom-ghost.top}} aria-hidden="true"/>)}
{!loading&&!items.length&&<p className="launcher-empty">{t(query?'没有匹配的应用':filter==='favorites'?'还没有收藏的应用':'没有可启动的应用')}</p>}<nav className="launcher-pages" aria-label={t('分页')}>{Array.from({length:pages},(_,n)=><button key={n} aria-label={t('第 {0} 页',{0:n+1})} aria-current={current===n?'page':undefined} disabled={!!dragging||!!panView} onClick={()=>setPage(n)}><span/></button>)}</nav>{loading&&<span className="launcher-loading" role="status">{t('正在读取本机应用…')}</span>}
{launchChoice&&<div className="modal-backdrop" onPointerDown={e=>e.stopPropagation()}><div className="modal launch-choice" role="dialog" aria-modal="true" aria-labelledby="launch-choice-title"><h2 id="launch-choice-title">{launchChoice.name}</h2><p>{t('选择启动方式')}</p><div className="launch-choice-actions"><button onClick={()=>chooseLaunch(false)}><IconDeviceDesktop size={24}/>{t('本机启动')}</button><button onClick={()=>chooseLaunch(true)}><IconDeviceDesktopShare size={24}/>{t('远程畅玩')}</button></div><button onClick={()=>setLaunchChoice(null)}>{t('取消')}</button></div></div>}{uninstall&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true"><h2>{t('卸载 {0}?',{0:uninstall.manifest.name})}</h2><p>{t('依赖此插件的应用也会停用;插件数据会保留。')}</p>{!!uninstall.dependents.length&&<p>{t('停用:')}{uninstall.dependents.map(id=>plugins[id]?.manifest.name??id).join(', ')}</p>}<button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} className="danger" onClick={()=>void run(`plugin:${uninstall.manifest.id}`,async()=>{await launcherRequest('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{Object.entries(frontends).map(([key,frontend])=><div key={`${key}:${frontend.ticket}`} className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={error=>done(key,error,frontend.ticket)}/></div>)}</div>;
{launchChoice&&<SteamLaunchChoice name={launchChoice.name} icon={launchChoice.icon} local={!launchChoice.remote} targets={launchChoice.remoteTargets?.length?launchChoice.remoteTargets:[{client:launchChoice.remoteClient!,name:''}]} onChoose={chooseLaunch} onClose={()=>setLaunchChoice(null)}/>}{uninstall&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true"><h2>{t('卸载 {0}?',{0:uninstall.manifest.name})}</h2><p>{t('依赖此插件的应用也会停用;插件数据会保留。')}</p>{!!uninstall.dependents.length&&<p>{t('停用:')}{uninstall.dependents.map(id=>plugins[id]?.manifest.name??id).join(', ')}</p>}<button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busyKeys.has(`plugin:${uninstall.manifest.id}`)} className="danger" onClick={()=>void run(`plugin:${uninstall.manifest.id}`,async()=>{await launcherRequest('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{Object.entries(frontends).map(([key,frontend])=><div key={`${key}:${frontend.ticket}`} className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={error=>done(key,error,frontend.ticket)}/></div>)}</div>;
}
+2 -2
View File
@@ -89,7 +89,7 @@ function App(){
if(parts[1]==='launcher')return <Launcher plugins={status.database.plugins} safeMode={status.database.safeMode} config={status.database.launcher} favorites={status.database.steamFavorites??[]} localFavorites={status.database.launcherFavorites??[]} order={status.database.launcherOrder??[]} refresh={refresh}/>;
if(parts[1]==='notifications')return <NotificationCenter items={status.notifications} plugins={status.database.plugins} safeMode={status.database.safeMode} refresh={refresh}/>;
if(windowPlugin){const p=status.database.plugins[windowPlugin];return <div className="app plugin-window">{p?.enabled&&!status.database.safeMode?<PluginFrame plugin={windowPlugin} entry={parts[3]}/>:<div className="empty"><p>{t("插件已停用或正在启动。")}</p><button onClick={()=>void action(()=>api('window.close',{plugin:windowPlugin,window:parts[3]}))}>{t("关闭")}</button></div>}{error&&<p className="error">{error}</p>}</div>;}
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong>{managerMode?<><ManagerSwitcher section={managerSection} onChange={switchManager}/>{managerSection==='plugins'&&<button className="primary" onClick={()=>setImporting(true)}><Icon name="download"/>{t("安装插件")}</button>}<button className="icon-button" aria-label={t("关闭管理窗口")} onClick={()=>void api('host.manager.close')}><Icon name="close"/></button></>:<><button className="icon-button quick-library-button" aria-label={t("插件库")} onClick={()=>void action(()=>api('host.manager.open',{page:'catalog'}))}><Icon name="store"/><span>{t("插件库")}</span></button><button className="icon-button" aria-label={t("关闭菜单")} onClick={()=>void api('host.menu.close')}><Icon name="close"/></button></>}</header>{error&&<p className="error" role="alert">{error}<button onClick={()=>setError('')}>×</button></p>}{status.database.safeMode&&<p className="banner">{t("安全模式:第三方插件暂停运行。")}</p>}
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong>{managerMode?<>{managerSection==='plugins'&&<button className="primary manager-install-button" onClick={()=>setImporting(true)}><Icon name="download" size={20}/>{t("安装插件")}</button>}<ManagerSwitcher section={managerSection} onChange={switchManager}/><button className="icon-button" aria-label={t("关闭管理窗口")} onClick={()=>void api('host.manager.close')}><Icon name="close"/></button></>:<><button className="icon-button quick-library-button" aria-label={t("插件库")} onClick={()=>void action(()=>api('host.manager.open',{page:'catalog'}))}><Icon name="store"/><span>{t("插件库")}</span></button><button className="icon-button" aria-label={t("关闭菜单")} onClick={()=>void api('host.menu.close')}><Icon name="close"/></button></>}</header>{error&&<p className="error" role="alert">{error}<button onClick={()=>setError('')}>×</button></p>}{status.database.safeMode&&<p className="banner">{t("安全模式:第三方插件暂停运行。")}</p>}
{selected?<><div className="page-title"><button onClick={()=>setPage(null)}>{t("‹ 返回")}</button><strong>{selected.manifest.name}</strong><button onClick={()=>void action(()=>api('plugin.favorite',{plugin:page,favorite:!selected.favorite}))}>{selected.favorite?'★':'☆'}</button>{managerMode&&<button onClick={()=>setManage(selected)}>{t("管理")}</button>}</div>{selected.enabled&&!status.database.safeMode&&selected.manifest.ui.quickPage?<PluginFrame plugin={page!}/>:<p className="empty">{t("插件已停用、处于安全模式或没有快捷页面。")}</p>}</>:<div className={`workspace ${managerMode&&(tab==='terminal'||tab==='files')?'tools-workspace':''}`}><nav aria-label={t("主导航")}>{(managerMode?(managerSection==='terminal'||managerSection==='files'?[]:managerSection==='settings'?[['settings',t('通用'),'settings'],['launcher-settings',t('启动台'),'launcher'],['updates',t('更新'),'refresh'],['about',t('关于'),'info']]:apkPanel?[['apk',t('应用'),'download'],['apk-containers',t('容器'),'plugins'],['apk-cleanup',t('数据清理'),'settings'],['apk-settings',t('设置'),'settings']]:[['installed',t("已安装"),'plugins'],['catalog',t("插件库"),'download'],['sources',t("插件源导航"),'link'],['notification-settings',t("通知设置导航"),'bell']]):[['favorites',t("常用"),'star'],['installed',t("已安装"),'plugins'],['notifications',t("通知"),'bell'],['settings',t("设置"),'settings']]).map(([key,label,icon])=><button className={tab===key?'active':''} aria-current={tab===key?'page':undefined} key={key} onClick={()=>key==='catalog'?void loadCatalog():(setTab(key),history.replaceState(null,'',location.pathname+location.search+'#'+key))}>{icon==='bell'?<IconBell size={24} stroke={1.8}/>:icon==='launcher'?<IconLayoutGrid size={24} stroke={1.8}/>:<Icon name={icon as any}/>}<span>{label}{key==='notifications'&&!!status.notificationBadge&&<small className="notification-count">{status.notificationBadge!>99?'99+':status.notificationBadge}</small>}</span></button>)}{managerMode&&<div className="nav-foot"><span>{apkPanel?t("Lepton 侧载管理"):<>{plugins.length}{t("个已安装插件")}</>}</span><small>{status.version}</small></div>}</nav><main className={`content ${managerMode&&tab==='files'?'file-content':managerMode&&tab==='terminal'?'terminal-content':''}`}>
{(tab==='favorites'||tab==='installed')&&<>
{managerMode&&<div className="list-heading"><div><h1>{t("已安装")}</h1><p>{plugins.length}{t("个插件,")}{plugins.filter(p=>p.enabled).length}{t("个已启用")}</p></div><div className="search-field"><Icon name="search"/><input aria-label={t("搜索已安装插件")} placeholder={t("搜索插件")} value={query} onChange={e=>setQuery(e.target.value)}/></div></div>}
@@ -109,7 +109,7 @@ function App(){
{tab==='sources'&&managerMode&&<><div className="list-heading"><div><h1>{t("插件源")}</h1><p>{t("管理插件目录及订阅。")}</p></div></div><section className="settings-card"><Sources origins={status.database.sourceOrigins} sources={status.database.sources} save={async s=>{await api('sources.save',{sources:s});await refresh();}}/></section><Subscriptions subscriptions={status.database.subscriptions??[]} refresh={refresh}/></>}
{tab==='updates'&&managerMode&&<SystemUpdates status={status} refresh={refresh}/>}
{tab==='about'&&managerMode&&<><div className="list-heading"><div><h1>{t("关于 Framely")}</h1></div></div><div className="about"><Brand/><b>Framely</b><small>{status.build??status.version}</small></div><section className="settings-card"><h2>{t("用户协议与隐私声明")}</h2><div className="row"><button onClick={()=>setReviewAgreement(true)}>{t("查看协议与隐私声明")}</button></div></section></>}
{tab==='settings'&&(!managerMode?<div className="quick-settings"><button className="setting-entry" onClick={()=>void api('host.manager.open')}><span className="setting-symbol"><Icon name="plugins" size={28}/></span><span><b>{t("管理插件")}</b><small>{t("安装、更新与管理插件源")}</small></span><span className="forward">›</span></button><section className="settings-card"><h2>{t("运行模式")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/></section><LanguageSettings selectionOnly value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><button className="setting-entry quick-update-entry" onClick={()=>void action(()=>api('host.manager.open',{page:'updates'}))}><span className="setting-symbol"><Icon name="refresh" size={28}/></span><span><b>{t("检查更新")}</b></span><span className="forward">›</span></button><div className="about"><Brand/><b>Framely</b><small>{t("版本")}{status.build??status.version}</small></div></div>:<><div className="list-heading"><div><h1>{t("设置")}</h1><p>{t("运行模式与诊断。")}</p></div></div><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><NetworkSettings config={status.database.networkPanel??{enabled:true,port:15915}} refresh={refresh}/><section className="settings-card"><h2>{t("交互反馈")}</h2>{(['haptics','sounds'] as const).map(key=><SwitchRow key={key} label={t(key==='haptics'?'振动反馈':'提示音反馈')} description={t(key==='haptics'?'指向可交互控件时振动,不影响 APK 游戏振动。':'使用 Steam 提示音反馈聚焦和点击。')} checked={status.database.feedback?.[key]??true} disabled={busy} onChange={enabled=>void action(()=>api('feedback.settings.save',{haptics:true,sounds:true,...status.database.feedback,[key]:enabled}))}/>)}</section><ProxySettings config={status.database.proxy??{http:"",github:""}} refresh={refresh}/><section className="settings-card"><h2>{t("恢复与诊断")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/><p className="sub">{t("导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。")}</p><div className="row"><button onClick={()=>void action(async()=>setLogs(JSON.stringify(await api('status'),null,2)))}>{t("诊断信息")}</button><button disabled={exportingLogs||busy} onClick={()=>void exportLogs()}>{exportingLogs?t("正在导出日志…"):t("导出日志")}</button></div>{logExport&&<p role="status">{logExport}</p>}</section><section className="settings-card"><h2>{t("运行用户")}</h2><p className="sub">{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}</p></section></>)}
{tab==='settings'&&(!managerMode?<div className="quick-settings"><button className="setting-entry" onClick={()=>void api('host.manager.open')}><span className="setting-symbol"><Icon name="plugins" size={28}/></span><span><b>{t("管理插件")}</b><small>{t("安装、更新与管理插件源")}</small></span><span className="forward">›</span></button><section className="settings-card"><h2>{t("运行模式")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/></section><LanguageSettings selectionOnly value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><button className="setting-entry quick-update-entry" onClick={()=>void action(()=>api('host.manager.open',{page:'updates'}))}><span className="setting-symbol"><Icon name="refresh" size={28}/></span><span><b>{t("检查更新")}</b></span><span className="forward">›</span></button><div className="about"><Brand/><b>Framely</b><small>{t("版本")}{status.build??status.version}</small></div></div>:<><div className="list-heading"><div><h1>{t("设置")}</h1><p>{t("运行模式与诊断。")}</p></div></div><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><NetworkSettings config={status.database.networkPanel??{enabled:true,port:15915}} refresh={refresh}/><section className="settings-card"><h2>{t("交互反馈")}</h2>{(['haptics','sounds'] as const).map(key=><SwitchRow key={key} label={t(key==='haptics'?'振动反馈':'提示音反馈')} description={t(key==='haptics'?'指向可交互控件时振动,不影响 APK 游戏振动。':'使用 Steam 提示音反馈聚焦和点击。')} checked={status.database.feedback?.[key]??(key==='haptics')} disabled={busy} onChange={enabled=>void action(()=>api('feedback.settings.save',{haptics:true,sounds:false,...status.database.feedback,[key]:enabled}))}/>)}</section><ProxySettings config={status.database.proxy??{http:"",github:""}} refresh={refresh}/><section className="settings-card"><h2>{t("恢复与诊断")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/><p className="sub">{t("导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。")}</p><div className="row"><button onClick={()=>void action(async()=>setLogs(JSON.stringify(await api('status'),null,2)))}>{t("诊断信息")}</button><button disabled={exportingLogs||busy} onClick={()=>void exportLogs()}>{exportingLogs?t("正在导出日志…"):t("导出日志")}</button></div>{logExport&&<p role="status">{logExport}</p>}</section><section className="settings-card"><h2>{t("运行用户")}</h2><p className="sub">{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}</p></section></>)}
{tab==='catalog'&&<Store target={catalogTarget} plugins={status.database.plugins} sources={status.database.sources} onInspect={inspect} onSources={()=>setTab('sources')} busy={busy}/>}
</main></div>}
{importing&&<div className="modal" role="dialog" aria-modal="true" aria-label={t("安装插件")}><button className="icon-button modal-close" aria-label={t("关闭导入")} onClick={()=>setImporting(false)}><Icon name="close"/></button><InstallInput inspect={async p=>{await inspect(p);setImporting(false);}} busy={busy}/></div>}
+13
View File
@@ -0,0 +1,13 @@
import React,{useEffect,useRef} from 'react';
import {IconArrowRight,IconDeviceDesktop,IconDeviceDesktopShare,IconX} from '@tabler/icons-react';
import {PluginImage} from './store';
import {t} from './i18n';
export type RemoteLaunchTarget={client:string;name:string};
export function SteamLaunchChoice({name,icon,local,targets,onChoose,onClose}:{name:string;icon?:string|null;local:boolean;targets:RemoteLaunchTarget[];onChoose:(client?:string)=>void;onClose:()=>void}){
const dialog=useRef<HTMLDivElement>(null);
useEffect(()=>{const previous=document.activeElement as HTMLElement|null;dialog.current?.querySelector<HTMLButtonElement>('.launch-choice-target')?.focus();return()=>previous?.focus();},[]);
return <div className="modal-backdrop" onPointerDown={e=>e.stopPropagation()} onClick={e=>e.stopPropagation()}><div ref={dialog} className="modal launch-choice" role="dialog" aria-modal="true" aria-labelledby="launch-choice-title" onKeyDown={e=>{if(e.key!=='Tab')return;const buttons=Array.from(dialog.current?.querySelectorAll<HTMLButtonElement>('button')??[]);const first=buttons[0],last=buttons.at(-1);if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus();}}}>
<header className="launch-choice-heading"><PluginImage kind="steam" src={icon} name={name} size={56}/><div><h2 id="launch-choice-title">{name}</h2><p>{t('选择启动方式')}</p></div><button className="icon-button" aria-label={t('取消')} onClick={onClose}><IconX size={22}/></button></header>
<div className="launch-choice-targets">{local&&<button className="launch-choice-target" onClick={()=>onChoose()}><span className="launch-choice-symbol"><IconDeviceDesktop size={26} stroke={1.6}/></span><span className="launch-choice-copy"><b>{t('本机启动')}</b><small>{t('在此 Frame 上运行')}</small></span><IconArrowRight className="launch-choice-arrow" size={22} stroke={1.6}/></button>}{targets.map(target=><button key={target.client} className="launch-choice-target" onClick={()=>onChoose(target.client)}><span className="launch-choice-symbol"><IconDeviceDesktopShare size={26} stroke={1.6}/></span><span className="launch-choice-copy"><b>{target.name||t('远程设备 {0}',{0:target.client})}</b><small>{t('远程畅玩')}</small></span><IconArrowRight className="launch-choice-arrow" size={22} stroke={1.6}/></button>)}</div>
</div></div>;
}
+2
View File
@@ -193,3 +193,5 @@ button{overflow-wrap:anywhere}
/* Tint layers preserve the shell alpha; do not add opaque scrolling surfaces. */
@media(prefers-reduced-transparency:reduce){:root{--bg:#121212;--surface:#1a1a1a;--overlay-bg:#121212;--manager-content-tint:#1a1a1c;--manager-chrome-tint:#202022}}
.manager .shell-header .manager-install-button{min-height:44px;padding:8px 14px;font-size:18px;gap:8px;white-space:nowrap;flex:none}