mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 08:01:30 +02:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d2d4289b | ||
|
|
3b7382e1a0 | ||
|
|
d24aa708a7 | ||
|
|
c64bdfb374 | ||
|
|
1fc2e32f26 | ||
|
|
3d2899fb6e | ||
|
|
6589f85ec6 | ||
|
|
e8c406ffca | ||
|
|
16fb76c579 |
No files matched your search
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.5.0-preview.2"
|
||||
version = "0.5.1-preview.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.5.0-preview.2"
|
||||
version = "0.5.1-preview.2"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
## 交互反馈
|
||||
|
||||
在 **设置 → 通用 → 交互反馈** 中,振动反馈和提示音反馈默认开启,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机的导航和点击音效,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
|
||||
在 **设置 → 通用 → 交互反馈** 中,振动反馈默认开启,提示音反馈默认关闭,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机键盘音效,以较低音量反馈聚焦和点击,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
|
||||
|
||||
## 终端
|
||||
|
||||
|
||||
+6
-6
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+60
-2
@@ -1305,7 +1305,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
|
||||
@@ -5481,7 +5495,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 +5518,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)
|
||||
|
||||
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
@@ -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.');
|
||||
@@ -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;}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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&¤t+panView.direction>=0&¤t+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
@@ -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>}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user