mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-17 14:01:26 +02:00
Improve Quest permission UI and pending interface fixes
This commit is contained in:
1 parent
41137e1d26
commit
3039c9ef0e
19 files changed
+384
-33
No files matched your search
@@ -20,3 +20,6 @@
|
||||
/media/bin/
|
||||
|
||||
/media/source/
|
||||
|
||||
# Local agent operating notes
|
||||
/AGENTS.md
|
||||
@@ -72,7 +72,7 @@ Both services should be `active`. Open SteamVR Dashboard and find Framely in the
|
||||
|
||||
## Update and rollback
|
||||
|
||||
When an update source is configured, choose **Stable** or **Testing** in **About → Framely update**, then check, download and confirm installation. Stable checks stable releases only; Testing checks prereleases such as Preview, Beta and RC. The channel preference persists on the device. Switching channels clears the previous candidate and download, so check and download again before installation. Alternatively, use the desktop installer or run on Frame:
|
||||
When an update source is configured, choose **Stable** or **Testing** in **About → Framely update**, then check, download and confirm installation. Stable checks stable releases only; Testing checks both stable releases and prereleases such as Preview, Beta and RC, then selects the newest version. The channel preference persists on the device. Switching channels clears the previous candidate and download, so check and download again before installation. Alternatively, use the desktop installer or run on Frame:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- update --version v0.4.2-preview.3
|
||||
|
||||
@@ -72,7 +72,7 @@ sudo /var/lib/framely/current/bin/framely status
|
||||
|
||||
## 更新与回滚
|
||||
|
||||
已配置更新服务时,在“关于 → Framely 更新”选择“正式版”或“测试版”,再检查、下载并确认安装。正式版只检查正式 Release,测试版只检查 Preview、Beta、RC 等预发布版本;渠道选择保存在设备上。切换渠道会清除原候选版本和已下载包,需要重新检查和下载。也可以用桌面安装器,或在 Frame 执行:
|
||||
已配置更新服务时,在“关于 → Framely 更新”选择“正式版”或“测试版”,再检查、下载并确认安装。正式版只检查正式 Release;测试版同时检查正式版和 Preview、Beta、RC 等预发布版本,并选择最新版本。渠道选择保存在设备上。切换渠道会清除原候选版本和已下载包,需要重新检查和下载。也可以用桌面安装器,或在 Frame 执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- update --version v0.4.2-preview.3
|
||||
|
||||
+71
-6
@@ -108,6 +108,7 @@ static bool menu_open=false,keyboard_open=false,keyboard_close_pending=false;
|
||||
static float notification_curve_radius=1.3f;
|
||||
static bool keyboard_toolbar=false,keyboard_chars=false,keyboard_done=false;
|
||||
static MenuButtonInput menu_button;
|
||||
static std::map<unsigned,uint64_t> menu_previous_buttons;
|
||||
static cef_frame_t* keyboard_frame=nullptr;
|
||||
static std::string keyboard_view;
|
||||
static KeyboardMenuGuard keyboard_menu;
|
||||
@@ -133,6 +134,7 @@ struct View {
|
||||
vr::VROverlayHandle_t overlay=0,thumbnail=0;GLuint textures[2]{};int texture_slot=0;bool texture_ready[2]{};PaintDamage texture_damage[2];std::vector<uint8_t> pixels;
|
||||
};
|
||||
static std::map<std::string,std::unique_ptr<View>> views;
|
||||
static bool permission_trigger_held[2]{};
|
||||
static View* find_browser(cef_browser_t* b){int id=b->get_identifier(b);for(auto& [key,v]:views)if(v->browser_id==id)return v.get();return nullptr;}
|
||||
static Handler<cef_client_t> client;
|
||||
static Handler<cef_render_handler_t> render_handler;
|
||||
@@ -268,8 +270,25 @@ static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_o
|
||||
}else {keyboard_done=keyboard_done||e.eventType==vr::VREvent_KeyboardDone;keyboard_close_pending=true;}
|
||||
return true;
|
||||
}
|
||||
static void close_view(View& v){if(v.closing)return;if(v.key.rfind("framely.cast.",0)==0){std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
|
||||
static void close_view(View& v){if(v.closing)return;if(v.key.rfind("framely.cast.",0)==0){std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu"||v.key=="framely.apk.permission")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
|
||||
static void anchor_to_head(View& v,float width,float x=0,float y=0,float z=-1.35f){vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);if(!poses[0].bPoseIsValid)return;auto m=poses[0].mDeviceToAbsoluteTracking;for(int r=0;r<3;r++)m.m[r][3]+=m.m[r][0]*x+m.m[r][1]*y+m.m[r][2]*z;overlays->SetOverlayWidthInMeters(v.overlay,width);overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&m);}
|
||||
static void anchor_permission(View& v){
|
||||
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
|
||||
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);
|
||||
if(!poses[0].bPoseIsValid)return;
|
||||
const auto& head=poses[0].mDeviceToAbsoluteTracking;
|
||||
float fx=head.m[0][2],fz=head.m[2][2],length=std::hypot(fx,fz);
|
||||
if(length<.01f)return;
|
||||
fx/=length;fz/=length;
|
||||
vr::HmdMatrix34_t upright{};
|
||||
upright.m[0][0]=fz;upright.m[1][1]=1;upright.m[2][0]=-fx;
|
||||
upright.m[0][2]=fx;upright.m[2][2]=fz;
|
||||
upright.m[0][3]=head.m[0][3]-.65f*fx;
|
||||
upright.m[1][3]=head.m[1][3];
|
||||
upright.m[2][3]=head.m[2][3]-.65f*fz;
|
||||
overlays->SetOverlayWidthInMeters(v.overlay,.85f);
|
||||
overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&upright);
|
||||
}
|
||||
static void anchor_notification(View& v){
|
||||
// Let the compositor follow the HMD's render pose; a captured standing-space
|
||||
// transform leaves the toast behind when the user turns or moves their head.
|
||||
@@ -290,7 +309,13 @@ static View& create_view(const std::string& key,const std::string& title,const s
|
||||
// texture upload and launcher mipmap generation scale with the pixel count.
|
||||
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dashboard;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications")?2:1;
|
||||
auto error=dashboard?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
|
||||
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);
|
||||
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,key=="framely.apk.permission");overlays->SetOverlaySortOrder(v->overlay,key=="framely.apk.permission"?100:0);if(key=="framely.apk.permission")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true);
|
||||
if(key=="framely.apk.permission"){
|
||||
vr::VROverlayIntersectionMaskPrimitive_t mask{};
|
||||
mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;
|
||||
mask.m_Primitive.m_Rectangle={0,0,float(width),float(height)};
|
||||
if(auto e=overlays->SetOverlayIntersectionMask(v->overlay,&mask,1))std::cerr<<"Permission input mask failed "<<e<<"\n";
|
||||
}
|
||||
|
||||
// Keep the runtime's full dashboard controls, including its keyboard and close buttons.
|
||||
if(dashboard){
|
||||
@@ -532,6 +557,32 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
}
|
||||
if(kind==0&&!active)entry_analog[hand].held=false;
|
||||
if(kind==0&&active)gaze_buttons[hand]|=down;
|
||||
if(kind==0){
|
||||
auto permission=views.find("framely.apk.permission");
|
||||
if(permission!=views.end()&&!permission->second->closing&&permission->second->shown&&pose_ok){
|
||||
auto& view=*permission->second;
|
||||
const auto& m=pose.pose.mDeviceToAbsoluteTracking;
|
||||
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;
|
||||
for(int row=0;row<3;row++){ray.vSource.v[row]=m.m[row][3];ray.vDirection.v[row]=-m.m[row][2];}
|
||||
vr::VROverlayIntersectionResults_t point{};
|
||||
if(overlays->ComputeOverlayIntersection(view.overlay,&ray,&point)&&point.vUVs.v[0]>=0&&point.vUVs.v[0]<=1&&point.vUVs.v[1]>=0&&point.vUVs.v[1]<=1){
|
||||
cef_mouse_event_t mouse{};
|
||||
mouse.x=std::clamp(int(point.vUVs.v[0]*view.width),0,view.width-1);
|
||||
mouse.y=std::clamp(int((1-point.vUVs.v[1])*view.height),0,view.height-1);
|
||||
if(!overlays->IsHoverTargetOverlay(view.overlay)){
|
||||
view.host->send_mouse_move_event(view.host,&mouse,0);
|
||||
if(active&&down&&!permission_trigger_held[hand]){
|
||||
view.host->set_focus(view.host,1);
|
||||
mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;
|
||||
view.host->send_mouse_click_event(view.host,&mouse,MBT_LEFT,0,1);
|
||||
mouse.modifiers=0;
|
||||
view.host->send_mouse_click_event(view.host,&mouse,MBT_LEFT,1,1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
permission_trigger_held[hand]=active&&down;
|
||||
}
|
||||
bool was_held=entry_buttons[hand][kind].held;
|
||||
entry_buttons[hand][kind].update(active,down,hit,now);
|
||||
if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
|
||||
@@ -552,9 +603,9 @@ 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",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=="cast.window"){auto id=c.at("id").get<std::string>();auto key="framely.cast."+id;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}int width=c.value("width",640),height=c.value("height",240);if(width<160||width>1920||height<112||height>8192)continue;auto& v=create_view(key,"Framely casting",origin+"/cast/window/"+id,false,width,height,"",2.f);v.cast_frame.path=c.at("frame").get<std::string>();if(v.width!=width||v.height!=height){v.width=width;v.height=height;v.pixels.clear();v.texture_ready[0]=v.texture_ready[1]=false;v.host->was_resized(v.host);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v.overlay,&scale);}menu_open=false;launcher_open=false;}else if(kind=="cast.close"){auto key="framely.cast."+c.at("id").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="window.open"){hide_launcher(false);std::string plugLine 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=="apk.permission.open"){auto key="framely.apk.permission";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto& v=create_view(key,"Android permission",origin+"/apk/permission",false,1000,560,"",.85f);anchor_permission(v);}else if(kind=="apk.permission.close"){if(views.count("framely.apk.permission"))close_view(*views.at("framely.apk.permission"));}else if(kind=="cast.window"){auto id=c.at("id").get<std::string>();auto key="framely.cast."+id;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}int width=c.value("width",640),height=c.value("height",240);if(width<160||width>1920||height<112||height>8192)continue;auto& v=create_view(key,"Framely casting",origin+"/cast/window/"+id,false,width,height,"",2.f);v.cast_frame.path=c.at("frame").get<std::string>();if(v.width!=width||v.height!=height){v.width=width;v.heighLine 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"&&v.key.rfind("framely.cast.",0)!=0)close_view(v);break;default:break;}}
|
||||
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;if(menu_open&&v.key!="menu"&&e.eventType==vr::VREvent_MouseButtonDown&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now()))menu_open=false;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="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"&&v.key.rfind("framely.cast.",0)!=0&&v.key!="framely.apk.permission")close_view(v);break;default:break;}}
|
||||
|
||||
int main(int argc,char** argv){
|
||||
std::cout.setf(std::ios::unitbuf);std::string initial;int port=0;for(int i=1;i+1<argc;i++){std::string s=argv[i];if(s=="--url")initial=argv[++i];else if(s=="--port")port=std::stoi(argv[++i]);else if(s=="--runtime")runtime=argv[++i];}
|
||||
@@ -612,7 +663,7 @@ int main(int argc,char** argv){
|
||||
if(!main_overlay)main_overlay=active_main_window();
|
||||
if(main_overlay&&!overlays->GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose;
|
||||
if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);}
|
||||
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher"&&key.rfind("framely.cast.",0)!=0)close_view(*v);}was_visible=visible;
|
||||
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher"&&key.rfind("framely.cast.",0)!=0&&key!="framely.apk.permission")close_view(*v);}was_visible=visible;
|
||||
if(launcher_close_at.time_since_epoch().count()&&now>=launcher_close_at){launcher_close_at={};if(!launcher_open)finish_launcher_close();}
|
||||
// The runtime owns dashboard placement, curvature, visibility and switching.
|
||||
// Never move the launcher into standing space or hide another app's overlay.
|
||||
@@ -660,8 +711,22 @@ int main(int argc,char** argv){
|
||||
activate_entry(entry_hold.tick(std::chrono::steady_clock::now(),anchor_ok&&entry_connected,entry_inside));
|
||||
for(auto&[key,v]:views)v->stick_scrolling=false;
|
||||
entry_actions(button,anchor_ok);
|
||||
// Some dashboard runtimes do not emit Modal_Cancel for clicks on their own
|
||||
// overlays. Observe the same controller click edge as a fallback, without
|
||||
// consuming the click destined for the other surface.
|
||||
for(int hand=0;hand<2;hand++){
|
||||
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
|
||||
if(device==vr::k_unTrackedDeviceIndexInvalid)continue;
|
||||
vr::VRControllerState_t state{};
|
||||
if(!vr_system->GetControllerState(device,&state,sizeof(state))){menu_previous_buttons.erase(device);continue;}
|
||||
auto previous=menu_previous_buttons.find(device);
|
||||
uint64_t pressed=previous==menu_previous_buttons.end()?0:state.ulButtonPressed&~previous->second;
|
||||
menu_previous_buttons[device]=state.ulButtonPressed;
|
||||
const uint64_t click=vr::ButtonMaskFromId(vr::k_EButton_SteamVR_Trigger)|vr::ButtonMaskFromId(vr::k_EButton_A)|vr::ButtonMaskFromId(vr::k_EButton_DPad_Down);
|
||||
if(menu_open&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now())&&(pressed&click)&&views.count("menu")&&!overlays->IsHoverTargetOverlay(views.at("menu")->overlay)&&!overlays->IsHoverTargetOverlay(button))menu_open=false;
|
||||
}
|
||||
if(launcher_open){bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held;launcher_dismiss.observe(held);}
|
||||
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){if(!active&&key=="launcher"){if(v->launcher_pointer.active){cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;v->host->send_mouse_move_event(v->host,&outside,0);outside.modifiers=0;v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);}v->launcher_pointer.clear();auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->base.release(&frame->base);}v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;if(key!="launcher")for(auto&[other,t]:views)if(!t->dashboard&&other!="menu"&&other!="notifications"&&other!="launcher"&&other.rfind("framely.cast.",0)!=0&&!keyboard_open)close_view(*t);}}
|
||||
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){if(!active&&key=="launcher"){if(v->launcher_pointer.active){cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;v->host->send_mouse_move_event(v->host,&outside,0);outside.modifiers=0;v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);}v->launcher_pointer.clear();auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->base.release(&frame->base);}v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;if(key!="launcher")for(auto&[other,t]:views)if(!t->dashboard&&other!="menu"&&other!="notifications"&&other!="launcher"&&other.rfind("framely.cast.",0)!=0&&other!="framely.apk.permission"&&!keyboard_open)close_view(*t);}}
|
||||
while(overlays->PollNextOverlayEvent(v->overlay,&event,sizeof(event)))overlay_input(*v,event);
|
||||
if(!v->cast_frame.path.empty()){glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);if(v->cast_frame.update())v->dirty=true;}
|
||||
if(v->dirty&&!v->closing&&v->pixels.size()==size_t(v->width*v->render_scale)*size_t(v->height*v->render_scale)*4){
|
||||
|
||||
@@ -18,6 +18,9 @@ export function installKeyboard(open:(params:{existing:string;password:boolean;m
|
||||
};
|
||||
(window as any).__framelyEditKeyboard=(edit:string)=>{
|
||||
if(!field?.isConnected)return;
|
||||
// Embedded editors can consume the native keyboard without mutating their
|
||||
// hidden textarea. Ordinary inputs continue through the path below.
|
||||
if(!field.dispatchEvent(new CustomEvent('framely.keyboard.edit',{detail:edit,cancelable:true})))return;
|
||||
const value=field.value;
|
||||
let start=field.selectionStart??value.length,end=field.selectionEnd??start;
|
||||
const segments=Array.from(new (Intl as any).Segmenter(undefined,{granularity:'grapheme'}).segment(value)) as {index:number;segment:string}[];
|
||||
|
||||
@@ -24,6 +24,7 @@ mod cleanup;
|
||||
mod graphics;
|
||||
mod lifecycle;
|
||||
mod native;
|
||||
pub(crate) mod permissions;
|
||||
mod steam_bridge;
|
||||
mod steam_shortcuts;
|
||||
pub(crate) mod steam_ui;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Mirrors only a pending Android runtime-permission dialog into a VR window.
|
||||
//! The user's click is delivered to Android's own PermissionController, so
|
||||
//! Android retains its one-time/while-in-use/denial semantics and callbacks.
|
||||
use super::*;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use image::{DynamicImage, ImageFormat};
|
||||
use std::collections::BTreeSet;
|
||||
use std::io::{Cursor, Seek, SeekFrom};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Pending {
|
||||
pub id: String,
|
||||
pub app_name: String,
|
||||
pub package: String,
|
||||
runtime: String,
|
||||
instance: String,
|
||||
}
|
||||
|
||||
fn active_permission(runtime: &str) -> Result<bool> {
|
||||
let mut command = crate::process::tool("podman");
|
||||
command.args([
|
||||
"exec", &format!("lepton-{runtime}"), "sh", "-c",
|
||||
"dumpsys activity activities | grep -E 'topResumedActivity=|mResumedActivity:'; printf '\\nFRAMELY_PERMISSION_DONE\\n'",
|
||||
]);
|
||||
let text = output(command, Duration::from_secs(3), None)?;
|
||||
ensure!(text.ends_with("FRAMELY_PERMISSION_DONE\n"), "Incomplete permission probe");
|
||||
Ok(text.lines().any(|line| {
|
||||
(line.contains("topResumedActivity=") || line.contains("mResumedActivity:"))
|
||||
&& line.contains("com.android.permissioncontroller/")
|
||||
&& line.contains("GrantPermissionsActivity")
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn pending(home: &Path) -> Result<Option<Pending>> {
|
||||
let db = load(home)?;
|
||||
let mut command = crate::process::tool("podman");
|
||||
command.args(["ps", "--format", "{{.Names}}"]);
|
||||
let running = output(command, Duration::from_secs(3), None)?;
|
||||
let running: BTreeSet<_> = running.lines().collect();
|
||||
let contexts = containers(home, &db);
|
||||
for record in db.records.values().filter(|r| !r.removed && r.metadata.vr && !r.show_window.unwrap_or(false)) {
|
||||
let Some(base) = contexts.iter().find(|c| c.id == record.context) else { continue };
|
||||
let runtime = native::runtime_name(record).filter(|name| running.contains(format!("lepton-{name}").as_str()))
|
||||
.unwrap_or_else(|| base.name.clone());
|
||||
if !running.contains(format!("lepton-{runtime}").as_str()) { continue; }
|
||||
let mut probe = crate::process::tool("podman");
|
||||
probe.args(["exec", &format!("lepton-{runtime}"), "getprop", "lepton.active_app_id"]);
|
||||
let Ok(package) = output(probe, Duration::from_secs(3), None) else { continue };
|
||||
if package.trim() != record.metadata.package { continue; }
|
||||
if !active_permission(&runtime)? { continue; }
|
||||
let mut inspect = crate::process::tool("podman");
|
||||
inspect.args(["inspect", "--format", "{{.Id}}|{{.State.StartedAt}}|{{.State.Running}}", &format!("lepton-{runtime}")]);
|
||||
let Ok(instance) = output(inspect, Duration::from_secs(3), None) else { continue };
|
||||
let instance = instance.trim().to_owned();
|
||||
if !instance.ends_with("|true") || instance.split('|').count() != 3 { continue; }
|
||||
let id = hash(&format!("{}|{}|{}", runtime, instance, package.trim()));
|
||||
return Ok(Some(Pending {
|
||||
id,
|
||||
app_name: record.metadata.name.clone(),
|
||||
package: package.trim().to_owned(),
|
||||
runtime,
|
||||
instance,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn current(home: &Path, id: &str) -> Result<Pending> {
|
||||
let request = pending(home)?.context("Android permission request has ended")?;
|
||||
ensure!(request.id == id, "Android permission request has changed");
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn screenshot(runtime: &str) -> Result<Vec<u8>> {
|
||||
let mut file = tempfile::tempfile()?;
|
||||
let mut child = crate::process::tool("podman")
|
||||
.args(["exec", &format!("lepton-{runtime}"), "screencap", "-p"])
|
||||
.stdin(Stdio::null()).stdout(Stdio::from(file.try_clone()?)).stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
let start = Instant::now();
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? { break status; }
|
||||
if start.elapsed() >= Duration::from_secs(3) {
|
||||
let _ = child.kill(); let _ = child.wait();
|
||||
bail!("Android permission capture timed out");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
ensure!(status.success(), "Android permission capture failed");
|
||||
ensure!(file.metadata()?.len() <= 8 * 1024 * 1024, "Android permission capture too large");
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "Android permission capture is not PNG");
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn frame(home: &Path, id: &str) -> Result<Value> {
|
||||
let request = current(home, id)?;
|
||||
let image = image::load_from_memory_with_format(&screenshot(&request.runtime)?, ImageFormat::Png)?;
|
||||
let (width, height) = (image.width(), image.height());
|
||||
ensure!((320..=4096).contains(&width) && (240..=4096).contains(&height), "Unexpected Android display size");
|
||||
let crop_width = width * 11 / 20;
|
||||
let crop_height = height * 11 / 20;
|
||||
let left = (width - crop_width) / 2;
|
||||
let top = (height - crop_height) / 2;
|
||||
let cropped = image.crop_imm(left, top, crop_width, crop_height);
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
DynamicImage::ImageRgba8(cropped.into_rgba8()).write_to(&mut png, ImageFormat::Png)?;
|
||||
ensure!(current(home, id)?.instance == request.instance, "Android permission request has changed");
|
||||
Ok(json!({"id":id,"appName":request.app_name,"package":request.package,
|
||||
"image":format!("data:image/png;base64,{}",STANDARD.encode(png.into_inner())),
|
||||
"left":left,"top":top,"width":crop_width,"height":crop_height}))
|
||||
}
|
||||
|
||||
const INPUT: &str = r#"
|
||||
set -eu
|
||||
for entry in $(cat /proc/$(pidof system_server)/environ | tr '\0' '\n' | grep -E '^(ANDROID_(ROOT|DATA|ART_ROOT|I18N_ROOT|TZDATA_ROOT)|BOOTCLASSPATH)='); do
|
||||
export "$entry"
|
||||
done
|
||||
exec input touchscreen tap "$1" "$2"
|
||||
"#;
|
||||
|
||||
pub(crate) fn click(home: &Path, id: &str, x: u32, y: u32) -> Result<Value> {
|
||||
let request = current(home, id)?;
|
||||
ensure!(x < 4096 && y < 4096, "Invalid Android input position");
|
||||
let image = image::load_from_memory_with_format(&screenshot(&request.runtime)?, ImageFormat::Png)?;
|
||||
ensure!(x < image.width() && y < image.height(), "Android input position outside display");
|
||||
ensure!(current(home, id)?.instance == request.instance, "Android permission request has changed");
|
||||
let mut command = crate::process::tool("podman");
|
||||
command.args([
|
||||
"exec", &format!("lepton-{}", request.runtime), "sh", "-c", INPUT,
|
||||
"framely-permission", &x.to_string(), &y.to_string(),
|
||||
]);
|
||||
output(command, Duration::from_secs(3), None)?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
@@ -853,6 +853,10 @@ impl Agent {
|
||||
if remote && request["method"] == "ui.visibility.get" {
|
||||
request["params"] = json!({"view":""});
|
||||
}
|
||||
ensure!(
|
||||
!remote || !request["method"].as_str().is_some_and(|m| m.starts_with("apk.permission.")),
|
||||
"Headset permission window is local only"
|
||||
);
|
||||
let result = if remote && request["method"] == "agreement.decline" {
|
||||
Ok(json!({"closed":false}))
|
||||
} else {
|
||||
@@ -867,6 +871,7 @@ impl Agent {
|
||||
if path == "/"
|
||||
|| path == "/cast/watch"
|
||||
|| path.starts_with("/cast/window/")
|
||||
|| path == "/apk/permission"
|
||||
|| path == "/manager"
|
||||
|| path == "/notifications"
|
||||
|| path == "/launcher"
|
||||
@@ -1074,6 +1079,15 @@ impl Agent {
|
||||
),
|
||||
"apk.cleanup.list" => crate::apk::cleanup_list(&crate::steam::home()?),
|
||||
"apk.storage.status" => crate::apk::storage_status(&crate::steam::home()?),
|
||||
"apk.permission.pending" => Ok(json!(crate::apk::permissions::pending(&crate::steam::home()?)?)),
|
||||
"apk.permission.frame" => crate::apk::permissions::frame(
|
||||
&crate::steam::home()?, p["id"].as_str().context("Missing permission request")?
|
||||
),
|
||||
"apk.permission.click" => crate::apk::permissions::click(
|
||||
&crate::steam::home()?, p["id"].as_str().context("Missing permission request")?,
|
||||
p["x"].as_u64().context("Invalid Android input position")?.try_into()?,
|
||||
p["y"].as_u64().context("Invalid Android input position")?.try_into()?,
|
||||
),
|
||||
"apk.storage.start" => {
|
||||
let _gate = APK_STORAGE_API_GATE.lock().unwrap();
|
||||
ensure!(!self.jobs.uploads.active() && !self.jobs.active_kind("apk:storage") && !self.jobs.active_kind("apk:operation"), "请等待 APK 上传或操作完成");
|
||||
@@ -1901,6 +1915,34 @@ pub fn serve(
|
||||
}
|
||||
cast_agent.maintain_cast();std::thread::sleep(Duration::from_millis(100));
|
||||
}});
|
||||
let permission_agent = agent.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut shown = String::new();
|
||||
let mut missing = 0u8;
|
||||
loop {
|
||||
if let Ok(home) = crate::steam::home() {
|
||||
if let Ok(pending) = crate::apk::permissions::pending(&home) {
|
||||
let next = pending.as_ref().map(|p| p.id.clone()).unwrap_or_default();
|
||||
if next.is_empty() && !shown.is_empty() && missing < 1 {
|
||||
missing += 1;
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
continue;
|
||||
}
|
||||
missing = 0;
|
||||
if next != shown {
|
||||
if !shown.is_empty() {
|
||||
push(&permission_agent.commands, json!({"kind":"apk.permission.close"}));
|
||||
}
|
||||
if let Some(request) = pending {
|
||||
push(&permission_agent.commands, json!({"kind":"apk.permission.open","id":request.id}));
|
||||
}
|
||||
shown = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
});
|
||||
let file_agent = agent.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
if let Ok(home) = crate::steam::home() {
|
||||
|
||||
+29
-19
@@ -182,10 +182,10 @@ fn resolve_release(
|
||||
let Ok(version) = semver::Version::parse(tag) else {
|
||||
continue;
|
||||
};
|
||||
let testing = channel == UpdateChannel::Testing;
|
||||
let is_prerelease = !version.pre.is_empty();
|
||||
if release.draft
|
||||
|| release.prerelease != testing
|
||||
|| version.pre.is_empty() == testing
|
||||
|| release.prerelease != is_prerelease
|
||||
|| (channel == UpdateChannel::Stable && is_prerelease)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -242,7 +242,7 @@ fn resolve_release(
|
||||
let release = verify_descriptor(&bytes)?;
|
||||
let version = descriptor_version(&release.version)?;
|
||||
ensure!(
|
||||
version.pre.is_empty() == (channel == UpdateChannel::Stable),
|
||||
channel == UpdateChannel::Testing || version.pre.is_empty(),
|
||||
"更新清单不属于所选渠道,请配置该渠道的清单地址"
|
||||
);
|
||||
Ok(Some((release, bytes, version.to_string())))
|
||||
@@ -678,17 +678,29 @@ mod tests {
|
||||
let testing = resolve_fixture(UpdateChannel::Testing, releases)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(testing.2, "0.4.2-preview.10");
|
||||
assert_eq!(testing.2, "0.4.2");
|
||||
assert!(resolve_fixture(
|
||||
UpdateChannel::Stable,
|
||||
vec![fixture("0.4.2-preview.1", false)]
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(
|
||||
assert_eq!(
|
||||
resolve_fixture(UpdateChannel::Testing, vec![fixture("0.4.2", false)])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
.unwrap()
|
||||
.2,
|
||||
"0.4.2"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_fixture(
|
||||
UpdateChannel::Testing,
|
||||
vec![fixture("0.4.2", false), fixture("0.4.3-preview.1", false)]
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.2,
|
||||
"0.4.3-preview.1"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
@@ -762,9 +774,10 @@ mod tests {
|
||||
.ends_with("framely-0.4.2-preview.1-012345abcdef-linux-arm64.tar.gz"));
|
||||
}
|
||||
#[test]
|
||||
fn custom_descriptors_distinguish_channels_and_ignore_packaging_hashes() {
|
||||
fn custom_descriptors_allow_stable_on_testing_and_ignore_packaging_hashes() {
|
||||
for (version, channel) in [
|
||||
("0.4.2", UpdateChannel::Stable),
|
||||
("0.4.2", UpdateChannel::Testing),
|
||||
("0.4.2-beta.1", UpdateChannel::Testing),
|
||||
] {
|
||||
let (_, bytes) = fixture(version, false);
|
||||
@@ -780,17 +793,14 @@ mod tests {
|
||||
.2,
|
||||
version
|
||||
);
|
||||
let other_channel = if channel == UpdateChannel::Stable {
|
||||
UpdateChannel::Testing
|
||||
} else {
|
||||
UpdateChannel::Stable
|
||||
};
|
||||
assert!(
|
||||
resolve_release(&source, other_channel, &Default::default(), &mut |_, _| Ok(
|
||||
bytes.clone()
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
if version.contains('-') {
|
||||
assert!(
|
||||
resolve_release(&source, UpdateChannel::Stable, &Default::default(), &mut |_, _| Ok(
|
||||
bytes.clone()
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -24,6 +24,13 @@ registerPlugin({QuickPage:Page});
|
||||
await native('X','x','keyboard-multiline-state');await native('ENTER','x\n','keyboard-multiline-state');
|
||||
await native('Y','x\ny','keyboard-multiline-state');
|
||||
await native('BACKSPACE','x\n','keyboard-multiline-state');await native('BACKSPACE','x','keyboard-multiline-state');await native('BACKSPACE','','keyboard-multiline-state');
|
||||
const editor=document.createElement('textarea'),edits:string[]=[];
|
||||
editor.addEventListener('framely.keyboard.edit',event=>{event.preventDefault();edits.push((event as CustomEvent<string>).detail);});
|
||||
document.body.appendChild(editor);editor.focus();editor.dispatchEvent(new FocusEvent('focusin',{bubbles:true}));
|
||||
console.log('FRAMELY_NATIVE_KEYBOARD_X');await until(()=>edits.length===1);
|
||||
console.log('FRAMELY_NATIVE_KEYBOARD_BACKSPACE');await until(()=>edits.length===2);
|
||||
if(edits[0]!=='x'||edits[1]!=='\b'||editor.value!=='')throw Error('custom keyboard editor did not consume native edits');
|
||||
editor.remove();
|
||||
console.log('FRAMELY_KEYBOARD_REACT_PASS');
|
||||
(window as any).__framelyKeyboardFixtureDone=true;
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}})();
|
||||
@@ -9,7 +9,14 @@ cargo build --release --locked
|
||||
npm ci --no-audit --no-fund
|
||||
npm run build
|
||||
bash tools/build-native.sh "$cef"
|
||||
bash tools/build-media.sh
|
||||
if [[ ${FRAMELY_USE_PREBUILT_MEDIA:-0} == 1 ]]; then
|
||||
[[ -z $(git status --porcelain -- media) ]] || { echo 'Media sources changed; rebuild media.' >&2; exit 1; }
|
||||
for file in media/bin/framely-panel-grab media/bin/framely-capture media/bin/mediamtx media/bin/uxplay media/bin/framely-receiver media/bin/libgstframely.so media/bin/libgstlibav.so media/bin/LICENSE media/bin/LICENSE.uxplay media/bin/LICENSE.gst-libav media/bin/LICENSE.llhttp; do
|
||||
[[ -s $file ]] || { echo "Missing prebuilt media: $file" >&2; exit 1; }
|
||||
done
|
||||
else
|
||||
bash tools/build-media.sh
|
||||
fi
|
||||
binary=${CARGO_TARGET_DIR:-target}/release/framely
|
||||
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
build_id="$version-$(find "$binary" target/native media/bin ui/dist sdk templates/plugin examples/showcase packaging tools docs assets/branding assets/search README.md README.zh-CN.md LICENSE -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)"
|
||||
|
||||
@@ -417,7 +417,7 @@
|
||||
"正式版": "Stable",
|
||||
"测试版": "Testing",
|
||||
"仅检查正式版。": "Check stable releases only.",
|
||||
"仅检查测试版(Preview、Beta、RC 等)。": "Check testing releases only (Preview, Beta, RC, etc.).",
|
||||
"检查正式版和测试版(Preview、Beta、RC 等),选择最新版本。": "Check stable and testing releases (Preview, Beta, RC, etc.) and select the newest version.",
|
||||
"所选渠道暂无可用发行版本。": "No release is available in the selected channel.",
|
||||
"所选版本早于当前版本,安装将切换到较旧版本。": "The selected release is older than your current version. Installing will switch to that older version.",
|
||||
"通知": "Notifications",
|
||||
|
||||
@@ -419,7 +419,7 @@
|
||||
"正式版": "正式版",
|
||||
"测试版": "测试版",
|
||||
"仅检查正式版。": "仅检查正式版。",
|
||||
"仅检查测试版(Preview、Beta、RC 等)。": "仅检查测试版(Preview、Beta、RC 等)。",
|
||||
"检查正式版和测试版(Preview、Beta、RC 等),选择最新版本。": "检查正式版和测试版(Preview、Beta、RC 等),选择最新版本。",
|
||||
"所选渠道暂无可用发行版本。": "所选渠道暂无可用发行版本。",
|
||||
"所选版本早于当前版本,安装将切换到较旧版本。": "所选版本早于当前版本,安装将切换到较旧版本。",
|
||||
"通知": "通知",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import {t} from './i18n';
|
||||
import {installKeyboard} from '../../sdk/src/keyboard';
|
||||
import {installHoverFeedback} from '../../sdk/src/hover';
|
||||
export async function api<T=any>(method:string,params:unknown={},signal?:AbortSignal):Promise<T>{const r=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method,params}),signal});if(r.status===401&&r.headers.get('X-Framely-Reauthenticate')==='1')location.replace('/manager');if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));return v.result;}
|
||||
export function viewKey(){const p=location.pathname.split('/');return p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='cast'&&p[2]==='window'?`framely.cast.${p[3]}`:p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';}
|
||||
export function viewKey(){const p=location.pathname.split('/');return p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='cast'&&p[2]==='window'?`framely.cast.${p[3]}`:p[1]==='apk'&&p[2]==='permission'?'framely.apk.permission':p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';}
|
||||
installKeyboard(params=>api('host.keyboard',{view:viewKey(),...params}));
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
html:has(.apk-permission-view),body:has(.apk-permission-view),#root:has(.apk-permission-view){margin:0;width:100%;height:100%;background:transparent}
|
||||
.apk-permission-view{position:relative;box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;overflow:hidden;border:1px solid #35383d;border-radius:18px;background:#111316;color:#f3f4f6;font-family:system-ui,sans-serif;box-shadow:0 18px 60px #0008}
|
||||
.apk-permission-view header{display:flex;align-items:center;gap:14px;padding:19px 26px 13px}
|
||||
.apk-permission-mark{width:9px;height:9px;border-radius:50%;background:#87c8ff;box-shadow:0 0 16px #87c8ff88}
|
||||
.apk-permission-view header div{display:flex;align-items:baseline;gap:13px}
|
||||
.apk-permission-view header strong{font-size:17px;font-weight:650;letter-spacing:.01em}
|
||||
.apk-permission-view header small{font-size:14px;color:#aeb5be}
|
||||
.apk-permission-display{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:0 22px;background:#08090b}
|
||||
.apk-permission-display img{display:block;max-width:100%;max-height:100%;width:auto;height:auto;user-select:none}
|
||||
.apk-permission-display p{color:#aeb5be}
|
||||
.apk-permission-view footer{height:42px;box-sizing:border-box;padding:10px 26px;color:#aeb5be;font-size:13px}
|
||||
.apk-permission-view footer [role=alert]{color:#ffaaa5}
|
||||
.apk-permission-pointer{position:absolute;z-index:5;width:12px;height:12px;border:2px solid #fff;border-radius:50%;background:#81c8ff;box-shadow:0 0 0 5px #08131e99,0 0 16px #81c8ff;transform:translate(-50%,-50%);pointer-events:none}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {api} from './api';
|
||||
import {t} from './i18n';
|
||||
import './apk-permission.css';
|
||||
|
||||
type Pending={id:string;appName:string;package:string};
|
||||
type Frame={id:string;appName:string;package:string;image:string;left:number;top:number;width:number;height:number};
|
||||
|
||||
export function ApkPermission(){
|
||||
const [frame,setFrame]=useState<Frame|null>(null);
|
||||
const [error,setError]=useState('');
|
||||
const [busy,setBusy]=useState(false);
|
||||
const [pointer,setPointer]=useState<{x:number;y:number}|null>(null);
|
||||
const active=useRef(true);
|
||||
const loading=useRef(false);
|
||||
const choosing=useRef(false);
|
||||
async function refresh(){
|
||||
if(loading.current||choosing.current)return;
|
||||
loading.current=true;
|
||||
try{
|
||||
const pending=await api<Pending|null>('apk.permission.pending');
|
||||
if(!pending){if(active.current)setFrame(null);return;}
|
||||
const next=await api<Frame>('apk.permission.frame',{id:pending.id});
|
||||
if(active.current){setFrame(next);setError('');}
|
||||
}catch(e){if(active.current)setError(String(e));}
|
||||
finally{loading.current=false;}
|
||||
}
|
||||
useEffect(()=>{
|
||||
active.current=true;
|
||||
void refresh();
|
||||
const timer=window.setInterval(()=>void refresh(),2000);
|
||||
return()=>{active.current=false;window.clearInterval(timer);};
|
||||
},[]);
|
||||
async function choose(event:React.MouseEvent<HTMLImageElement>){
|
||||
if(event.button!==0||!frame||choosing.current)return;
|
||||
event.preventDefault();
|
||||
const box=event.currentTarget.getBoundingClientRect();
|
||||
const x=Math.floor(frame.left+(event.clientX-box.left)/box.width*frame.width);
|
||||
const y=Math.floor(frame.top+(event.clientY-box.top)/box.height*frame.height);
|
||||
choosing.current=true;setBusy(true);setError('');
|
||||
try{await api('apk.permission.click',{id:frame.id,x,y});setFrame(null);}
|
||||
catch(e){setError(String(e));}
|
||||
finally{choosing.current=false;setBusy(false);window.setTimeout(()=>void refresh(),350);}
|
||||
}
|
||||
return <main className="apk-permission-view" onMouseMove={event=>{const box=event.currentTarget.getBoundingClientRect();setPointer({x:event.clientX-box.left,y:event.clientY-box.top});}} onMouseLeave={()=>setPointer(null)}>
|
||||
<header><span className="apk-permission-mark" aria-hidden="true"/><div><strong>{t('安卓权限请求')}</strong><small>{frame?.appName??t('正在读取权限请求')}</small></div></header>
|
||||
<section className="apk-permission-display" aria-label={t('安卓权限选择')}>
|
||||
{frame?<img src={frame.image} alt={t('安卓原生权限对话框')} onMouseDown={choose} draggable={false} style={{cursor:busy?'wait':'pointer'}}/>:<p>{t('正在等待安卓权限画面…')}</p>}
|
||||
</section>
|
||||
<footer>{error?<span role="alert">{error}</span>:<span>{t('使用手柄激光选择原生权限选项')}</span>}</footer>
|
||||
{pointer&&<span className="apk-permission-pointer" style={{left:pointer.x,top:pointer.y}} aria-hidden="true"/>}
|
||||
</main>;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {TerminalManager} from './terminal';
|
||||
import {FrameFilePicker} from './file-picker';
|
||||
import {ManagerSwitcher,managerTab,sectionFor,type ManagerSection} from './manager-switcher';
|
||||
import {ApkManager} from './apk-manager';
|
||||
import {ApkPermission} from './apk-permission';
|
||||
import {Switch,SwitchRow} from './switch';
|
||||
import {PluginFrame,pluginLanguagePreference,setPluginLanguagePreference} from './plugin-frame';
|
||||
import {Launcher,LauncherSettings,type LauncherConfig} from './launcher';
|
||||
@@ -37,6 +38,7 @@ const managerMode=location.pathname==='/manager';
|
||||
const empty:Status={version:'0.4.0',database:{plugins:{},sources:[],safeMode:false},notifications:[],running:[]};
|
||||
|
||||
function App(){
|
||||
if(location.pathname==='/apk/permission')return <ApkPermission/>;
|
||||
if(location.pathname==='/cast/watch')return <CastViewer/>;
|
||||
if(location.pathname.startsWith('/cast/window/'))return <CastPlayer/>;
|
||||
const mobile=useMobileManager();useMobileViewport(managerMode&&mobile);
|
||||
|
||||
+8
-1
@@ -24,6 +24,13 @@ export function TerminalManager(){
|
||||
useEffect(()=>{void refresh();const timer=setInterval(()=>void refresh(),3000);return()=>clearInterval(timer);},[]);
|
||||
async function create(){if(busy)return;setBusy(true);try{const s=await managerApi<Session>('terminal',{operation:'create',name:t('终端')});setSelected(s.id);setName(s.name);await refresh();term.current?.focus();}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
useEffect(()=>{if(!host.current||!selected)return;const terminal=new Terminal({fontSize:matchMedia('(max-width: 767px)').matches?13:16,scrollback:10000,allowTransparency:true,cursorBlink:!matchMedia('(prefers-reduced-motion: reduce)').matches,disableStdin:true,theme:{background:'#00000000',foreground:'#e8e8ed',selectionBackground:'#555555'}}),fitter=new FitAddon();terminal.loadAddon(fitter);terminal.open(host.current);term.current=terminal;fit.current=fitter;terminal.parser.registerOscHandler(52,()=>true);
|
||||
const keyboardInput=(event:Event)=>{
|
||||
event.preventDefault();
|
||||
if(terminal.options.disableStdin)return;
|
||||
const edit=(event as CustomEvent<string>).detail;
|
||||
terminal.input(edit==='\b'?'\x7f':edit==='\n'?'\r':edit,true);
|
||||
};
|
||||
terminal.textarea?.addEventListener('framely.keyboard.edit',keyboardInput);
|
||||
terminal.attachCustomKeyEventHandler(event=>{
|
||||
if(event.type!=='keydown'||!event.ctrlKey||!event.shiftKey||event.altKey||event.metaKey)return true;
|
||||
if(event.code==='KeyV')return false; // Native paste supplies the clipboard to xterm; do not emit Ctrl-V.
|
||||
@@ -44,7 +51,7 @@ export function TerminalManager(){
|
||||
ws.onmessage=e=>{if(!alive||socket.current!==ws)return;lastSeen=performance.now();try{const v=JSON.parse(e.data);setWritable(v.writable);terminal.options.disableStdin=!v.writable||v.exited;setExited(v.exited);if(v.truncated)setTruncated(true);if(v.data){const raw=atob(v.data);terminal.write(Uint8Array.from(raw,c=>c.charCodeAt(0)));}}catch(err){setError(String(err));}};
|
||||
ws.onclose=()=>{if(!alive||socket.current!==ws)return;if(timer)clearInterval(timer);setConnected(false);setWritable(false);terminal.options.disableStdin=true;retry=setTimeout(connect,2000);};ws.onerror=()=>setError(t('终端连接中断,正在重连。'));
|
||||
}
|
||||
const offline=()=>{if(localPanel)return;terminal.options.disableStdin=true;setConnected(false);setWritable(false);socket.current?.close();};window.addEventListener('offline',offline);window.addEventListener('online',connect);reconnect.current=connect;connect();return()=>{window.removeEventListener('offline',offline);window.removeEventListener('online',connect);alive=false;if(timer)clearInterval(timer);if(retry)clearTimeout(retry);observer.disconnect();input.dispose();socket.current?.close();socket.current=null;terminal.dispose();term.current=null;};
|
||||
const offline=()=>{if(localPanel)return;terminal.options.disableStdin=true;setConnected(false);setWritable(false);socket.current?.close();};window.addEventListener('offline',offline);window.addEventListener('online',connect);reconnect.current=connect;connect();return()=>{window.removeEventListener('offline',offline);window.removeEventListener('online',connect);terminal.textarea?.removeEventListener('framely.keyboard.edit',keyboardInput);alive=false;if(timer)clearInterval(timer);if(retry)clearTimeout(retry);observer.disconnect();input.dispose();socket.current?.close();socket.current=null;terminal.dispose();term.current=null;};
|
||||
},[selected]);
|
||||
const current=sessions.find(session=>session.id===selected);
|
||||
async function rename(){if(!name.trim()||busy)return;setBusy(true);try{await managerApi('terminal',{operation:'rename',id:selected,name:name.trim()});await refresh();setRenaming(false);}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
|
||||
<p className="sub">{t('安装更新会保留插件和数据,Framely 界面会暂时关闭并重新启动。')}</p>
|
||||
{!source&&<p className="banner">{t('此版本尚未配置更新服务,请通过发行安装包更新。')}</p>}
|
||||
<Select label={t('更新渠道')} value={channel} disabled={busy||installing||!source} onChange={value=>void switchChannel(value)} options={[{value:'stable',label:t('正式版')},{value:'testing',label:t('测试版')}]}/>
|
||||
<p className="sub">{channel==='stable'?t('仅检查正式版。'):t('仅检查测试版(Preview、Beta、RC 等)。')}</p>
|
||||
<p className="sub">{channel==='stable'?t('仅检查正式版。'):t('检查正式版和测试版(Preview、Beta、RC 等),选择最新版本。')}</p>
|
||||
<div className="row">
|
||||
<button className="primary" disabled={busy||installing||!source} onClick={()=>{reset();void run('system.check.start',{},v=>{setRelease(v.release?{...v.release,targetVersion:v.version}:null);setReady(false);setChecked(true);setShowRelease(!!v.release&&!sameSystemVersion(v.release.version,status.build??status.version));});}}>{busy?t('处理中…'):t('检查更新')}</button>
|
||||
<button disabled={busy||installing||!status.previousRelease} onClick={()=>setConfirm('rollback')}>{t('回滚上一版本')}</button>
|
||||
|
||||
Reference in new issue
Block a user