From 3039c9ef0e25f70c9b9aaff5a95994933b0b8fc2 Mon Sep 17 00:00:00 2001 From: toorux Date: Mon, 12 Oct 2026 01:40:22 +0800 Subject: [PATCH] Improve Quest permission UI and pending interface fixes --- .gitignore | 3 + docs/user-guide/installation.md | 2 +- docs/zh-CN/user-guide/installation.md | 2 +- native/host.cpp | 77 ++++++++++++-- sdk/src/keyboard.ts | 3 + src/apk.rs | 1 + src/apk/permissions.rs | 138 ++++++++++++++++++++++++++ src/session.rs | 42 ++++++++ src/update.rs | 48 +++++---- tests/keyboard_fixture.tsx | 7 ++ tools/package-release.sh | 9 +- ui/locales/en-US.json | 2 +- ui/locales/zh-CN.json | 2 +- ui/src/api.ts | 2 +- ui/src/apk-permission.css | 13 +++ ui/src/apk-permission.tsx | 53 ++++++++++ ui/src/main.tsx | 2 + ui/src/terminal.tsx | 9 +- ui/src/updates.tsx | 2 +- 19 files changed, 384 insertions(+), 33 deletions(-) create mode 100644 src/apk/permissions.rs create mode 100644 ui/src/apk-permission.css create mode 100644 ui/src/apk-permission.tsx diff --git a/.gitignore b/.gitignore index 046fbdb..95cc381 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ /media/bin/ /media/source/ + +# Local agent operating notes +/AGENTS.md diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index b454f40..14d532b 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.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 diff --git a/docs/zh-CN/user-guide/installation.md b/docs/zh-CN/user-guide/installation.md index 9c114a1..330b863 100644 --- a/docs/zh-CN/user-guide/installation.md +++ b/docs/zh-CN/user-guide/installation.md @@ -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 diff --git a/native/host.cpp b/native/host.cpp index 7ad8702..38875f6 100644 --- a/native/host.cpp +++ b/native/host.cpp @@ -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 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 pixels; }; static std::map> 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 client; static Handler 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 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 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();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 "<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 commands;{std::lock_guard 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());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){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 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();auto key="framely.cast."+id;if(views.count(key)&&views.at(key)->closing){std::lock_guard 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();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();if(views.count(key))close_view(*views.at(key));}else if(kind=="window.open"){hide_launcher(false);std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get()+"."+c.at("window").get();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){bool requested=c.value("visible",false);if(!requested){if(views.count("notifications")){auto& v=*views.at("notifications");v.notification_requested=false;v.notification_content=false;show(v,false);}continue;}auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);v.notification_requested=true;anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");keyboard_state(keyboard_open);if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_requested=false;views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "< commands;{std::lock_guard lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="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());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){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 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 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();auto key="framely.cast."+id;if(views.count(key)&&views.at(key)->closing){std::lock_guard 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();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();if(views.count(key))close_view(*views.at(key));}else if(kind=="window.open"){hide_launcher(false);std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get()+"."+c.at("window").get();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){bool requested=c.value("visible",false);if(!requested){if(views.count("notifications")){auto& v=*views.at("notifications");v.notification_requested=false;v.notification_content=false;show(v,false);}continue;}auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);v.notification_requested=true;anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");keyboard_state(keyboard_open);if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_requested=false;views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "<IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&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"<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+1GetOverlayMouseScale(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){ diff --git a/sdk/src/keyboard.ts b/sdk/src/keyboard.ts index 3e807e8..80eadb3 100644 --- a/sdk/src/keyboard.ts +++ b/sdk/src/keyboard.ts @@ -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}[]; diff --git a/src/apk.rs b/src/apk.rs index 16eb755..a5abbb6 100644 --- a/src/apk.rs +++ b/src/apk.rs @@ -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; diff --git a/src/apk/permissions.rs b/src/apk/permissions.rs new file mode 100644 index 0000000..5d7a465 --- /dev/null +++ b/src/apk/permissions.rs @@ -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 { + 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> { + 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 { + 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> { + 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 { + 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 { + 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)) +} diff --git a/src/session.rs b/src/session.rs index 6ec601e..2fa6cd5 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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() { diff --git a/src/update.rs b/src/update.rs index c11f223..37610ed 100644 --- a/src/update.rs +++ b/src/update.rs @@ -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] diff --git a/tests/keyboard_fixture.tsx b/tests/keyboard_fixture.tsx index 61b8ebd..dc42fd0 100644 --- a/tests/keyboard_fixture.tsx +++ b/tests/keyboard_fixture.tsx @@ -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).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);}})(); diff --git a/tools/package-release.sh b/tools/package-release.sh index e1c49c8..8398823 100755 --- a/tools/package-release.sh +++ b/tools/package-release.sh @@ -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)" diff --git a/ui/locales/en-US.json b/ui/locales/en-US.json index 2a1e9c9..9d0710e 100644 --- a/ui/locales/en-US.json +++ b/ui/locales/en-US.json @@ -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", diff --git a/ui/locales/zh-CN.json b/ui/locales/zh-CN.json index ef05387..24f5747 100644 --- a/ui/locales/zh-CN.json +++ b/ui/locales/zh-CN.json @@ -419,7 +419,7 @@ "正式版": "正式版", "测试版": "测试版", "仅检查正式版。": "仅检查正式版。", - "仅检查测试版(Preview、Beta、RC 等)。": "仅检查测试版(Preview、Beta、RC 等)。", + "检查正式版和测试版(Preview、Beta、RC 等),选择最新版本。": "检查正式版和测试版(Preview、Beta、RC 等),选择最新版本。", "所选渠道暂无可用发行版本。": "所选渠道暂无可用发行版本。", "所选版本早于当前版本,安装将切换到较旧版本。": "所选版本早于当前版本,安装将切换到较旧版本。", "通知": "通知", diff --git a/ui/src/api.ts b/ui/src/api.ts index 3429bb8..b3efbbb 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -2,7 +2,7 @@ import {t} from './i18n'; import {installKeyboard} from '../../sdk/src/keyboard'; import {installHoverFeedback} from '../../sdk/src/hover'; export async function api(method:string,params:unknown={},signal?:AbortSignal):Promise{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})); diff --git a/ui/src/apk-permission.css b/ui/src/apk-permission.css new file mode 100644 index 0000000..9772402 --- /dev/null +++ b/ui/src/apk-permission.css @@ -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} diff --git a/ui/src/apk-permission.tsx b/ui/src/apk-permission.tsx new file mode 100644 index 0000000..1258970 --- /dev/null +++ b/ui/src/apk-permission.tsx @@ -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(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('apk.permission.pending'); + if(!pending){if(active.current)setFrame(null);return;} + const next=await api('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){ + 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
{const box=event.currentTarget.getBoundingClientRect();setPointer({x:event.clientX-box.left,y:event.clientY-box.top});}} onMouseLeave={()=>setPointer(null)}> +
+
+ {frame?{t('安卓原生权限对话框')}:

{t('正在等待安卓权限画面…')}

} +
+
{error?{error}:{t('使用手柄激光选择原生权限选项')}}
+ {pointer&&
; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index beebfaf..1cd6178 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -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 ; if(location.pathname==='/cast/watch')return ; if(location.pathname.startsWith('/cast/window/'))return ; const mobile=useMobileManager();useMobileViewport(managerMode&&mobile); diff --git a/ui/src/terminal.tsx b/ui/src/terminal.tsx index a092040..effffa3 100644 --- a/ui/src/terminal.tsx +++ b/ui/src/terminal.tsx @@ -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('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).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);}} diff --git a/ui/src/updates.tsx b/ui/src/updates.tsx index e744fd3..d997df3 100644 --- a/ui/src/updates.tsx +++ b/ui/src/updates.tsx @@ -78,7 +78,7 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise{t('安装更新会保留插件和数据,Framely 界面会暂时关闭并重新启动。')}

{!source&&

{t('此版本尚未配置更新服务,请通过发行安装包更新。')}

}