feat: add gaze calibration and refine launcher appearance and APK startup

This commit is contained in:
toorux committed 2026-10-06 17:21:55 +08:00
1 parent f102d78804
commit 88261cd5ae
33 files changed
+1225 -113

No files matched your search

+18
View File
@@ -31,3 +31,21 @@ Search accepts original names, English word initials, Chinese pinyin and Japanes
Indexes are built asynchronously and cached on the device. Chinese uses offline pinyin data; Japanese uses the bundled Vibrato / IPADic reading dictionary. Han names receive both Chinese and Japanese candidates regardless of interface language. Unknown dictionary readings retain their original text. Original-name search remains available before phonetic indexing completes. No app names are sent to an external search service.
Session API: `launcher.search.index` accepts `{names: string[]}` and returns a name-keyed map of `{terms: string[]}`. `launcher.favorite` accepts `{key, favorite}` with the same stable keys as launcher ordering. Plugin and Steam keys update their existing favorites; `framely`, `desktop:` and local `lepton:` keys use `launcherFavorites`. `launcher.settings.save` now includes `allCategories` (`plugin`, `steam`, `lepton`, `desktop`); omitted fields migrate to all four categories, while an explicit empty array means none. Search dictionaries and notices are included in both core updates and offline packages under `share/search`.
## Gaze and controller confirmation
When the laser is not pointing at the launcher, valid eye tracking can select an icon, category or action button. A target becomes highlighted after 120 ms of stable gaze with a light controller pulse. Fixation jitter is lightly filtered, nearby icon gaps accept the nearest icon, and short tracking dropouts retain hover for up to 280 ms. A dropout during a press still cancels that press. Pointing the laser at the launcher restores laser interaction immediately; Steam window positioning controls and the VR keyboard pause gaze input. This uses launcher laser focus as the fallback rule, rather than assuming Steam’s private per-hand gamepad mode flags are available through OpenVR.
Use the left controller’s lower D-pad button (the position requested as “A”), right A, or either primary trigger to confirm. Short press opens the selected item; holding an icon for 400 ms opens its menu and 800 ms enters arrangement mode. The target is locked until release, so moving your eyes does not drag or switch the pressed icon. Arrange icons with the laser and joystick afterwards. Losing eye tracking or disconnecting the confirming controller cancels the press; buttons must be released before another press can begin. Unsupported or inactive tracking does not fall back to head direction.
### Gaze calibration
In the Frame management panel, open **Launcher → Eye tracking calibration → Start calibration**. You can also use the eye calibration button above Settings in the launcher’s right rail. A simulated launcher uses the same Dock surface, icon layout, search capsule and side rails. Start the guided sequence, then look naturally at the highlighted target; no confirmation press is needed. It samples 13 icons, six category controls, four shortcuts and search, then repeats five targets to validate the correction. Allow about one minute with uninterrupted tracking. Tracking loss pauses collection; excessive jitter asks you to retry. B or Cancel exits without saving.
You can turn or move your head to see peripheral targets. Each frame pairs the measured gaze with the direction from the current eye origin to the highlighted target, using the head pose at the same nominal sampling time. The target point is located using Steam's curved-overlay intersection, and is checked again as the head or window moves. Head movement itself is not treated as fixation jitter.
Paired directions are averaged in short time slices before fitting; each target has equal weight, and robust reweighting limits the influence of mis-fixated targets. Validation measures the median residual direction for each fixation, preserving per-frame head coordinates. A single spike cannot invalidate calibration.
The result shows RMS target bias and the worst target bias in degrees. Each target also shows its 95th percentile frame error and uncorrected median error, separating systematic bias from sensor noise. Calibration summaries and paired training blocks are recorded in the session log for diagnosis. **Save calibration** persists a correction of head-relative yaw and pitch. At runtime, the corrected direction is rotated into world space with the current headset pose, preserving the eye origin before intersecting the Dock surface. It does not change laser input or the system tracker. Validation compares against highlighted target directions, not against native gaze as a reference. Failed validation cannot overwrite the previous calibration. Old viewport-coordinate calibrations are ignored; run calibration again after this update. The correction handles systematic angular bias and gain; nonlinear tracking errors may remain. OpenVR does not expose the eye camera's exact exposure timestamp, so fast motion may still have timing error.
After substantial changes to headset fit or the window distance, run calibration again.
+2
View File
@@ -36,6 +36,8 @@ Once a modifying operation starts, cancellation is disabled until its actual out
The launcher uses declared MAIN/LAUNCHER, television launcher and recognized VR entries, plus the running Android package manager's resolved launcher entry. Disabled packages/components are excluded. A launch entry does not guarantee that an app will run successfully.
Launching an APK boots its existing container in the background and reveals the target application after ActivityManager reports success. Framely keeps Lepton’s development-context data handling instead of switching an existing container to APK bake mode. A temporary launch adapter uses the installed Lepton libraries without editing Steam’s installation; an unsupported entry script reports an error before starting a cold container. Container maintenance commands retain their normal behavior.
Window handling is automatic by default. **Advanced settings → Show Android window** is a troubleshooting override, applied on container restart. It does not convert a flat application into VR or a VR application into a flat application. Lepton development contexts may mount their APK at a different path after a restart. Framely re-registers the same signed APK when Android can no longer resolve it, preserving data and reporting failures. Advanced settings also allow selecting an enabled, declared Activity when a nonstandard app needs a different entry.
Launcher favorites, search and manual ordering continue to use stable context/package identities. Updating and refreshing do not reorder icons. Uninstalling hides an icon without deleting its favorite/order record; reinstalling in the same context restores it. Long-press a local Lepton icon to open APK management or removal confirmation.
+18
View File
@@ -33,3 +33,21 @@ Steam 启动成功表示请求已交给 Steam,并不代表游戏加载完成
会话接口:`launcher.search.index` 接收 `{names: string[]}`,返回名称对应的 `{terms: string[]}`。`launcher.favorite` 接收 `{key, favorite}`,使用与图标排列相同的稳定标识。插件和 Steam 仍更新原有收藏,`framely`、`desktop:` 和本地 `lepton:` 标识保存于 `launcherFavorites`。`launcher.settings.save` 新增 `allCategories`(`plugin`、`steam`、`lepton`、`desktop`);旧配置省略字段时默认全选,显式空数组表示全部不选。词典和许可说明位于发布包 `share/search`,同时包含于本体更新包和完整离线包。
已收藏应用的右上角显示常驻的蓝色实心星标,再次点击即可取消收藏;收藏状态变化不会改变图标排序。
## 眼追与手柄确认
激光未指向启动台时,有效眼追可以选中图标、分类和操作按钮。稳定注视 120 毫秒后高亮目标,并轻振手柄;注视坐标经过轻度滤波,图标边缘附近也可命中最近图标;小幅抖动不会立即切换焦点,短暂丢帧最多保留高亮 280 毫秒。按住期间追踪失效仍会取消按下。激光指向启动台后立即恢复激光交互,Steam 的窗口移动控件及 VR 键盘会暂停眼追。这里使用“激光未指向启动台”的切换规则,不假定公开 OpenVR 能读取 Steam 私有的左右手柄模式标志。
使用左手柄下方方向键(需求中称为“A”的位置)、右手柄 A 或任意手柄的主扳机确认。短按打开选中项,图标长按 400 毫秒打开菜单,800 毫秒进入排列模式。按下后锁定目标,眼睛移动不会拖动图标或切换正在按住的目标;随后使用激光和摇杆排列。眼追失效或确认手柄断连会取消当前按下,必须松开按键才接受下一次操作。追踪不可用时不会用头部朝向替代眼追。
### 眼追校准
在 Frame 内的管理面板打开“启动台 → 眼追校准 → 开始校准”。也可点击启动台右侧设置按钮上方的眼追校准入口。模拟界面沿用同一个 Dock 窗口、图标排列、搜索框和两侧胶囊。开始后自然注视依次高亮的目标,不需要按键确认。采集 13 个图标、6 个分类按钮、4 个快捷按钮和搜索框,再重复采集 5 个位置验证修正效果。连续追踪时约需一分钟;追踪失效会暂停采集,抖动过大会提示重试。B 或取消退出,不保存结果。
可以转头或移动头部来看清边缘目标。每帧都结合当时的头显姿态,把测得的注视方向与“当前眼睛起点到高亮目标”的方向配对。目标位置通过 Steam 实际曲面命中反求,并在头部或窗口移动后重新检查;头部移动本身不算注视抖动。
拟合前对短时间片内的成对方向一起平均,每个目标权重相同,并通过稳健重加权减小采错目标的影响。验证使用每次注视的方向残差中位数,始终保留逐帧头部坐标;单个尖峰帧不会否决整次校准。
结果显示各目标偏差的均方根和最差目标偏差,单位为度。各目标还显示 95% 帧误差以及未修正时的中位误差,区分持续偏差和追踪噪声。校准摘要与训练时间片数据会写入会话日志,便于定位问题。“保存校准”持久保存头显坐标系内偏航、俯仰角的修正。运行时使用当前头显姿态把修正方向转换到空间坐标,保留眼睛起点,再与 Dock 曲面求交。不会改变激光输入或系统眼追。误差参照高亮目标方向,不以原生眼追作为正确答案。验证失败不会覆盖原有校准;旧版窗口坐标校准不再应用,更新后请重新校准。此修正处理稳定角度偏移与增益,非线性追踪误差仍可能存在。OpenVR 未公开眼睛摄像头的准确曝光时间,快速移动时仍可能有时序误差。
明显调整头显佩戴方式或窗口距离后,请重新校准。
+2
View File
@@ -38,6 +38,8 @@ APK 管理用于安装和管理 Steam Frame 上的 Lepton 侧载应用,本地
后台识别清单中的 MAIN/LAUNCHER、电视启动入口和已知 VR 入口,并结合运行中的 Android 包管理器解析结果。停用的包与组件排除。有入口不代表一定能正常运行。
启动 APK 时,先在后台启动原有容器,ActivityManager 报告启动成功后再显示目标应用。Framely 保留 Lepton 开发容器的数据处理方式,不把已有容器切换到 APK 烘焙模式。临时启动适配脚本使用设备上已安装的 Lepton 库,不修改 Steam 安装目录;遇到不支持的入口脚本,会在冷启动容器前报错。容器维护操作仍沿用原来的启动行为。
默认自动处理窗口显示。“高级设置 → 显示安卓窗口”用于排查,并在重启容器后生效。它不会把二维应用转成 VR,也不会把 VR 应用转成二维。Lepton 开发容器重启后可能改变 APK 挂载路径;Android 无法识别时,Framely 会重新登记同签名 APK,保留数据并报告错误。非标准应用还可从已启用、已声明的 Activity 中指定启动入口。
启动台继续按稳定的容器与包名保存收藏、搜索和排序。更新或刷新不重排图标;卸载隐藏图标,保留收藏与位置记录,在原容器重新安装后恢复。长按本地 Lepton 图标可进入管理或卸载确认。
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "launcher_gaze.h"
#include <algorithm>
struct GazeAngles {float yaw=0,pitch=0;};
inline bool gaze_head_angles(const vr::HmdMatrix34_t& head,const vr::HmdVector3_t& world,GazeAngles& out){
float local[3]{};for(int i=0;i<3;i++)for(int r=0;r<3;r++){if(!std::isfinite(head.m[r][i])||!std::isfinite(world.v[r]))return false;local[i]+=head.m[r][i]*world.v[r];}
if(local[0]*local[0]+local[1]*local[1]+local[2]*local[2]<1e-8)return false;
out={std::atan2(local[0],-local[2]),std::atan2(local[1],std::hypot(local[0],local[2]))};return true;
}
inline vr::VROverlayIntersectionParams_t gaze_world_ray(const vr::HmdMatrix34_t& head,const vr::HmdVector3_t& origin,GazeAngles a){
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;ray.vSource=origin;
float local[]{std::sin(a.yaw)*std::cos(a.pitch),std::sin(a.pitch),-std::cos(a.yaw)*std::cos(a.pitch)};
for(int r=0;r<3;r++)for(int i=0;i<3;i++)ray.vDirection.v[r]+=head.m[r][i]*local[i];return ray;
}
inline bool gaze_angles_to_point(const vr::HmdMatrix34_t& head,const vr::HmdVector3_t& origin,const vr::HmdVector3_t& target,GazeAngles& angles){
vr::HmdVector3_t direction{};for(int r=0;r<3;r++)direction.v[r]=target.v[r]-origin.v[r];return gaze_head_angles(head,direction,angles);
}
inline GazeAngles gaze_correct(GazeAngles raw,const double (&m)[6]){return {float(m[0]*raw.yaw+m[1]*raw.pitch+m[2]),float(m[3]*raw.yaw+m[4]*raw.pitch+m[5])};}
// Invert the runtime's actual curved-dashboard intersection. Its coordinate
// transform API alone may describe a flat surface. Sampling this inverse is
// needed only when the target or window changes; cache the returned world point.
template<class Sample> bool gaze_target_inverse(GazeAngles seed,float u,float v,Sample sample,GazeAngles& out){
auto current=seed;float x=0,y=0;if(!sample(current,x,y))return false;
for(int n=0;n<18;n++){
float ex=x-u,ey=y-v;if(std::hypot(ex,ey)<.0003f){out=current;return true;}
constexpr float delta=.0005f;float xx,xy,yx,yy;
if(!sample({current.yaw+delta,current.pitch},xx,xy)||!sample({current.yaw,current.pitch+delta},yx,yy))return false;
float a=(xx-x)/delta,b=(yx-x)/delta,c=(xy-y)/delta,d=(yy-y)/delta,det=a*d-b*c;if(!std::isfinite(det)||std::abs(det)<1e-6)return false;
float dx=std::clamp((d*ex-b*ey)/det,-.15f,.15f),dy=std::clamp((-c*ex+a*ey)/det,-.15f,.15f);bool improved=false;
for(float factor=1;factor>=.03125f;factor*=.5f){GazeAngles next{current.yaw-factor*dx,current.pitch-factor*dy};float nx,ny;if(sample(next,nx,ny)&&std::hypot(nx-u,ny-v)<std::hypot(ex,ey)){current=next;x=nx;y=ny;improved=true;break;}}
if(!improved)return false;
}
return false;
}
+96 -10
View File
@@ -12,6 +12,8 @@
#include "launcher_input.h"
#include "launcher_fade.h"
#include "launcher_pointer.h"
#include "launcher_gaze.h"
#include "gaze_geometry.h"
#include "page_scroll.h"
#include "page_scroll_injection.h"
#include "include/capi/cef_load_handler_capi.h"
@@ -67,9 +69,24 @@ static bool launcher_selected=false;
static std::chrono::steady_clock::time_point launcher_keyboard_back_until{};
static std::atomic<bool> launcher_primary{true};
static bool entry_actions_ready=false;
static vr::VRActionSetHandle_t entry_set=0;
static vr::VRActionSetHandle_t entry_set=0,gaze_set=0;
static vr::VRActionHandle_t primary_action=0,upper_action=0,aim_action=0,close_action=0,navigate_action=0;
static vr::VRInputValueHandle_t hands[2]{};
static vr::VRActionHandle_t gaze_action=0,confirm_action=0,gaze_haptic_action=0;
static LauncherGazePress gaze_press;
static bool gaze_active=false,launcher_calibrating=false;
static json gaze_correction=nullptr,gaze_preview=nullptr;
static std::string gaze_target_id;
static float gaze_target_u=.5f,gaze_target_v=.5f;
static vr::HmdVector3_t gaze_target_world{};
static bool gaze_target_cached=false;
static bool gaze_matrix(const json& config,double (&m)[6]){
if(!config.is_object()||config.value("space",std::string{})!="headAngles"||!config.contains("matrix")||!config["matrix"].is_array()||config["matrix"].size()!=6)return false;
for(int i=0;i<6;i++){if(!config["matrix"][i].is_number())return false;m[i]=config["matrix"][i].get<double>();if(!std::isfinite(m[i])||std::abs(m[i])>4)return false;}
double det=m[0]*m[4]-m[1]*m[3];return det>=.25&&det<=4&&std::abs(m[2])<=.5&&std::abs(m[5])<=.5;
}
static int gaze_feedback_hand=1;
static std::chrono::steady_clock::time_point gaze_sent{},gaze_feedback_at{};
static EntryTrigger entry_buttons[2][2];
static EntryAnalogTrigger entry_analog[2];
static EntryPointer entry_pointer;
@@ -271,7 +288,8 @@ static bool launcher_dock_interaction(){
}
static void hide_launcher(){
if(keyboard_view=="launcher")close_keyboard();
const bool was_open=launcher_open;launcher_open=false;launcher_selected=false;launcher_dock.clear();launcher_stick={};
if(launcher_calibrating&&views.count("launcher")){auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);execute(frame,"location.hash='';window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}
const bool was_open=launcher_open;launcher_calibrating=false;launcher_open=false;launcher_selected=false;launcher_dock.clear();launcher_stick={};
if(was_open&&views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay)){
auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);
execute(frame,"window.dispatchEvent(new Event('framely.launcher.closing'))");frame->base.release(&frame->base);
@@ -312,6 +330,7 @@ static void setup_entry_actions(){
auto error=input->SetActionManifestPath(std::filesystem::absolute(manifest).lexically_normal().c_str());if(error!=vr::VRInputError_None){std::cerr<<"Action manifest failed: "<<error<<std::endl;return;}
entry_actions_ready=input->GetActionSetHandle("/actions/entry",&entry_set)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/primary",&primary_action)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/upper",&upper_action)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/aim",&aim_action)==vr::VRInputError_None;
input->GetActionHandle("/actions/entry/in/close",&close_action);input->GetActionHandle("/actions/entry/in/navigate",&navigate_action);
input->GetActionSetHandle("/actions/gaze",&gaze_set);input->GetActionHandle("/actions/gaze/in/eye",&gaze_action);input->GetActionHandle("/actions/entry/in/confirm",&confirm_action);input->GetActionHandle("/actions/entry/out/gazeHaptic",&gaze_haptic_action);
input->GetInputSourceHandle("/user/hand/left",&hands[0]);input->GetInputSourceHandle("/user/hand/right",&hands[1]);
std::cout<<"Framely independent trigger bindings "<<(entry_actions_ready?"ready":"unavailable")<<std::endl;
}
@@ -327,10 +346,65 @@ static void page_scroll(float x,float y,bool active,vr::TrackedDeviceIndex_t dev
auto delta=page_scroll_stick.update(x,y,target->stick_scrolling,now);
if(delta.x||delta.y){auto mouse=target->pointer;mouse.modifiers=0;target->host->send_mouse_wheel_event(target->host,&mouse,delta.x,delta.y);}
}
static bool launcher_gaze_allowed(){
if(!launcher_open||keyboard_open||!views.count("launcher")||launcher_dock_interaction())return false;
auto& v=*views.at("launcher");return !v.closing&&overlays->IsActiveDashboardOverlay(v.overlay)&&overlays->IsOverlayVisible(v.overlay)&&(launcher_calibrating||(!overlays->IsHoverTargetOverlay(v.overlay)&&!v.launcher_pointer.active));
}
static void launcher_gaze_event(const json& sample){
if(!views.count("launcher"))return;auto& v=*views.at("launcher");if(v.closing||!v.browser)return;
auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.launcher.gaze',{detail:"+sample.dump()+"}))");frame->base.release(&frame->base);
}
static void launcher_gaze(vr::IVRInput* input,bool allowed,const bool (&connected)[2],const bool (&buttons)[2],std::chrono::steady_clock::time_point now){
allowed=allowed&&launcher_gaze_allowed();
vr::VREyeTrackingData_t eye{};vr::VROverlayIntersectionParams_t ray{};vr::VROverlayIntersectionResults_t hit{};
auto sampled_at=std::chrono::steady_clock::now();
bool tracked=allowed&&gaze_action&&input->GetEyeTrackingDataRelativeToNow(gaze_action,vr::TrackingUniverseStanding,0,&eye,sizeof(eye))==vr::VRInputError_None&&launcher_gaze_ray(eye,ray);
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
// Use the same nominal time for gaze and head pose, rather than pairing gaze
// with a later/predicted rendering pose while the user turns their head.
if(tracked)vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,std::chrono::duration<float>(sampled_at-std::chrono::steady_clock::now()).count(),poses,vr::k_unMaxTrackedDeviceCount);
GazeAngles raw{},expected{};tracked=tracked&&poses[0].bPoseIsValid&&gaze_head_angles(poses[0].mDeviceToAbsoluteTracking,ray.vDirection,raw);
bool target_valid=false;
if(tracked&&launcher_calibrating&&!gaze_target_id.empty()){
auto& view=*views.at("launcher");auto& head=poses[0].mDeviceToAbsoluteTracking;
auto sample=[&](GazeAngles angles,float& u,float& v){auto candidate=gaze_world_ray(head,ray.vSource,angles);vr::VROverlayIntersectionResults_t result{};bool valid=overlays->ComputeOverlayIntersection(view.overlay,&candidate,&result);u=result.vUVs.v[0];v=result.vUVs.v[1];return valid&&std::isfinite(u)&&std::isfinite(v);};
if(gaze_target_cached&&gaze_angles_to_point(head,ray.vSource,gaze_target_world,expected)){float u,v;target_valid=sample(expected,u,v)&&std::hypot(u-gaze_target_u,v-gaze_target_v)<.001f;}
if(!target_valid){
vr::HmdMatrix34_t seed_pose{};GazeAngles seed{};
if(!overlays->GetTransformForOverlayCoordinates(view.overlay,vr::TrackingUniverseStanding,{{gaze_target_u*view.width,gaze_target_v*view.height}},&seed_pose)){
vr::HmdVector3_t seed_point{{seed_pose.m[0][3],seed_pose.m[1][3],seed_pose.m[2][3]}};
bool solved=gaze_angles_to_point(head,ray.vSource,seed_point,seed)&&gaze_target_inverse(seed,gaze_target_u,gaze_target_v,sample,expected);
if(!solved){vr::HmdMatrix34_t center{};if(!overlays->GetTransformForOverlayCoordinates(view.overlay,vr::TrackingUniverseStanding,{{view.width*.5f,view.height*.5f}},&center)){vr::HmdVector3_t point{{center.m[0][3],center.m[1][3],center.m[2][3]}};solved=gaze_angles_to_point(head,ray.vSource,point,seed)&&gaze_target_inverse(seed,gaze_target_u,gaze_target_v,sample,expected);}}
if(solved){
auto candidate=gaze_world_ray(head,ray.vSource,expected);vr::VROverlayIntersectionResults_t result{};
target_valid=overlays->ComputeOverlayIntersection(view.overlay,&candidate,&result);if(target_valid){gaze_target_world=result.vPoint;gaze_target_cached=true;}
}
}
}
}
if(tracked){double matrix[6];if(gaze_matrix(launcher_calibrating?gaze_preview:gaze_correction,matrix))ray=gaze_world_ray(poses[0].mDeviceToAbsoluteTracking,ray.vSource,gaze_correct(raw,matrix));}
bool valid=tracked&&overlays->ComputeOverlayIntersection(views.at("launcher")->overlay,&ray,&hit)&&std::isfinite(hit.vUVs.v[0])&&std::isfinite(hit.vUVs.v[1])&&hit.vUVs.v[0]>=0&&hit.vUVs.v[0]<=1&&hit.vUVs.v[1]>=0&&hit.vUVs.v[1]<=1;
// Looking away during a press keeps the locked target. Tracking loss cancels.
auto edge=gaze_press.update(!launcher_calibrating&&tracked&&(valid||gaze_press.held),connected,buttons);
if(edge==LauncherGazePress::Down)gaze_feedback_hand=gaze_press.owner;
if(edge==LauncherGazePress::Cancel||(!tracked&&gaze_active)){launcher_gaze_event(!allowed||edge==LauncherGazePress::Cancel?json{{"valid",false},{"phase","cancel"}}:json{{"valid",false}});gaze_active=false;return;}
if(!tracked)return;
if(edge==LauncherGazePress::Down||edge==LauncherGazePress::Up){launcher_gaze_event({{"valid",true},{"phase",edge==LauncherGazePress::Down?"down":"up"}});gaze_sent=now;}
else if(now-gaze_sent>=std::chrono::milliseconds(16)){
auto& v=*views.at("launcher");json event=valid?json{{"valid",true},{"x",hit.vUVs.v[0]*v.width},{"y",(1-hit.vUVs.v[1])*v.height}}:json{{"valid",bool(gaze_press.held)}};
if(launcher_calibrating){event["valid"]=tracked&&target_valid;event["targetId"]=gaze_target_id;event["targetUnavailable"]=tracked&&!target_valid;event["rawAngles"]={raw.yaw,raw.pitch};event["targetAngles"]={expected.yaw,expected.pitch};event["headPose"]=json::array();for(int r=0;r<3;r++)for(int c=0;c<4;c++)event["headPose"].push_back(poses[0].mDeviceToAbsoluteTracking.m[r][c]);}
launcher_gaze_event(event);gaze_sent=now;
}
gaze_active=true;
}
static void entry_actions(vr::VROverlayHandle_t button,bool available){
if(!entry_actions_ready)return;auto* input=vr::VRInput();vr::VRActiveActionSet_t set{};set.ulActionSet=entry_set;
if(input->UpdateActionState(&set,sizeof(set),1)!=vr::VRInputError_None)return;
if(!entry_actions_ready)return;auto* input=vr::VRInput();vr::VRActiveActionSet_t sets[2]{};sets[0].ulActionSet=entry_set;sets[1].ulActionSet=gaze_set;
// Only the eye action participates in overlay priority. Controller bindings
// keep their existing priority so Steam laser and Dock controls are preserved.
if(launcher_gaze_allowed())sets[1].nPriority=vr::k_nActionSetOverlayGlobalPriorityMin;
if(input->UpdateActionState(sets,sizeof(sets[0]),gaze_set?2:1)!=vr::VRInputError_None)return;
auto now=std::chrono::steady_clock::now();
const bool gaze_allowed=launcher_gaze_allowed();bool gaze_connected[2]{},gaze_buttons[2]{};
float stick_x=0,stick_y=0;bool stick_active=false;vr::TrackedDeviceIndex_t stick_device=vr::k_unTrackedDeviceIndexInvalid;
for(int hand=0;hand<2;hand++){
vr::InputPoseActionData_t pose{};auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);bool hit=available&&entry_pointer.hit(device);
@@ -345,6 +419,13 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
}
if(pose_ok){auto& m=pose.pose.mDeviceToAbsoluteTracking;vr::VROverlayIntersectionParams_t params{};params.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){params.vSource.v[r]=m.m[r][3];params.vDirection.v[r]=-m.m[r][2];}vr::VROverlayIntersectionResults_t result{};hit=hit||(overlays->IsHoverTargetOverlay(button)&&overlays->ComputeOverlayIntersection(button,&params,&result)&&result.vUVs.v[0]>=float(dock_icon_left)/dock_canvas_width&&result.vUVs.v[0]<=float(dock_icon_left+128)/dock_canvas_width);}
gaze_connected[hand]=device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device);
if(gaze_connected[hand]){vr::InputDigitalActionData_t confirm{};bool active=input->GetDigitalActionData(confirm_action,&confirm,sizeof(confirm),hands[hand])==vr::VRInputError_None&&confirm.bActive;gaze_buttons[hand]=active&&confirm.bState;
if(!active){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};
if(hand==1)gaze_buttons[hand]=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_a",hands[hand],&mode,&state)&&(state.uProperties&vr::VRComponentProperty_IsPressed);
else{vr::VRControllerState_t controller{};gaze_buttons[hand]=vr_system->GetControllerState(device,&controller,sizeof(controller))&&(controller.ulButtonPressed&(vr::ButtonMaskFromId(vr::k_EButton_DPad_Down)|vr::ButtonMaskFromId(vr::k_EButton_A)));}
}
}
for(int kind=0;kind<2;kind++){vr::InputDigitalActionData_t data{};bool active=input->GetDigitalActionData(kind?upper_action:primary_action,&data,sizeof(data),hands[hand])==vr::VRInputError_None&&data.bActive;
bool down=data.bState;if(kind==0&&active)entry_analog[hand].held=down;
// Dashboard focus suppresses action data. Frame's render-model components
@@ -368,9 +449,10 @@ 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;
bool was_held=entry_buttons[hand][kind].held;
if(entry_buttons[hand][kind].update(active,down,hit,now))toggle_entry(kind==0?launcher_primary.load():!launcher_primary.load());
else if(kind==0&&active&&down&&!was_held&&launcher_open&&!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);
else 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);
}
if(launcher_open&&hand==1){vr::InputDigitalActionData_t close{};bool active=input->GetDigitalActionData(close_action,&close,sizeof(close),hands[hand])==vr::VRInputError_None&&close.bActive;bool down=close.bState;
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};active=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_b",hands[hand],&mode,&state);down=active&&(state.uProperties&vr::VRComponentProperty_IsPressed);}
@@ -381,14 +463,16 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
// Pick a single controller and a single axis; never emit two directions.
if(active&&(!stick_active||std::max(std::abs(x),std::abs(y))>std::max(std::abs(stick_x),std::abs(stick_y)))){stick_active=true;stick_x=x;stick_y=y;stick_device=device;}
}
launcher_gaze(input,gaze_allowed,gaze_connected,gaze_buttons,now);
if(launcher_open){const bool blocked=keyboard_open||launcher_dock_interaction();int direction=launcher_stick.update(blocked?0:stick_x,blocked?0:stick_y,!blocked&&stick_active);if(direction&&views.count("launcher")){auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:"+std::to_string(direction)+"}))");frame->base.release(&frame->base);}}
else{launcher_stick={};launcher_close_button={};}
page_scroll(stick_x,stick_y,stick_active,stick_device,now);
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=="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=="launcher.close"){hide_launcher();}else if(kind=="menu.open"){hide_launcher();auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<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)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){hide_launcher();if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<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"+((page=="catalog"||page=="settings"||page=="updates"||page=="notification-settings"||page=="launcher-settings"||page=="apk"||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(page=="catalog"||page=="settings"||page=="updates"||page=="notification-settings"||page=="launcher-settings"||page=="apk"){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){hide_launcher();std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");keyboard_state(keyboard_open);if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "<<e.what()<<"\n";}}
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=="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"){hide_launcher();}else if(kind=="menu.open"){hide_launcher();auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<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)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){hide_launcher();if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<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"+((page=="catalog"||page=="settings"||page=="updates"||page=="notification-settings"||page=="launcher-settings"||page=="apk"||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(page=="catalog"||page=="settings"||page=="updates"||page=="notification-settings"||page=="launcher-settings"||page=="apk"){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){hide_launcher();std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("exiLine 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&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")close_view(v);break;default:break;}}
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")close_view(v);break;default:break;}}
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];}
@@ -410,7 +494,9 @@ int main(int argc,char** argv){
render_handler.api.on_paint=[](cef_render_handler_t*,cef_browser_t* b,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){auto* v=find_browser(b);if(!v||v->closing||type!=PET_VIEW||w!=v->width*v->render_scale||h!=v->height*v->render_scale)return;merge_paint(v->pixels,data,w,h,dirty_count,dirty,true);for(auto& damage:v->texture_damage)damage.add(w,h,dirty_count,dirty);v->dirty=true;};
life_handler.api.on_before_popup=[](cef_life_span_handler_t*,cef_browser_t*,cef_frame_t*,int,const cef_string_t*,const cef_string_t*,cef_window_open_disposition_t,int,const cef_popup_features_t*,cef_window_info_t*,cef_client_t**,cef_browser_settings_t*,cef_dictionary_value_t**,int*){return 1;};
life_handler.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t* b){auto* v=find_browser(b);if(v){cancel_file_dialog(*v);v->closed=true;v->host->base.release(&v->host->base);v->host=nullptr;v->browser->base.release(&v->browser->base);v->browser=nullptr;}};
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);if(v&&v->key=="launcher"){const auto value=text(message);if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=1")launcher_fade.reduced=true;else if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=0")launcher_fade.reduced=false;}std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);if(v&&v->key=="launcher"){const auto value=text(message);if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=1")launcher_fade.reduced=true;else if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=0")launcher_fade.reduced=false;else if(value=="FRAMELY_GAZE_FOCUS"&&gaze_active&&launcher_gaze_allowed()){
auto now=std::chrono::steady_clock::now();if(now-gaze_feedback_at>=std::chrono::milliseconds(180)){gaze_feedback_at=now;int hand=gaze_feedback_hand;auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);if(device==vr::k_unTrackedDeviceIndexInvalid){hand=1-hand;device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);}if(device!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRInput()->TriggerHapticVibrationAction(gaze_haptic_action,0,.012f,110.f,.16f,hands[hand])!=vr::VRInputError_None)vr_system->TriggerHapticPulse(device,0,1200);}
return 1;}}std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
load_handler.api.on_load_end=[](cef_load_handler_t*,cef_browser_t*,cef_frame_t* frame,int){inject_page_scroll(frame);};
client.api.get_load_handler=[](cef_client_t*){return load_handler.acquire();};
request_handler.api.on_before_browse=[](cef_request_handler_t*,cef_browser_t* b,cef_frame_t* f,cef_request_t* r,int,int){auto* uri=r->get_url(r);auto s=text(uri);cef_string_userfree_utf16_free(uri);auto* v=find_browser(b);return int(!allow_plugin_navigation(s,origin,!f||f->is_main(f),v&&!v->plugin.empty()));};
@@ -424,7 +510,7 @@ int main(int argc,char** argv){
client.api.get_dialog_handler=[](cef_client_t*){return dialog_handler.acquire();};client.api.get_render_handler=[](cef_client_t*){return render_handler.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life_handler.acquire();};client.api.get_display_handler=[](cef_client_t*){return display_handler.acquire();};client.api.get_request_handler=[](cef_client_t*){return request_handler.acquire();};
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);overlays->SetOverlayInputMethod(button,vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{dock_canvas_width,dock_canvas_height}};overlays->SetOverlayMouseScale(button,&scale);vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;mask.m_Primitive.m_Rectangle={dock_icon_left,0,128,128};if(overlays->SetOverlayIntersectionMask(button,&mask,1)){std::cerr<<"Dock input mask unavailable\n";overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayFlag(button,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(button,0);overlays->SetOverlayFlag(button,vr::VROverlayFlags_HideLaserIntersection,false);
try{create_view("menu","Framely",initial,false,600,840);}catch(const std::exception& e){std::cerr<<e.what()<<"\n";running=0;}
std::thread poller([port]{int failures=0;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_primary.store(data.value("launcher",json::object()).value("primaryTrigger",true));std::lock_guard<std::mutex> lock(queue_mutex);for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
std::thread poller([port]{int failures=0;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_primary.store(data.value("launcher",json::object()).value("primaryTrigger",true));std::lock_guard<std::mutex> lock(queue_mutex);auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}keyboard_event(global);}vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
if(visible&&(!was_visible||dock!=last_dock)){uv={};anchored_at={};sampled_at={};}last_dock=dock;
@@ -451,7 +537,7 @@ int main(int argc,char** argv){
bool selected=overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay);
if(launcher_open&&selected)launcher_selected=true;
if(launcher_open||launcher_close_at.time_since_epoch().count())overlays->SetOverlayAlpha(views.at("launcher")->overlay,launcher_open?std::max(.02f,launcher_fade.value(now)):launcher_fade.value(now));
if(launcher_open&&!selected&&overlays->IsDashboardVisible()&&!launcher_dock_interaction()&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(1)))launcher_open=false;
if(launcher_open&&!selected&&overlays->IsDashboardVisible()&&!launcher_dock_interaction()&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(1))){if(launcher_calibrating)hide_launcher();else launcher_open=false;}
}
// Toggle once for a matched left click; never activate on a stale release.
vr::VREvent_t event{};while(overlays->PollNextOverlayEvent(button,&event,sizeof(event))){
+30 -1
View File
@@ -3,6 +3,10 @@
{
"name": "/actions/entry",
"usage": "leftright"
},
{
"name": "/actions/gaze",
"usage": "single"
}
],
"actions": [
@@ -25,12 +29,33 @@
{
"name": "/actions/entry/in/navigate",
"type": "vector2"
},
{
"name": "/actions/gaze/in/eye",
"type": "eyetracking",
"requirement": "optional"
},
{
"name": "/actions/entry/in/confirm",
"type": "boolean"
},
{
"name": "/actions/entry/out/gazeHaptic",
"type": "vibration"
}
],
"default_bindings": [
{
"controller_type": "frame_controller",
"binding_url": "frame-controller.json"
},
{
"controller_type": "generic_hmd",
"binding_url": "generic_hmd.json"
},
{
"controller_type": "frame_hmd",
"binding_url": "frame_hmd.json"
}
],
"localization": [
@@ -41,7 +66,11 @@
"/actions/entry/in/upper": "Upper trigger",
"/actions/entry/in/aim": "Pointer",
"/actions/entry/in/close": "Close launcher (B)",
"/actions/entry/in/navigate": "Launcher navigation"
"/actions/entry/in/navigate": "Launcher navigation",
"/actions/entry/in/confirm": "Confirm gaze target (left lower button / A)",
"/actions/entry/out/gazeHaptic": "Gaze focus feedback",
"/actions/gaze": "Framely eye tracking",
"/actions/gaze/in/eye": "Eye gaze"
}
]
}
+34
View File
@@ -92,6 +92,40 @@
"output": "/actions/entry/in/navigate"
}
}
},
{
"path": "/user/hand/left/input/dpad_down",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/entry/in/confirm"
}
},
"parameters": {
"haptic_amplitude": "0"
}
},
{
"path": "/user/hand/right/input/a",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/entry/in/confirm"
}
},
"parameters": {
"haptic_amplitude": "0"
}
}
],
"haptics": [
{
"path": "/user/hand/left/output/haptic",
"output": "/actions/entry/out/gazeHaptic"
},
{
"path": "/user/hand/right/output/haptic",
"output": "/actions/entry/out/gazeHaptic"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"controller_type": "frame_hmd",
"name": "Framely eye tracking",
"bindings": {
"/actions/gaze": {
"eyetracking": [
{
"path": "/user/head/eyetracking",
"output": "/actions/gaze/in/eye"
}
]
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"controller_type": "generic_hmd",
"name": "Framely eye tracking",
"bindings": {
"/actions/gaze": {
"eyetracking": [
{
"path": "/user/head/eyetracking",
"output": "/actions/gaze/in/eye"
}
]
}
}
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <chrono>
#include <cmath>
#include "vendor/openvr/openvr.h"
inline bool launcher_gaze_ray(const vr::VREyeTrackingData_t& eye,vr::VROverlayIntersectionParams_t& ray){
if(!eye.bActive||!eye.bValid||!eye.bTracked)return false;
double length=0;for(int i=0;i<3;i++){if(!std::isfinite(eye.vGazeOrigin.v[i])||!std::isfinite(eye.vGazeTarget.v[i]))return false;ray.vSource.v[i]=eye.vGazeOrigin.v[i];ray.vDirection.v[i]=eye.vGazeTarget.v[i]-eye.vGazeOrigin.v[i];length+=double(ray.vDirection.v[i])*ray.vDirection.v[i];}
if(!std::isfinite(length)||length<1e-8)return false;
for(float& v:ray.vDirection.v)v/=float(std::sqrt(length));ray.eOrigin=vr::TrackingUniverseStanding;return true;
}
// Release-to-arm prevents the entry press, overlapping buttons, disconnects,
// or a mode switch while held from becoming a fresh gaze click.
struct LauncherGazePress {
bool ready=false,held=false;int owner=-1;
enum Edge {None,Down,Up,Cancel};
Edge update(bool enabled,const bool (&connected)[2],const bool (&buttons)[2]){
if(!enabled|| (held&&(owner<0||!connected[owner]))){auto edge=held?Cancel:None;ready=false;held=false;owner=-1;return edge;}
if(held){if(buttons[owner])return None;held=false;owner=-1;ready=!buttons[0]&&!buttons[1];return Up;}
if(!buttons[0]&&!buttons[1]){ready=true;return None;}
if(!ready)return None;ready=false;for(int hand=0;hand<2;hand++)if(connected[hand]&&buttons[hand]){owner=hand;held=true;return Down;}return None;
}
};
+201 -9
View File
@@ -871,11 +871,70 @@ fn restore_package_mount(home: &Path, c: &Container, log: &Path) -> Result<()> {
}
Ok(())
}
// Adapt only the entry script in a private temporary file. Keep Lepton's original
// libraries and dev-context data handling; app mode can clear existing baked data.
fn direct_launch_script(source: &str) -> Result<String> {
let directory =
"SCRIPT_DIR=$( cd -- \"$( dirname -- \"${BASH_SOURCE[0]}\" )\" &> /dev/null && pwd )";
let include = "source \"${SCRIPT_DIR}/liblepton/liblepton.sh\"";
ensure!(
source.matches(directory).count() == 1 && source.matches(include).count() == 1,
"Unsupported Lepton entry script; cannot start an APK without showing the desktop"
);
let hooks = r#"
# Framely: boot without a desktop window, retaining the original dev context.
# Do not use is_app: Lepton's app bake path can reset existing application data.
eval "$(declare -f setup_props | sed '1s/setup_props/framely_original_setup_props/')"
function app_wants_flatscreen() {
[[ "${APP_WANTS_FLATSCREEN:-true}" == true ]]
}
function setup_props() {
framely_original_setup_props "$@"
sed -i 's/^waydroid.background_start=false$/waydroid.background_start=true/' "$(props_file)"
printf '\nwaydroid.active_apps=none\n' >> "$(props_file)"
}
"#;
Ok(source
.replace(directory, "SCRIPT_DIR=\"${FRAMELY_LEPTON_DIR:?}\"")
.replace(include, &format!("{include}\n{hooks}")))
}
fn start(home: &Path, c: &Container, show: Option<bool>, log: &Path) -> Result<()> {
start_container(home, c, show, false, log)
}
fn start_container(
home: &Path,
c: &Container,
show: Option<bool>,
direct: bool,
log: &Path,
) -> Result<()> {
if running(&c.name) {
return restore_package_mount(home, c, log);
}
let mut cmd = Command::new(runner(home)?);
let runner = runner(home)?;
let mut temporary = None;
let mut cmd = if direct {
let script = direct_launch_script(&fs::read_to_string(&runner)?)?;
let path = root(home).join("logs").join(format!(
"lepton-launch-{}.sh",
hex::encode(rand::random::<[u8; 16]>())
));
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o700)
.open(&path)?;
file.write_all(script.as_bytes())?;
let mut command = Command::new(&path);
command.env(
"FRAMELY_LEPTON_DIR",
runner.parent().context("Missing Lepton directory")?,
);
temporary = Some(path);
command
} else {
Command::new(&runner)
};
cmd.args(["start", &c.name])
.env("LEPTON_NO_CLEANUP", "true")
.env("TERM", "dumb")
@@ -901,9 +960,20 @@ fn start(home: &Path, c: &Container, show: Option<bool>, log: &Path) -> Result<(
cmd.stdin(Stdio::null())
.stdout(file.try_clone()?)
.stderr(file);
let mut child = cmd.spawn()?;
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(error) => {
if let Some(path) = temporary {
let _ = fs::remove_file(path);
}
return Err(error.into());
}
};
std::thread::spawn(move || {
let _ = child.wait();
if let Some(path) = temporary {
let _ = fs::remove_file(path);
}
});
let began = Instant::now();
while began.elapsed() < Duration::from_secs(60) {
@@ -944,8 +1014,13 @@ fn wrapper_active(name: &str) -> bool {
.split(|b| *b == 0)
.filter_map(|b| std::str::from_utf8(b).ok())
.collect();
args.windows(3)
.any(|a| a[0].ends_with("/lepton") && a[1] == "start" && a[2] == name)
args.windows(3).any(|a| {
(a[0].ends_with("/lepton")
|| (a[0].contains("/framely/apk-manager/logs/lepton-launch-")
&& a[0].ends_with(".sh")))
&& a[1] == "start"
&& a[2] == name
})
})
}
fn stop(c: &Container, log: &Path) -> Result<()> {
@@ -1239,7 +1314,7 @@ fn install_package(c: &Container, apk: &Path, log: &Path) -> Result<()> {
);
Ok(())
}
fn launch(c: &Container, a: &App, log: &Path) -> Result<()> {
fn launch_component(a: &App) -> Result<String> {
let name = a
.activity
.as_ref()
@@ -1249,7 +1324,10 @@ fn launch(c: &Container, a: &App, log: &Path) -> Result<()> {
a.metadata.declared_activities.contains(name),
"Activity is not declared by this APK"
);
let component = format!("{}/{}", a.metadata.package, name);
Ok(format!("{}/{}", a.metadata.package, name))
}
fn launch(c: &Container, a: &App, log: &Path) -> Result<()> {
let component = launch_component(a)?;
podman(
&[
"exec",
@@ -1275,9 +1353,25 @@ fn launch(c: &Container, a: &App, log: &Path) -> Result<()> {
Some(log),
)?;
ensure!(
!s.contains("Error:") && !s.contains("Exception"),
!s.contains("Error:")
&& !s.contains("Exception")
&& s.lines().any(|line| line.trim() == "Status: ok"),
"Application launch failed: {s}"
);
// Reveal the target only after ActivityManager has completed the launch.
// The compositor's app mode avoids exposing the Android home screen.
if a.show_window.unwrap_or(!a.metadata.vr) {
podman(
&[
"exec",
&format!("lepton-{}", c.name),
"setprop",
"waydroid.active_apps",
&a.metadata.package,
],
Some(log),
)?;
}
Ok(())
}
fn live_installed(c: &Container, package: &str, log: &Path) -> Result<bool> {
@@ -1775,8 +1869,15 @@ pub fn operate(
match kind {
"launch" => {
ensure!(a.installed, "Application is not installed");
launch_component(&a)?;
progress(json!({"phase":"starting"}));
start(home, &c, a.show_window.or(Some(!a.metadata.vr)), &log)?;
start_container(
home,
&c,
a.show_window.or(Some(!a.metadata.vr)),
true,
&log,
)?;
launch(&c, &a, &log)?;
}
"close" => {
@@ -2047,6 +2148,62 @@ pub fn launch_app(home: &Path, id: &str) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn direct_boot_preserves_context_and_controls_window_visibility() {
let dir = tempfile::tempdir().unwrap();
let library = dir.path().join("vendor with spaces/liblepton");
fs::create_dir_all(&library).unwrap();
fs::write(
library.join("liblepton.sh"),
r#"
function props_file() { printf '%s' "$TEST_PROPS"; }
function is_app() { return 1; }
function setup_props() {
printf 'retained.context=%s\n' "$2" > "$(props_file)"
if app_wants_flatscreen; then
printf 'waydroid.background_start=false\nlepton.headless=false\n' >> "$(props_file)"
else
printf 'lepton.headless=true\n' >> "$(props_file)"
fi
}
"#,
)
.unwrap();
let script = direct_launch_script(
r#"#!/bin/bash
set -euo pipefail
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
source "${SCRIPT_DIR}/liblepton/liblepton.sh"
if is_app; then exit 99; fi
setup_props "$@"
"#,
)
.unwrap();
let entry = dir.path().join("launch.sh");
fs::write(&entry, script).unwrap();
let props = dir.path().join("props");
for visible in [true, false] {
let status = Command::new("bash")
.arg(&entry)
.args(["start", "existing-context"])
.env("FRAMELY_LEPTON_DIR", library.parent().unwrap())
.env("TEST_PROPS", &props)
.env("APP_WANTS_FLATSCREEN", visible.to_string())
.status()
.unwrap();
assert!(status.success());
let props = fs::read_to_string(&props).unwrap();
assert!(props.contains("retained.context=existing-context"));
assert!(props.contains("waydroid.active_apps=none"));
assert!(props.contains(if visible {
"waydroid.background_start=true"
} else {
"lepton.headless=true"
}));
assert!(!props.contains("waydroid.background_start=false"));
}
assert!(direct_launch_script("unrecognized future entrypoint").is_err());
}
static SERIAL: Mutex<()> = Mutex::new(());
struct Fixture {
dir: tempfile::TempDir,
@@ -2081,6 +2238,7 @@ mod tests {
fs::write(&state, "true").unwrap();
let script = format!(
r#"#!/bin/sh
printf '%s\n' "$*" >> '{commands}'
case "$1" in
inspect) cat '{state}';;
ps) if [ "$(cat '{state}')" = true ]; then echo lepton-test; fi;;
@@ -2097,11 +2255,13 @@ case "$1" in
esac
elif [ "$3" = getprop ]; then echo 30;
elif [ "$3" = cmd ]; then echo com.example.app/.Main;
elif [ "$3" = am ]; then echo 'Status: ok';
elif [ "$3" = am ]; then if [ -f '{launch_fail}' ]; then echo 'Error: launch failed'; else echo 'Status: ok'; fi;
fi;;
esac
"#,
state = state.display(),
commands = dir.path().join("commands").display(),
launch_fail = dir.path().join("launch-fail").display(),
incoming = dir.path().join("incoming.apk").display(),
apk = baked.join("app_overlay/base.apk").display(),
fail = dir.path().join("fail").display(),
@@ -2152,6 +2312,38 @@ esac
}
}
#[test]
fn launch_reveals_only_the_successful_target_and_respects_vr_override() {
let f = Fixture::new();
let db = load(&f.home).unwrap();
let (mut a, c) = app(&f.home, &db, "test/com.example.app").unwrap();
let commands = f.dir.path().join("commands");
let log = f.dir.path().join("launch.log");
a.show_window = Some(true);
launch(&c, &a, &log).unwrap();
let trace = fs::read_to_string(&commands).unwrap();
let start = trace
.find("am start -W --user 0 -n com.example.app/")
.unwrap();
let reveal = trace
.find("setprop waydroid.active_apps com.example.app")
.unwrap();
assert!(start < reveal);
fs::write(&commands, "").unwrap();
a.show_window = Some(false);
launch(&c, &a, &log).unwrap();
assert!(!fs::read_to_string(&commands)
.unwrap()
.contains("waydroid.active_apps"));
fs::write(&commands, "").unwrap();
a.show_window = Some(true);
fs::write(f.dir.path().join("launch-fail"), "").unwrap();
assert!(launch(&c, &a, &log).is_err());
assert!(!fs::read_to_string(&commands)
.unwrap()
.contains("waydroid.active_apps"));
assert_eq!(fs::read_to_string(f.save_file()).unwrap(), "saved progress");
}
#[test]
fn discovery_reads_mount_layer_and_does_not_claim_existing_context() {
let f = Fixture::new();
let v = list(&f.home).unwrap();
+46
View File
@@ -188,6 +188,50 @@ pub struct LauncherSettings {
#[serde(default = "launcher_categories")]
pub all_categories: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GazeCalibration {
#[serde(default)]
pub space: String,
pub matrix: [f64; 6],
pub width: u32,
pub height: u32,
pub error: f64,
pub max_error: f64,
}
impl GazeCalibration {
// Preview models have not passed independent validation yet. Accept valid
// geometry and finite metrics so validation can measure their actual quality.
pub fn validate_preview(&self) -> Result<()> {
let m = self.matrix;
let det = m[0] * m[4] - m[1] * m[3];
ensure!(
self.space == "headAngles"
&& m.iter().all(|v| v.is_finite() && v.abs() <= 4.0)
&& (0.25..=4.0).contains(&det)
&& m[2].abs() <= 0.5
&& m[5].abs() <= 0.5
&& (320..=8192).contains(&self.width)
&& (240..=8192).contains(&self.height)
&& self.error.is_finite()
&& self.error >= 0.0
&& self.max_error.is_finite()
&& self.max_error >= 0.0
&& self.max_error + 1e-12 >= self.error,
"Invalid gaze calibration model"
);
Ok(())
}
pub fn validate(&self) -> Result<()> {
self.validate_preview()?;
ensure!(
self.error <= 0.035 && self.max_error <= 0.065,
"Gaze calibration validation error exceeds the allowed limit"
);
Ok(())
}
}
fn launcher_categories() -> Vec<String> {
["plugin", "steam", "lepton", "desktop"]
.into_iter()
@@ -787,6 +831,8 @@ pub struct Database {
#[serde(default)]
pub launcher: LauncherSettings,
#[serde(default)]
pub gaze_calibration: Option<GazeCalibration>,
#[serde(default)]
pub steam_favorites: std::collections::BTreeSet<u32>,
#[serde(default)]
pub launcher_order: Vec<String>,
+10
View File
@@ -1058,6 +1058,16 @@ impl Service {
self.save()?;
Ok(json!(true))
}
"launcher.gaze.calibration.get" => Ok(json!(self.db.gaze_calibration)),
"launcher.gaze.calibration.save" => {
let calibration: Option<GazeCalibration> = serde_json::from_value(p)?;
if let Some(value) = &calibration {
value.validate()?;
}
self.db.gaze_calibration = calibration;
self.save()?;
Ok(json!(true))
}
"launcher.settings.save" => {
let settings: LauncherSettings = serde_json::from_value(p)?;
settings.validate()?;
+119 -8
View File
@@ -370,7 +370,7 @@ impl Agent {
.unwrap_or(json!(0));
return send_json(
r,
json!({"commands":commands,"notificationBadge":badge,"launcher":self.core("status",json!({})).ok().map(|v|v["database"]["launcher"].clone()).unwrap_or(json!({}))}),
json!({"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":self.core("status",json!({})).ok().map(|v|v["database"]["launcher"].clone()).unwrap_or(json!({}))}),
);
}
if let Some(rel) = path.strip_prefix("/plugin-assets/") {
@@ -520,9 +520,20 @@ impl Agent {
"Session authentication failed"
);
if path.starts_with("/host/files/") {
ensure!(!remote && r.method() == &Method::Post, "File browser is only available inside Frame");
ensure!(r.headers().iter().any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin), "Cross-origin file browser request denied");
ensure!(self.core("agreement.status", json!({}))?["accepted"] == true, "请先同意用户协议和隐私声明");
ensure!(
!remote && r.method() == &Method::Post,
"File browser is only available inside Frame"
);
ensure!(
r.headers()
.iter()
.any(|h| h.field.equiv("Origin") && h.value.as_str() == expected_origin),
"Cross-origin file browser request denied"
);
ensure!(
self.core("agreement.status", json!({}))?["accepted"] == true,
"请先同意用户协议和隐私声明"
);
let mut bytes = Vec::new();
r.as_reader().take(65537).read_to_end(&mut bytes)?;
ensure!(bytes.len() <= 65536, "File browser request too large");
@@ -532,15 +543,27 @@ impl Agent {
match path.as_str() {
"/host/files/list" => crate::file_browser::list(&home, &params),
"/host/files/complete" => {
let id = params["id"].as_str().filter(|s| !s.is_empty() && s.len() <= 128).context("Invalid file dialog")?;
let id = params["id"]
.as_str()
.filter(|s| !s.is_empty() && s.len() <= 128)
.context("Invalid file dialog")?;
let paths = crate::file_browser::selection(&home, &params)?;
push(&self.commands, json!({"kind":"file.dialog.complete","id":id,"paths":paths}));
push(
&self.commands,
json!({"kind":"file.dialog.complete","id":id,"paths":paths}),
);
Ok(json!(true))
}
_ => anyhow::bail!("Unknown file browser operation"),
}
})();
return send_json(r, match result { Ok(v) => json!({"result":v}), Err(e) => json!({"error":e.to_string()}) });
return send_json(
r,
match result {
Ok(v) => json!({"result":v}),
Err(e) => json!({"error":e.to_string()}),
},
);
}
if let Some(path) = path.strip_prefix("/api/upload/") {
ensure!(r.method() == &Method::Post, "Invalid upload method");
@@ -742,6 +765,38 @@ impl Agent {
push(&self.commands, json!({"kind":"launcher.close"}));
Ok(json!(true))
}
"host.launcher.calibration.target" => {
let id = p["id"]
.as_str()
.filter(|id| !id.is_empty() && id.len() <= 128)
.context("Invalid calibration target")?;
for coordinate in ["x", "y"] {
let value = p[coordinate]
.as_f64()
.context("Invalid calibration target")?;
ensure!(
value.is_finite() && (0.0..=1.0).contains(&value),
"Invalid calibration target"
);
}
let preview: Option<crate::model::GazeCalibration> =
serde_json::from_value(p["preview"].clone())?;
if let Some(calibration) = &preview {
calibration.validate_preview()?;
}
push(
&self.commands,
json!({"kind":"gaze.target","id":id,"x":p["x"],"y":p["y"],"preview":preview}),
);
Ok(json!(true))
}
"host.launcher.calibrate" => {
push(
&self.commands,
json!({"kind":"launcher.calibrate","active":p["active"].as_bool().unwrap_or(true)}),
);
Ok(json!(true))
}
"host.launcher.close" => {
push(&self.commands, json!({"kind":"launcher.close"}));
Ok(json!(true))
@@ -1017,6 +1072,8 @@ impl Agent {
| "plugin.launch"
| "plugin.launcher.action"
| "launcher.settings.save"
| "launcher.gaze.calibration.get"
| "launcher.gaze.calibration.save"
| "launcher.order.save"
| "launcher.favorite"
| "steam.favorite"
@@ -1731,6 +1788,55 @@ mod tests {
})
}
#[test]
fn calibration_preview_allows_unvalidated_error_but_rejects_invalid_geometry() {
let root = tempfile::tempdir().unwrap();
let server = Server::http("127.0.0.1:0").unwrap();
let agent = agent(&server, root.path());
let mut core =
crate::tests::accepted_service(&agent.state, unsafe { libc::geteuid() }).unwrap();
let socket = UnixListener::bind(&agent.socket).unwrap();
socket.set_nonblocking(true).unwrap();
let stop = Arc::new(AtomicBool::new(false));
let core_stop = stop.clone();
let core_worker = std::thread::spawn(move || {
while !core_stop.load(Ordering::Relaxed) {
if let Ok((mut stream, _)) = socket.accept() {
let request = ipc::read(&mut stream).unwrap();
let response = ipc::response(core.handle(
request["method"].as_str().unwrap(),
request["params"].clone(),
));
ipc::write(&mut stream, &response).unwrap();
} else {
std::thread::sleep(Duration::from_millis(5));
}
}
});
let http = http_server(server, agent.clone(), stop.clone());
let request = |preview: Value| -> Value {
ureq::post(&format!("{}/api", agent.origin))
.set("Cookie", &format!("framely={}", agent.web_key))
.set("Origin", &agent.origin)
.send_json(json!({"method":"host.launcher.calibration.target", "params":{"id":"verify:1","x":0.3,"y":0.5,"preview":preview}}))
.unwrap().into_json().unwrap()
};
let preview = json!({"space":"headAngles","matrix":[1.0,0.0,0.1,0.0,1.0,-0.1],"width":1440,"height":800,"error":0.08,"maxError":0.15});
let allowed = request(preview.clone());
let mut invalid = preview.clone();
invalid["matrix"][0] = json!(-1);
let rejected = request(invalid);
stop.store(true, Ordering::Relaxed);
http.join().unwrap();
core_worker.join().unwrap();
assert_eq!(allowed["result"], json!(true), "{allowed}");
assert!(rejected.get("error").is_some(), "{rejected}");
let commands = agent.commands.lock().unwrap();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0]["preview"], preview);
let model: crate::model::GazeCalibration = serde_json::from_value(preview).unwrap();
assert!(model.validate().is_err()); // Still cannot persist a poor result.
}
#[test]
fn launcher_favorites_pass_through_real_http_session_and_persist() {
let root = tempfile::tempdir().unwrap();
let server = Server::http("127.0.0.1:0").unwrap();
@@ -2082,7 +2188,12 @@ mod tests {
.call()
.is_err());
for path in ["/host/files/list", "/host/files/complete"] {
assert!(matches!(ureq::post(&format!("{origin}{path}")).set("Origin", &origin).send_json(json!({})), Err(ureq::Error::Status(403, _))));
assert!(matches!(
ureq::post(&format!("{origin}{path}"))
.set("Origin", &origin)
.send_json(json!({})),
Err(ureq::Error::Status(403, _))
));
}
assert!(ureq::get(&format!("{origin}/host/poll"))
.set("X-Framely-Native", &agent.native_key)
+46
View File
@@ -2071,3 +2071,49 @@ fn launcher_trigger_defaults_preserve_explicit_preferences() {
serde_json::from_value(serde_json::to_value(saved).unwrap()).unwrap();
assert!(!restored.primary_trigger);
}
#[test]
fn launcher_gaze_calibration_validates_persists_and_clears() {
let root = tempfile::tempdir().unwrap();
let state = root.path().join("state");
let uid = unsafe { libc::geteuid() };
let mut core = accepted_service(&state, uid).unwrap();
assert!(core
.handle("launcher.gaze.calibration.get", json!({}))
.unwrap()
.is_null());
let calibration = json!({"space":"headAngles","matrix":[1.1,0.02,-0.03,0.01,0.95,0.02],"width":1440,"height":800,"error":0.01,"maxError":0.02});
let mut legacy = calibration.clone();
legacy.as_object_mut().unwrap().remove("space");
let legacy_model: crate::model::GazeCalibration =
serde_json::from_value(legacy.clone()).unwrap();
assert!(legacy_model.validate().is_err());
assert!(core
.handle("launcher.gaze.calibration.save", legacy)
.is_err());
core.handle("launcher.gaze.calibration.save", calibration.clone())
.unwrap();
for bad in [
json!({"space":"headAngles","matrix":[-1,0,0,0,1,0],"width":1440,"height":800,"error":0.01,"maxError":0.02}),
json!({"space":"headAngles","matrix":[1,0,0,0,1,0],"width":1440,"height":800,"error":0.08,"maxError":0.09}),
] {
assert!(core.handle("launcher.gaze.calibration.save", bad).is_err());
}
let mut loaded = Service::load(&state, uid).unwrap();
assert_eq!(
loaded
.handle("launcher.gaze.calibration.get", json!({}))
.unwrap(),
calibration
);
loaded.handle("launcher.settings.save", json!({})).unwrap();
assert!(loaded.db.gaze_calibration.is_some());
loaded
.handle("launcher.gaze.calibration.save", Value::Null)
.unwrap();
assert!(Service::load(&state, uid)
.unwrap()
.db
.gaze_calibration
.is_none());
}
+2 -1
View File
@@ -42,7 +42,8 @@ int main(int argc,char** argv){
cef_main_args_t args{argc,argv};if(std::strcmp(cef_api_hash(CEF_API_VERSION,0),CEF_API_HASH_PLATFORM))return 2;int child=cef_execute_process(&args,nullptr,nullptr);if(child>=0)return child;if(url.empty()||runtime.empty()||cef.empty())return 2;
cef_settings_t settings{};settings.size=sizeof(settings);settings.windowless_rendering_enabled=1;str(settings.root_cache_path,runtime+"/cache");str(settings.log_file,runtime+"/cef.log");str(settings.resources_dir_path,cef+"/Resources");str(settings.locales_dir_path,cef+"/Resources/locales");
if(!cef_initialize(&args,&settings,nullptr,nullptr))return 3;
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,3);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:600,std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?4320:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?2400:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);if(!notification_mode&&w>0&&h>0){auto alpha=[&](int x,int y){return pixels[(y*w+x)*4+3];};if(alpha(0,0)==0&&alpha(w-1,0)==0&&alpha(0,h-1)==0&&alpha(w-1,h-1)==0&&alpha(w/2,40)>240)rounded_paint=true;}};
render.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t*,cef_rect_t* r){*r=launcher_mode?cef_rect_t{0,0,1440,800}:std::getenv("FRAMELY_STORE_TEST")?cef_rect_t{0,0,1440,810}:cef_rect_t{0,0,600,notification_mode?360:840};};render.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t*,cef_screen_info_t* info){if(launcher_mode)return render_screen_info(info,1440,800,3);return render_screen_info(info,std::getenv("FRAMELY_STORE_TEST")?1440:600,std::getenv("FRAMELY_STORE_TEST")?810:(notification_mode?360:840),std::getenv("FRAMELY_STORE_TEST")?4:2);};render.api.on_paint=[](cef_render_handler_t*,cef_browser_t*,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){if(type!=PET_VIEW)return;++paints;if(w==(launcher_mode?4320:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?2400:std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;merge_paint(pixels,data,w,h,dirty_count,dirty);// Semi-transparent charcoal panels still need a readable surface and clear rounded corners.
if(!notification_mode&&w>0&&h>0){auto alpha=[&](int x,int y){return pixels[(y*w+x)*4+3];};if(alpha(0,0)==0&&alpha(w-1,0)==0&&alpha(0,h-1)==0&&alpha(w-1,h-1)==0&&alpha(w/2,40)>200)rounded_paint=true;}};
display.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* m,const cef_string_t*,int){auto s=text(m);std::cout<<s<<"\n";if(s.rfind("FRAMELY_PAGE_WHEEL_",0)==0){int x=0,y=0,dx=0,dy=0;if(std::sscanf(s.c_str(),"FRAMELY_PAGE_WHEEL_%d_%d_%d_%d",&x,&y,&dx,&dy)==4){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;host->send_mouse_move_event(host,&mouse,0);host->send_mouse_wheel_event(host,&mouse,dx,dy);host->base.release(&host->base);}}if(s.rfind("FRAMELY_NATIVE_KEYBOARD_",0)==0){auto* frame=b->get_focused_frame(b);auto command=s.substr(24);if(command=="TEXT")keyboard_edit(frame,"汉😃");else if(command=="BACKSPACE")keyboard_edit(frame,"\b");else if(command=="ENTER")keyboard_edit(frame,"\n");else if(command=="X")keyboard_edit(frame,"x");else if(command=="Y")keyboard_edit(frame,"y");else if(command=="LEFT")keyboard_edit(frame,"\x1b[D");else if(command=="RIGHT")keyboard_edit(frame,"\x1b[C");frame->base.release(&frame->base);}if(s=="FRAMELY_LAUNCHER_CANCEL_PRESS"){auto* pointer_host=b->get_host(b);cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_move_event(pointer_host,&outside,0);outside.modifiers=0;pointer_host->send_mouse_click_event(pointer_host,&outside,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;auto* frame=b->get_main_frame(b);cef_string_t code{},source{};str(code,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->execute_java_script(frame,&code,&source,1);cef_string_utf16_clear(&code);frame->base.release(&frame->base);pointer_host->base.release(&pointer_host->base);}if(s.rfind("FRAMELY_LAUNCHER_DRAG_",0)==0){int x=0,y=0;if(press_pending&&std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_DRAG_%d_%d",&x,&y)==2){press_dx=x-test_pointer.x;press_dy=y-test_pointer.y;}}if(s.rfind("FRAMELY_LAUNCHER_PRESS_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_PRESS_%d_%d_%d_%d_%d",&x,&y,&press_dx,&press_dy,&press_duration)==5){auto* pointer_host=b->get_host(b);test_pointer.begin(x,y,std::chrono::steady_clock::now());press_pending=true;cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;pointer_host->set_focus(pointer_host,1);pointer_host->send_mouse_move_event(pointer_host,&mouse,0);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;pointer_host->send_mouse_click_event(pointer_host,&mouse,MBT_LEFT,0,1);pointer_host->base.release(&pointer_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_HOVER_",0)==0){int x=0,y=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_HOVER_%d_%d",&x,&y)==2){auto* hover_host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=x;mouse.y=y;hover_host->send_mouse_move_event(hover_host,&mouse,0);hover_host->base.release(&hover_host->base);}}if(s.rfind("FRAMELY_LAUNCHER_REGION_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_REGION_%d_%d_%d_%d",&x,&y,&w,&h)==4){const int scale=launcher_mode?3:4;int ink=0;for(int py=std::max(0,y*scale);py<std::min(pixel_height,(y+h)*scale);py++)for(int px=std::max(0,x*scale);px<std::min(pixel_width,(x+w)*scale);px++)if(pixels[(size_t(py)*pixel_width+px)*4+3]>128)ink++;if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted control "<<x<<","<<y<<"\n";}}}if(s.rfind("FRAMELY_LAUNCHER_INK_",0)==0){int x=0,y=0,w=0,h=0;if(std::sscanf(s.c_str(),"FRAMELY_LAUNCHER_INK_%d_%d_%d_%d",&x,&y,&w,&h)==4){const int scale=launcher_mode?3:4;int ink=0;for(int py=std::max(0,y*scale);py<std::min(pixel_height,(y+h)*scale);py++)for(int px=std::max(0,x*scale);px<std::min(pixel_width,(x+w)*scale);px++){size_t i=(size_t(py)*pixel_width+px)*4;if(pixels[i+3]>128&&pixels[i]>180&&pixels[i+1]>180&&pixels[i+2]>180)ink++;}if(ink<30){failed=true;std::cerr<<"FRAMELY_BRIDGE_FAIL missing painted label "<<x<<","<<y<<" ink="<<ink<<"\n";}}}
if(s=="FRAMELY_DPI_CLICK_REQUEST"){auto* host=b->get_host(b);cef_mouse_event_t mouse{};mouse.x=50;mouse.y=45;host->set_focus(host,1);host->send_mouse_move_event(host,&mouse,0);host->send_mouse_click_event(host,&mouse,MBT_LEFT,0,1);host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);host->base.release(&host->base);}if(s=="FRAMELY_DPI_CLICK_PASS")dpi_click=true;if(s.rfind("FRAMELY_PREVIEW_INSTALL_",0)==0)snapshot(s.substr(16));if(s=="FRAMELY_PREVIEW_STORE")snapshot("store");if(s=="FRAMELY_PREVIEW_FAVORITES")snapshot("favorites");if(s=="FRAMELY_PREVIEW_INSTALLED")snapshot("installed");if(s=="FRAMELY_PREVIEW_SETTINGS")snapshot("settings");if(s=="FRAMELY_VIEW_READY")ready=true;if(s=="FRAMELY_BRIDGE_PASS")passed=true;if(s.rfind("FRAMELY_BRIDGE_FAIL",0)==0)failed=true;return 1;};life.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t*){closed=true;};
client.api.get_render_handler=[](cef_client_t*){return render.acquire();};client.api.get_display_handler=[](cef_client_t*){return display.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life.acquire();};
+24
View File
@@ -0,0 +1,24 @@
#include "gaze_geometry.h"
#include <cassert>
#include <limits>
int main(){
const double correction[6]{.93,.01,.045,-.02,1.06,-.035};
vr::HmdVector3_t target{{-.7f,1.8f,-1.4f}};
for(int n=0;n<100;n++){
float yaw=(n-50)*.012f,pitch=std::sin(n*.15f)*.2f,roll=std::cos(n*.12f)*.15f;
float cy=std::cos(yaw),sy=std::sin(yaw),cp=std::cos(pitch),sp=std::sin(pitch),cr=std::cos(roll),sr=std::sin(roll);
vr::HmdMatrix34_t head{{{cy*cr+sy*sp*sr,-cy*sr+sy*sp*cr,sy*cp,std::sin(n*.1f)*.2f},{cp*sr,cp*cr,-sp,1.65f},{-sy*cr+cy*sp*sr,sy*sr+cy*sp*cr,cy*cp,std::cos(n*.1f)*.15f}}};
vr::HmdVector3_t origin{{head.m[0][3]+.015f,head.m[1][3]-.02f,head.m[2][3]}};GazeAngles expected{};
assert(gaze_angles_to_point(head,origin,target,expected));
double ex=expected.yaw-correction[2],ey=expected.pitch-correction[5],det=correction[0]*correction[4]-correction[1]*correction[3];
GazeAngles raw{float((correction[4]*ex-correction[1]*ey)/det),float((-correction[3]*ex+correction[0]*ey)/det)};
auto ray=gaze_world_ray(head,origin,gaze_correct(raw,correction));GazeAngles round{};assert(gaze_head_angles(head,ray.vDirection,round));assert(std::hypot(round.yaw-expected.yaw,round.pitch-expected.pitch)<1e-5f);
float distance=0;for(int i=0;i<3;i++){assert(ray.vSource.v[i]==origin.v[i]);distance+=std::pow(target.v[i]-origin.v[i],2);}distance=std::sqrt(distance);
for(int i=0;i<3;i++)assert(std::abs(origin.v[i]+distance*ray.vDirection.v[i]-target.v[i])<1e-5f);
}
// Runtime UV inversion, including curvature coupling and a displaced seed.
auto curved=[](GazeAngles a,float& u,float& v){u=.5f+std::sin(a.yaw);v=.5f+a.pitch+.18f*a.yaw*a.yaw;return u>=0&&u<=1&&v>=0&&v<=1;};
for(float u:{.08f,.5f,.92f})for(float v:{.08f,.5f,.92f}){GazeAngles out;assert(gaze_target_inverse({},u,v,curved,out));float x,y;assert(curved(out,x,y));assert(std::hypot(x-u,y-v)<.0003f);}
GazeAngles out;assert(!gaze_target_inverse({},.3f,.3f,[](GazeAngles,float&,float&){return false;},out));assert(!gaze_target_inverse({},.3f,.3f,[](GazeAngles,float& u,float& v){u=v=.5f;return true;},out));
vr::HmdMatrix34_t invalid{};invalid.m[0][0]=std::numeric_limits<float>::quiet_NaN();assert(!gaze_head_angles(invalid,target,out));
}
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';import {createRoot} from 'react-dom/client';
import {Launcher} from '../ui/src/launcher';import {configureLanguage} from '../ui/src/i18n';import {fitCalibration,correctedGaze,calibrationBlocks,calibrationValidation} from '../ui/src/gaze-calibration';import '../ui/src/style.css';
const wait=(ms:number)=>new Promise(r=>setTimeout(r,ms));const calls:{method:string;params:any}[]=[];
window.fetch=async(_url,init)=>{const request=JSON.parse(String(init?.body));calls.push(request);return new Response(JSON.stringify({result:request.method==='steam.list'||request.method==='desktop.list'?[]:request.method==='launcher.gaze.calibration.get'?null:request.method==='launcher.search.index'?{}:true}));};
location.hash='#calibrate';configureLanguage('en-US',[]);const root=createRoot(document.getElementById('root')!);root.render(<Launcher plugins={{}} safeMode={false} favorites={[]} refresh={async()=>{}}/>);
(window as any).runInstallReviewChecks=async()=>{try{
const degenerate=Array.from({length:8},()=>({raw:{x:.5,y:.5},target:{x:.5,y:.5}}));const stable=fitCalibration(degenerate,1440,800);if(!stable||Math.abs(stable.matrix[0]-1)>.001)throw Error('Head-facing identity prior failed');if(fitCalibration(degenerate.slice(0,5),1440,800))throw Error('Insufficient samples accepted');
// Realistic correlated jitter, intermittent spikes, head motion and one
// mis-fixated target. Every target has equal influence despite frame counts.
const noisy=[];
for(let target=0;target<23;target++){const frames=[];for(let n=0;n<60+(target%3)*6;n++){const yaw=(target%5-2)*.2-Math.sin(n/18)*.15,pitch=(Math.floor(target/5)-2)*.13-Math.cos(n/22)*.08;const wrong=target===22?.18:0;frames.push({raw:{x:(yaw-.045)/.93+Math.sin(n*1.7)*.028+wrong,y:(pitch+.035)/1.06+Math.cos(n*1.3)*.02},target:{x:yaw,y:pitch}});}noisy.push(...calibrationBlocks(frames,target));}
const robust=fitCalibration(noisy,1440,800);if(!robust)throw Error('Noisy fit failed');
for(const yaw of [-.35,0,.35]){const q=correctedGaze({x:(yaw-.045)/.93,y:(.12+.035)/1.06},robust);if(Math.hypot(q.x-yaw,q.y-.12)>.012)throw Error('Noise or bad target corrupted model');}
const identity={space:'headAngles' as const,matrix:[1,0,0,0,1,0],width:1440,height:800,error:0,maxError:0};
const fixation=Array.from({length:80},(_,n)=>({raw:{x:.2+(n===30?.12:Math.sin(n)*.02),y:.1+Math.cos(n)*.02},target:{x:.2,y:.1}}));
if(calibrationValidation(fixation,identity).bias>.005)throw Error('One noisy frame invalidated fixation');
const biased=fixation.map(p=>({...p,raw:{x:p.raw.x+.09,y:p.raw.y}}));if(calibrationValidation(biased,identity).bias<.065)throw Error('Systematic bad gaze wrongly passed');
const marker=document.createElement('span');marker.style.cssText='position:fixed;left:calc(50% - 2px);top:0;width:4px;height:20px;background:white';document.body.append(marker);await wait(500);const button=(label:string)=>Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent===label)!;
if(document.querySelectorAll('[data-calibration-target]').length!==24)throw Error('Missing calibration targets');
console.log('FRAMELY_PREVIEW_INSTALL_CALIBRATION_INTRO_EN');
configureLanguage('zh-CN',[]);root.render(<Launcher plugins={{}} safeMode={false} favorites={[]} refresh={async()=>{}}/>);await wait(160);console.log('FRAMELY_PREVIEW_INSTALL_CALIBRATION_INTRO_ZH');
configureLanguage('en-US',[]);root.render(<Launcher plugins={{}} safeMode={false} favorites={[]} refresh={async()=>{}}/>);await wait(100);
let clock=1000;Object.defineProperty(performance,'now',{value:()=>clock,configurable:true});button('Start calibration').click();await wait(100);if(document.querySelector('.calibration-dialog'))throw Error('Intro dialog hid calibration targets');const notice=document.querySelector('.calibration-live')!.getBoundingClientRect();for(const node of document.querySelectorAll('[data-calibration-target]')){const b=node.getBoundingClientRect();if(b.bottom>notice.top&&b.top<notice.bottom&&b.right>notice.left&&b.left<notice.right)throw Error('Progress notice obscured target');}
let completed=0;
while(!button('Save calibration')&&completed<32){const node=document.querySelector<HTMLElement>('.calibration-target-active');if(!node)throw Error('No active target');const rect=node.getBoundingClientRect();const tx=(rect.x+rect.width/2)/innerWidth-.5,ty=(.5-(rect.y+rect.height/2)/innerHeight)*.6;const target=calls.filter(r=>r.method==='host.launcher.calibration.target').at(-1)!.params;
window.dispatchEvent(new CustomEvent('framely.launcher.gaze',{detail:{valid:false,targetId:target.id}}));
for(let n=0;n<80;n++){clock+=30;const headYaw=Math.sin(n/16)*.25,headPitch=Math.cos(n/19)*.12,translation=Math.sin(n/13)*.025;const yaw=tx-headYaw+translation,pitch=ty-headPitch;const raw=[(yaw-.045)/.93+Math.sin(n)*.001,(pitch+.035)/1.06+Math.cos(n)*.001];const c=Math.cos(headYaw),s=Math.sin(headYaw);window.dispatchEvent(new CustomEvent('framely.launcher.gaze',{detail:{valid:true,targetId:target.id,rawAngles:raw,targetAngles:[yaw,pitch],headPose:[c,0,s,translation,0,1,0,1.65,-s,0,c,0],x:rect.x+rect.width/2,y:rect.y+rect.height/2}}));await wait(3);}
if(completed===0)console.log('FRAMELY_PREVIEW_INSTALL_CALIBRATION_LIVE_EN');
await wait(80);completed++;
}
if(completed!==29)throw Error('Wrong collection/validation count '+completed);
await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_CALIBRATION_RESULT_EN');
button('Save calibration').click();await wait(100);const saved=calls.find(r=>r.method==='launcher.gaze.calibration.save')?.params;if(!saved||saved.error>.005)throw Error('No reliable calibration saved');
const q=correctedGaze({x:(.4-.045)/.93,y:(.6+.035)/1.06},saved);if(Math.hypot(q.x-.4,q.y-.6)>.005)throw Error('Correction did not recover known offset/scale');
configureLanguage('zh-CN',[]);root.render(<Launcher plugins={{}} safeMode={false} favorites={[]} refresh={async()=>{}}/>);await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_CALIBRATION_RESULT_ZH');button('完成').click();await wait(150);if(document.querySelector('.launcher-calibration'))throw Error('Calibration did not exit');if(!calls.some(r=>r.method==='host.launcher.calibrate'&&r.params.active===false))throw Error('Native calibration mode not disabled');if(calls.some(r=>r.method==='plugin.launch'))throw Error('Calibration launched real app');
console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
+21
View File
@@ -0,0 +1,21 @@
#include "launcher_gaze.h"
#include <cassert>
#include <limits>
int main(){
vr::VREyeTrackingData_t e{};vr::VROverlayIntersectionParams_t ray{};assert(!launcher_gaze_ray(e,ray));
e.bActive=e.bValid=e.bTracked=true;e.vGazeOrigin={{1,2,3}};e.vGazeTarget={{1,2,1}};assert(launcher_gaze_ray(e,ray));assert(ray.vDirection.v[2]==-1&&ray.vSource.v[0]==1);
e.vGazeTarget=e.vGazeOrigin;assert(!launcher_gaze_ray(e,ray));e.vGazeTarget.v[0]=std::numeric_limits<float>::quiet_NaN();assert(!launcher_gaze_ray(e,ray));
LauncherGazePress p;bool connected[2]{true,true},buttons[2]{true,false};
assert(p.update(true,connected,buttons)==LauncherGazePress::None); // entry held
buttons[0]=false;assert(p.update(true,connected,buttons)==LauncherGazePress::None);
buttons[0]=true;assert(p.update(true,connected,buttons)==LauncherGazePress::Down);
buttons[1]=true;assert(p.update(true,connected,buttons)==LauncherGazePress::None);
buttons[0]=false;assert(p.update(true,connected,buttons)==LauncherGazePress::Up);
assert(p.update(true,connected,buttons)==LauncherGazePress::None); // overlapping button doesn't re-click
buttons[1]=false;p.update(true,connected,buttons);buttons[1]=true;assert(p.update(true,connected,buttons)==LauncherGazePress::Down);
connected[1]=false;assert(p.update(true,connected,buttons)==LauncherGazePress::Cancel);
connected[1]=true;assert(p.update(true,connected,buttons)==LauncherGazePress::None);
buttons[1]=false;p.update(true,connected,buttons);buttons[0]=true;assert(p.update(true,connected,buttons)==LauncherGazePress::Down);
assert(p.update(false,connected,buttons)==LauncherGazePress::Cancel);
assert(p.update(true,connected,buttons)==LauncherGazePress::None);
}
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';import {createRoot} from 'react-dom/client';
import {Launcher} from '../ui/src/launcher';import {configureLanguage} from '../ui/src/i18n';import '../ui/src/style.css';
const wait=(ms:number)=>new Promise(r=>setTimeout(r,ms));const calls:string[]=[];
window.fetch=async(_url,init)=>{const {method}=JSON.parse(String(init?.body));calls.push(method);return new Response(JSON.stringify({result:method==='steam.list'||method==='desktop.list'?[]:method==='launcher.search.index'?{}:true}));};
const plugin:any={manifest:{id:'demo.test',name:'Gaze app',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:false,order:0};
createRoot(document.getElementById('root')!).render(<Launcher plugins={{'demo.test':plugin}} safeMode={false} favorites={[]} refresh={async()=>{}}/>);
function send(d:any){window.dispatchEvent(new CustomEvent('framely.launcher.gaze',{detail:d}));}
async function gaze(node:Element,duration=180){const b=node.getBoundingClientRect(),x=b.x+b.width/2,y=b.y+b.height/2;for(let t=0;t<duration;t+=30){send({valid:true,x,y});await wait(30);}}
async function hold(node:Element,duration:number){send({valid:true,phase:'down'});for(let t=0;t<duration;t+=30){send({valid:true,x:0,y:0});await wait(30);}}
(window as any).runInstallReviewChecks=async()=>{try{
configureLanguage('en-US',[]);const marker=document.createElement('div');marker.style.cssText='position:fixed;left:calc(50% - 2px);top:0;width:4px;height:20px;background:white';document.body.append(marker);await wait(900);
const icon=()=>document.querySelector<HTMLButtonElement>('.launch-icon[aria-label="Gaze app"]')!;
await gaze(icon());if(!icon().classList.contains('gaze-focused'))throw Error('No gaze focus');
const bounds=icon().getBoundingClientRect();for(let n=0;n<16;n++){send({valid:true,x:bounds.right+20+Math.sin(n)*8,y:bounds.y+bounds.height/2+Math.cos(n)*12});await wait(20);}if(!icon().classList.contains('gaze-focused'))throw Error('Edge jitter lost focus');
send({valid:false});await wait(90);if(!icon().classList.contains('gaze-focused'))throw Error('Brief dropout cleared hover');await gaze(icon());
send({valid:true,phase:'down'});await gaze(document.querySelector('.launcher-filters button')!,60);send({valid:true,phase:'up'});await wait(80);
if(calls.filter(m=>m==='plugin.launch').length!==1)throw Error('Press did not lock target');
await gaze(icon());await hold(icon(),450);send({valid:true,phase:'up'});await wait(80);
if(!document.querySelector('.launch-menu')||calls.filter(m=>m==='plugin.launch').length!==1)throw Error('400 ms hold did not open only menu');
window.dispatchEvent(new Event('framely.launcher.cancelInput'));await wait(80);
await gaze(icon());await hold(icon(),850);send({valid:true,phase:'up'});await wait(80);
if(!document.querySelector('.launcher-editing'))throw Error('800 ms hold did not edit');
window.dispatchEvent(new Event('framely.back'));await wait(80);
await gaze(icon());send({valid:true,phase:'down'});send({valid:false,phase:'cancel'});await wait(450);send({valid:true,phase:'up'});await wait(80);
if(document.querySelector('.launch-menu')||calls.filter(m=>m==='plugin.launch').length!==1)throw Error('Tracking loss launched or held');
await gaze(icon());await wait(360);if(icon().classList.contains('gaze-focused'))throw Error('Stale gaze remains active');
const search=document.querySelector<HTMLInputElement>('.launcher-search input')!;await gaze(search);send({valid:true,phase:'down'});send({valid:true,phase:'up'});if(document.activeElement!==search)throw Error('Search not focused');
window.dispatchEvent(new CustomEvent('framely.keyboard',{detail:true}));await gaze(icon());if(icon().classList.contains('gaze-focused'))throw Error('Gaze active behind keyboard');
window.dispatchEvent(new CustomEvent('framely.keyboard',{detail:false}));
const shortcuts=Array.from(document.querySelectorAll<HTMLButtonElement>('.launcher-shortcuts button'));
const calibration=shortcuts.find(b=>b.getAttribute('aria-label')==='Eye tracking calibration');
if(!calibration||shortcuts.indexOf(calibration)!==shortcuts.findIndex(b=>b.getAttribute('aria-label')==='Settings')-1)throw Error('Calibration shortcut is not above settings');
calibration.click();await wait(80);if(!calls.includes('host.launcher.calibrate'))throw Error('Calibration shortcut did not call native calibration');
console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
+1 -1
View File
@@ -88,7 +88,7 @@ async function until(check:()=>boolean){for(let i=0;i<60;i++){if(check())return;
const settings=[...document.querySelectorAll('nav button')].find(b=>b.textContent==='设置') as HTMLButtonElement;
settings.click();await until(()=>!!document.querySelector('.quick-settings'));
if(document.querySelector('.quick-settings input[type=file]')||document.querySelector('.quick-settings [download]'))throw Error('Quick settings exposes language installation or template download');
const safe=document.querySelector('.setting-toggle') as HTMLElement,languageCard=document.querySelector('.language-settings') as HTMLElement;
const safe=document.querySelector('[role=switch][aria-label="安全模式"]') as HTMLElement,languageCard=document.querySelector('.language-settings') as HTMLElement;
if(languageCard.getBoundingClientRect().top-safe.getBoundingClientRect().bottom<24)throw Error('Safe mode is too close to language divider');
button('界面语言').click();await wait();
const english=[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='English') as HTMLButtonElement;
+1 -1
View File
@@ -3,7 +3,7 @@ set -euo pipefail
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
cd "$base"
mkdir -p target/tests
for test in menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_fade launcher_pointer page_scroll paint_buffer render_timing; do
for test in menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing; do
"${CXX:-g++}" -std=c++17 -O2 -Inative -Inative/vendor/openvr "tests/$test.cpp" -o "target/tests/$test"
"target/tests/$test"
done
+60 -1
View File
@@ -628,6 +628,65 @@
"文件夹树": "Folders",
"收起 {0}": "Collapse {0}",
"展开 {0}": "Expand {0}",
"加载更多目录": "Load more folders"
"加载更多目录": "Load more folders",
"眼追校准": "Eye tracking calibration",
"开始校准": "Start calibration",
"清除校准": "Clear calibration",
"在 Frame 内打开模拟启动台,依次注视高亮目标。": "Open the simulated launcher on Frame and look at each highlighted target.",
"窗口尺寸发生变化,请重新开始校准。": "The window size changed. Restart calibration.",
"注视抖动过大,请重试当前目标。": "Gaze moved too much. Retry this target.",
"无法计算可靠校准,请重新开始。": "A reliable calibration could not be calculated. Restart.",
"验证误差过大,请重新校准;原校准保持不变。": "Validation error is too large. Recalibrate; your previous calibration is unchanged.",
"校准分类按钮": "Calibration category buttons",
"校准快捷按钮": "Calibration shortcut buttons",
"应用 {0}": "App {0}",
"验证校准": "Validate calibration",
"校准结果": "Calibration results",
"请注视高亮目标": "Look at the highlighted target",
"保持自然姿势,依次看高亮图标或按钮;无需点击。": "Keep a natural posture and look at each highlighted icon or button. No clicks needed.",
"校准已保存。": "Calibration saved.",
"确认结果后保存,或重新校准。": "Save the result or recalibrate.",
"只看高亮目标,不必盯住光点;等待进度完成。": "Look at the highlighted target, not the gaze dot. Wait for the progress to finish.",
"验证平均误差:{0} 像素": "Validation RMS error: {0} pixels",
"重新校准": "Recalibrate",
"保存校准": "Save calibration",
"重试当前目标": "Retry target",
"等待有效眼追,请保持佩戴并注视目标。": "Waiting for valid eye tracking. Keep your headset on and look at the target.",
"可以转头或移动头部来看清边缘目标;依次自然注视,无需点击。": "Turn or move your head to see edge targets. Look naturally at each highlight; no clicks needed.",
"眼追与目标的相对抖动过大,请重试当前目标;可以自然转头。": "Eye tracking varied too much relative to the target. Retry; natural head turns are allowed.",
"校准后与高亮目标的角度误差仍过大;查看各目标误差后重试,原校准保持不变。": "Corrected gaze is still too far from the highlighted targets. Review the target errors and retry; previous calibration is unchanged.",
"验证角度误差:平均 {0}°,最大 {1}°": "Validation angular error: RMS {0}°, maximum {1}°",
"无法定位曲面目标,请重新打开校准。": "Could not locate the curved target. Reopen calibration.",
"Invalid gaze calibration model": "Invalid gaze calibration model",
"Gaze calibration validation error exceeds the allowed limit": "Gaze calibration validation error exceeds the allowed limit",
"有效采样不足,请重试当前目标。": "Not enough valid samples. Retry this target.",
"验证目标偏差:均方根 {0}°,最差目标 {1}°": "Validation target bias: RMS {0}°, worst target {1}°",
"目标 {0}:偏差 {1}°,95%帧误差 {2}°,校准前 {3}°": "Target {0}: bias {1}°, 95th percentile frame error {2}°, before {3}°",
"校准进度": "Calibration progress",
"看向 {0}": "Look at {0}",
"等待眼动追踪": "Waiting for eye tracking",
"可以自然转头,无需按键。": "Turn your head naturally. No button press needed.",
"请保持佩戴,看向高亮目标。": "Keep your headset on and look at the highlight.",
"验证": "Validation",
"采集": "Sampling",
"当前目标进度": "Current target progress",
"校准需要重试": "Calibration needs another try",
"采集已暂停": "Sampling paused",
"校准完成": "Calibration complete",
"让注视更贴合启动台。": "Align your gaze with the launcher.",
"依次看向高亮图标或按钮,等待进度完成。": "Look at each highlighted icon or button until its progress finishes.",
"边缘看不清时可以转头,也可以移动头部。": "Turn or move your head to see targets at the edges.",
"无需点击目标;按 B 可随时退出。": "No need to click the targets. Press B to exit at any time.",
"先采集,再验证;完成后由你确认保存。": "Sample, then validate. Save when you are satisfied.",
"新的校准已应用到启动台。": "Your new calibration is active in the launcher.",
"之前的校准保持不变。": "Your previous calibration is unchanged.",
"验证已完成,保存后应用到启动台。": "Validation finished. Save to apply it to the launcher.",
"平均目标偏差": "RMS target bias",
"最差目标偏差": "Worst target bias",
"查看各目标误差": "View target errors",
"目标": "Target",
"校准前": "Before",
"校准后": "After",
"95%帧误差": "95th percentile"
}
}
+60 -1
View File
@@ -628,6 +628,65 @@
"文件夹树": "文件夹树",
"收起 {0}": "收起 {0}",
"展开 {0}": "展开 {0}",
"加载更多目录": "加载更多目录"
"加载更多目录": "加载更多目录",
"眼追校准": "眼追校准",
"开始校准": "开始校准",
"清除校准": "清除校准",
"在 Frame 内打开模拟启动台,依次注视高亮目标。": "在 Frame 内打开模拟启动台,依次注视高亮目标。",
"窗口尺寸发生变化,请重新开始校准。": "窗口尺寸发生变化,请重新开始校准。",
"注视抖动过大,请重试当前目标。": "注视抖动过大,请重试当前目标。",
"无法计算可靠校准,请重新开始。": "无法计算可靠校准,请重新开始。",
"验证误差过大,请重新校准;原校准保持不变。": "验证误差过大,请重新校准;原校准保持不变。",
"校准分类按钮": "校准分类按钮",
"校准快捷按钮": "校准快捷按钮",
"应用 {0}": "应用 {0}",
"验证校准": "验证校准",
"校准结果": "校准结果",
"请注视高亮目标": "请注视高亮目标",
"保持自然姿势,依次看高亮图标或按钮;无需点击。": "保持自然姿势,依次看高亮图标或按钮;无需点击。",
"校准已保存。": "校准已保存。",
"确认结果后保存,或重新校准。": "确认结果后保存,或重新校准。",
"只看高亮目标,不必盯住光点;等待进度完成。": "只看高亮目标,不必盯住光点;等待进度完成。",
"验证平均误差:{0} 像素": "验证平均误差:{0} 像素",
"重新校准": "重新校准",
"保存校准": "保存校准",
"重试当前目标": "重试当前目标",
"等待有效眼追,请保持佩戴并注视目标。": "等待有效眼追,请保持佩戴并注视目标。",
"可以转头或移动头部来看清边缘目标;依次自然注视,无需点击。": "可以转头或移动头部来看清边缘目标;依次自然注视,无需点击。",
"眼追与目标的相对抖动过大,请重试当前目标;可以自然转头。": "眼追与目标的相对抖动过大,请重试当前目标;可以自然转头。",
"校准后与高亮目标的角度误差仍过大;查看各目标误差后重试,原校准保持不变。": "校准后与高亮目标的角度误差仍过大;查看各目标误差后重试,原校准保持不变。",
"验证角度误差:平均 {0}°,最大 {1}°": "验证角度误差:平均 {0}°,最大 {1}°",
"无法定位曲面目标,请重新打开校准。": "无法定位曲面目标,请重新打开校准。",
"Invalid gaze calibration model": "校准模型参数无效,请重新开始校准。",
"Gaze calibration validation error exceeds the allowed limit": "校准验证误差超过允许范围,请查看各目标误差后重试。",
"有效采样不足,请重试当前目标。": "有效采样不足,请重试当前目标。",
"验证目标偏差:均方根 {0}°,最差目标 {1}°": "验证目标偏差:均方根 {0}°,最差目标 {1}°",
"目标 {0}:偏差 {1}°,95%帧误差 {2}°,校准前 {3}°": "目标 {0}:偏差 {1}°,95%帧误差 {2}°,校准前 {3}°",
"校准进度": "校准进度",
"看向 {0}": "看向 {0}",
"等待眼动追踪": "等待眼动追踪",
"可以自然转头,无需按键。": "可以自然转头,无需按键。",
"请保持佩戴,看向高亮目标。": "请保持佩戴,看向高亮目标。",
"验证": "验证",
"采集": "采集",
"当前目标进度": "当前目标进度",
"校准需要重试": "校准需要重试",
"采集已暂停": "采集已暂停",
"校准完成": "校准完成",
"让注视更贴合启动台。": "让注视更贴合启动台。",
"依次看向高亮图标或按钮,等待进度完成。": "依次看向高亮图标或按钮,等待进度完成。",
"边缘看不清时可以转头,也可以移动头部。": "边缘看不清时可以转头,也可以移动头部。",
"无需点击目标;按 B 可随时退出。": "无需点击目标;按 B 可随时退出。",
"先采集,再验证;完成后由你确认保存。": "先采集,再验证;完成后由你确认保存。",
"新的校准已应用到启动台。": "新的校准已应用到启动台。",
"之前的校准保持不变。": "之前的校准保持不变。",
"验证已完成,保存后应用到启动台。": "验证已完成,保存后应用到启动台。",
"平均目标偏差": "平均目标偏差",
"最差目标偏差": "最差目标偏差",
"查看各目标误差": "查看各目标误差",
"目标": "目标",
"校准前": "校准前",
"校准后": "校准后",
"95%帧误差": "95%帧误差"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
.apk-manager{width:100%;min-width:0}.apk-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px}.apk-tabs{display:flex;gap:8px;border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:22px}.apk-tabs button{background:transparent;border-color:transparent;color:var(--muted)}.apk-tabs button.active{background:#30343a;color:var(--text)}.apk-filters{display:flex;gap:12px;width:100%;margin-bottom:20px}.apk-filters input{flex:1;min-width:0;margin:0}.apk-filters select{width:240px;margin:0}.apk-row,.apk-container,.apk-cleanup-row{display:flex;align-items:center;gap:20px;padding:22px 0;border-bottom:1px solid var(--line);width:100%;min-width:0}.apk-copy{flex:1;min-width:0}.apk-copy h2{font-size:23px;display:flex;align-items:center;flex-wrap:wrap;gap:14px;margin:0 0 6px}.apk-copy h2 small{font-size:16px;font-weight:400;color:var(--muted)}.apk-copy code,.apk-location code,.apk-container code{font-size:16px;overflow-wrap:anywhere}.apk-copy p{color:var(--muted);font-size:16px;margin:7px 0 0}.apk-copy .error{color:#f7a7a7}.apk-notice{padding:14px 18px;background:#19282f;border-left:3px solid var(--blue)}.apk-error{display:flex;justify-content:space-between;gap:12px;white-space:pre-wrap;overflow-wrap:anywhere}.apk-error button{flex:none}.apk-locations{margin-top:30px;width:100%}.apk-locations>p,.apk-cleanup-heading p{color:var(--muted);font-size:17px}.apk-location{display:flex;gap:14px;align-items:center;justify-content:space-between;margin:12px 0}.apk-cleanup-heading{display:flex;align-items:center;justify-content:space-between;gap:20px}.apk-cleanup-row>small,.apk-copy>small{color:#d5b889}.apk-warnings{margin-top:24px;overflow-wrap:anywhere}.apk-dialog{position:fixed;z-index:40}.apk-dialog .apk-dialog-heading{display:flex;align-items:center;gap:18px;margin:20px 0}.apk-dialog-heading h2,.apk-dialog-heading h3{margin:0 0 8px}.apk-dialog-heading code{background:transparent;padding:0}.apk-meta{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:12px 24px;margin:24px 0}.apk-meta dt{color:var(--muted)}.apk-meta dd{margin:0;overflow-wrap:anywhere}.apk-dialog label{display:block;width:100%}.apk-dialog label select{width:100%;margin:12px 0}.apk-dialog footer{display:flex;justify-content:flex-end;gap:12px;border-top:1px solid var(--line);padding-top:20px;margin-top:24px}.apk-dialog footer button{margin:0}.apk-dialog .apk-actions{margin:20px 0}.apk-dialog .apk-actions button{margin:0}.apk-advanced{margin-top:24px}.apk-advanced p{font-size:17px;color:var(--muted)}.apk-confirm-copy{white-space:pre-wrap}.apk-dialog pre{max-height:50vh;overflow:auto}.apk-dialog progress{margin:18px 0}.apk-dialog .switch-row{width:100%}@media(max-width:700px){.apk-row,.apk-container{flex-wrap:wrap;gap:14px}.apk-row>.apk-actions,.apk-container>.apk-actions{width:100%}.apk-filters{flex-wrap:wrap}.apk-filters select{width:100%}.apk-cleanup-heading{flex-wrap:wrap}.apk-meta{grid-template-columns:minmax(0,1fr)}.apk-meta dd{margin-bottom:8px}.apk-location{flex-wrap:wrap}}
.apk-manager{width:100%;min-width:0}.apk-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px}.apk-tabs{display:flex;gap:8px;border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:22px}.apk-tabs button{background:transparent;border-color:transparent;color:var(--muted)}.apk-tabs button.active{background:#303030;color:var(--text)}.apk-filters{display:flex;gap:12px;width:100%;margin-bottom:20px}.apk-filters input{flex:1;min-width:0;margin:0}.apk-filters select{width:240px;margin:0}.apk-row,.apk-container,.apk-cleanup-row{display:flex;align-items:center;gap:20px;padding:22px 0;border-bottom:1px solid var(--line);width:100%;min-width:0}.apk-copy{flex:1;min-width:0}.apk-copy h2{font-size:23px;display:flex;align-items:center;flex-wrap:wrap;gap:14px;margin:0 0 6px}.apk-copy h2 small{font-size:16px;font-weight:400;color:var(--muted)}.apk-copy code,.apk-location code,.apk-container code{font-size:16px;overflow-wrap:anywhere}.apk-copy p{color:var(--muted);font-size:16px;margin:7px 0 0}.apk-copy .error{color:#f7a7a7}.apk-notice{padding:14px 18px;background:var(--overlay-bg);border-left:3px solid var(--blue)}.apk-error{display:flex;justify-content:space-between;gap:12px;white-space:pre-wrap;overflow-wrap:anywhere}.apk-error button{flex:none}.apk-locations{margin-top:30px;width:100%}.apk-locations>p,.apk-cleanup-heading p{color:var(--muted);font-size:17px}.apk-location{display:flex;gap:14px;align-items:center;justify-content:space-between;margin:12px 0}.apk-cleanup-heading{display:flex;align-items:center;justify-content:space-between;gap:20px}.apk-cleanup-row>small,.apk-copy>small{color:#d5b889}.apk-warnings{margin-top:24px;overflow-wrap:anywhere}.apk-dialog{position:fixed;z-index:40}.apk-dialog .apk-dialog-heading{display:flex;align-items:center;gap:18px;margin:20px 0}.apk-dialog-heading h2,.apk-dialog-heading h3{margin:0 0 8px}.apk-dialog-heading code{background:transparent;padding:0}.apk-meta{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:12px 24px;margin:24px 0}.apk-meta dt{color:var(--muted)}.apk-meta dd{margin:0;overflow-wrap:anywhere}.apk-dialog label{display:block;width:100%}.apk-dialog label select{width:100%;margin:12px 0}.apk-dialog footer{display:flex;justify-content:flex-end;gap:12px;border-top:1px solid var(--line);padding-top:20px;margin-top:24px}.apk-dialog footer button{margin:0}.apk-dialog .apk-actions{margin:20px 0}.apk-dialog .apk-actions button{margin:0}.apk-advanced{margin-top:24px}.apk-advanced p{font-size:17px;color:var(--muted)}.apk-confirm-copy{white-space:pre-wrap}.apk-dialog pre{max-height:50vh;overflow:auto}.apk-dialog progress{margin:18px 0}.apk-dialog .switch-row{width:100%}@media(max-width:700px){.apk-row,.apk-container{flex-wrap:wrap;gap:14px}.apk-row>.apk-actions,.apk-container>.apk-actions{width:100%}.apk-filters{flex-wrap:wrap}.apk-filters select{width:100%}.apk-cleanup-heading{flex-wrap:wrap}.apk-meta{grid-template-columns:minmax(0,1fr)}.apk-meta dd{margin-bottom:8px}.apk-location{flex-wrap:wrap}}
.apk-modal-backdrop{position:fixed;inset:0;z-index:40;background:#0009}.apk-modal-backdrop .modal{box-shadow:0 20px 80px #0008}
+19 -19
View File
@@ -1,39 +1,39 @@
/* Framely's existing dark interface, with a quieter file-browser hierarchy. */
.frame-files-backdrop{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;padding:28px;background:#090d14b8}
.frame-files{--files-muted:#a3adb9;--files-line:#ffffff12;--files-accent:#a8d5f4;width:min(1160px,100%);height:min(690px,calc(100dvh - 56px));display:flex;flex-direction:column;background:#1a2028;color:#edf2f7;border:1px solid #ffffff24;border-radius:18px;box-shadow:0 32px 100px #0008;overflow:hidden;font:18px/1.4 system-ui,-apple-system,sans-serif}
.frame-files-backdrop{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;padding:28px;background:#000000b8}
.frame-files{--files-muted:#a3adb9;--files-line:#ffffff12;--files-accent:#a8d5f4;width:min(1160px,100%);height:min(690px,calc(100dvh - 56px));display:flex;flex-direction:column;background:var(--overlay-bg);color:#edf2f7;border:1px solid #ffffff24;border-radius:18px;box-shadow:0 32px 100px #0008;overflow:hidden;font:18px/1.4 system-ui,-apple-system,sans-serif}
.frame-files button{font:inherit;min-height:48px;margin:0;border-radius:10px;white-space:nowrap;transition:background 120ms,color 120ms;border-color:transparent}
.frame-files button:focus-visible,.frame-files input:focus-visible{outline:2px solid var(--files-accent);outline-offset:2px}
.frame-files button:disabled{opacity:.38}
.frame-files header{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:14px 22px;background:#1d242d;border-bottom:1px solid var(--files-line);flex:none}
.frame-files-heading{display:flex;align-items:center;gap:14px}.frame-files-heading-icon{display:grid;place-items:center;width:48px;height:48px;border-radius:12px;background:#2a3a49;color:var(--files-accent)}
.frame-files header{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:14px 22px;background:#1c1c1c80;border-bottom:1px solid var(--files-line);flex:none}
.frame-files-heading{display:flex;align-items:center;gap:14px}.frame-files-heading-icon{display:grid;place-items:center;width:48px;height:48px;border-radius:12px;background:#ffffff0d;color:var(--files-accent)}
.frame-files header h2{font-size:23px;letter-spacing:-.35px;font-weight:600;line-height:1.2;margin:0 0 5px}.frame-files header small{font-size:15px;color:var(--files-muted)}
.frame-files .frame-files-icon-button{width:52px;padding:0;background:transparent;color:#b7c3ce;flex:none}.frame-files .frame-files-icon-button:hover:not(:disabled){background:#ffffff0c;color:#f1f6fb}.frame-files .frame-files-icon-button.active{background:#304456;color:var(--files-accent)}
.frame-files .frame-files-icon-button{width:52px;padding:0;background:transparent;color:#b7c3ce;flex:none}.frame-files .frame-files-icon-button:hover:not(:disabled){background:#ffffff0c;color:#f1f6fb}.frame-files .frame-files-icon-button.active{background:var(--control-selected);color:var(--files-accent)}
.frame-files-body{display:grid;grid-template-columns:242px minmax(0,1fr);flex:1;min-height:0}
.frame-files aside{padding:20px 12px;background:#161c23;border-right:1px solid var(--files-line);overflow:auto}
.frame-files aside{padding:20px 12px;background:#14141470;border-right:1px solid var(--files-line);overflow:auto}
.frame-files-sidebar-label{font-size:14px;font-weight:500;color:var(--files-muted);padding:0 16px;margin:0 0 14px}
.frame-files aside button{width:100%;padding:14px 16px;display:flex;align-items:center;justify-content:flex-start;text-align:left;gap:13px;min-height:58px;margin-bottom:5px;background:transparent;color:#b7c1cb;font-size:18px}
.frame-files aside button span{min-width:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.frame-files aside button.active{background:#293b4c;color:#c1e4fc}.frame-files aside button:hover:not(:disabled){background:#ffffff09}.frame-files aside button.active:hover:not(:disabled){background:#304456}
.frame-files aside button span{min-width:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.frame-files aside button.active{background:#ffffff18;color:#c1e4fc}.frame-files aside button:hover:not(:disabled){background:#ffffff09}.frame-files aside button.active:hover:not(:disabled){background:var(--control-selected)}
.frame-files section{min-width:0;min-height:0;padding:16px 22px 10px;display:flex;flex-direction:column;gap:10px}
.frame-files-location{display:flex;align-items:center;gap:8px}.frame-files-navigation{display:flex;flex:none;gap:2px}.frame-files-path{display:flex;align-items:center;gap:10px;flex:1;min-width:0;padding:0 8px 0 12px;background:#141a21;border:1px solid #ffffff0f;border-radius:10px;color:var(--files-muted)}
.frame-files-location{display:flex;align-items:center;gap:8px}.frame-files-navigation{display:flex;flex:none;gap:2px}.frame-files-path{display:flex;align-items:center;gap:10px;flex:1;min-width:0;padding:0 8px 0 12px;background:#10101090;border:1px solid #ffffff0f;border-radius:10px;color:var(--files-muted)}
.frame-files .frame-files-path input:not([type=checkbox]):not([type=file]){margin:0;min-height:48px;padding:9px 0;flex:1;min-width:0;width:auto;border:0;border-radius:0;background:transparent;color:#ccd6df;font-size:16px;outline-offset:0}
.frame-files .frame-files-go{padding:0 9px;min-height:44px;min-width:48px;background:#26303b;color:#c1ccd7;font-size:15px;border-radius:7px}.frame-files .frame-files-go:hover:not(:disabled){background:#364350}
.frame-files-toolbar{display:flex;gap:10px;align-items:center}.frame-files-search{display:flex;align-items:center;gap:12px;flex:1;min-width:0;padding:0 15px;background:#141a21;border:1px solid #ffffff12;border-radius:10px;color:#9aabba}
.frame-files .frame-files-go{padding:0 9px;min-height:44px;min-width:48px;background:#ffffff0d;color:#c1ccd7;font-size:15px;border-radius:7px}.frame-files .frame-files-go:hover:not(:disabled){background:var(--control-hover)}
.frame-files-toolbar{display:flex;gap:10px;align-items:center}.frame-files-search{display:flex;align-items:center;gap:12px;flex:1;min-width:0;padding:0 15px;background:#10101090;border:1px solid #ffffff12;border-radius:10px;color:#9aabba}
.frame-files .frame-files-search input:not([type=checkbox]):not([type=file]){margin:0;padding:10px 0;min-height:48px;min-width:0;border:0;background:transparent;font-size:18px}.frame-files .frame-files-search input::placeholder{color:#9aa9b7}.frame-files-search:focus-within{border-color:#a8d5f477}
.frame-files-options-anchor{position:relative}.frame-files-options{position:absolute;right:0;top:calc(100% + 8px);z-index:5;width:330px;padding:12px 18px;background:#252e39;border:1px solid #ffffff24;border-radius:12px;box-shadow:0 12px 35px #0006}.frame-files-options label{display:flex;justify-content:space-between;align-items:center;gap:20px;font-size:17px;margin:0;padding:12px 0}.frame-files-options .ui-switch{min-height:44px}
.frame-files-options-anchor{position:relative}.frame-files-options{position:absolute;right:0;top:calc(100% + 8px);z-index:5;width:330px;padding:12px 18px;background:#1c1c1cf2;border:1px solid #ffffff24;border-radius:12px;box-shadow:0 12px 35px #0006}.frame-files-options label{display:flex;justify-content:space-between;align-items:center;gap:20px;font-size:17px;margin:0;padding:12px 0}.frame-files-options .ui-switch{min-height:44px}
.frame-files-directory{display:flex;justify-content:space-between;align-items:center;padding:8px 4px 2px;min-height:38px}.frame-files-directory h3{margin:0;font-size:21px;font-weight:550;letter-spacing:-.3px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-files-directory small{flex:none;font-size:15px;color:var(--files-muted);margin-left:14px}
.frame-files-columns{display:grid;grid-template-columns:minmax(0,1fr) 100px 118px 24px;gap:16px;padding:0 16px 9px;color:var(--files-muted);font-size:14px}.frame-files-columns span:first-child{padding-left:60px}
.frame-files-list{flex:1;min-height:0;overflow:auto;padding:2px 2px 4px;scrollbar-gutter:stable}
.frame-files .frame-file-row{width:100%;min-height:68px;display:grid;grid-template-columns:44px minmax(0,1fr) 100px 118px 24px;align-items:center;text-align:left;gap:16px;padding:8px 14px;background:transparent;border:1px solid transparent;margin-bottom:3px;border-radius:10px;color:#e2e9ef;white-space:normal}
.frame-files .frame-file-row:hover:not(:disabled){background:#ffffff07}.frame-files .frame-file-row.selected{background:#2b4053;border-color:#9bcff42e}.frame-files .frame-file-row.selected:hover:not(:disabled){background:#314a60}
.frame-file-symbol{display:grid;place-items:center;width:44px;height:44px;border-radius:10px;background:#252f3a;color:#afbecb}.frame-file-symbol.folder{color:#aecbe0;background:#243442}.frame-file-symbol.apk{color:#b8dcf7;background:#293e52}
.frame-file-name{display:flex;flex-direction:column;gap:4px;min-width:0}.frame-file-name b{font-size:18px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-file-name small{font-size:13px;letter-spacing:.2px;color:var(--files-muted)}.frame-file-size,.frame-file-modified{font-size:15px;color:#aab7c3;font-variant-numeric:tabular-nums}.frame-file-check{display:grid;place-items:center;width:24px;height:24px;color:#8ba1b3}.selected .frame-file-check{border-radius:50%;background:var(--files-accent);color:#172635}
.frame-files .frame-file-row:hover:not(:disabled){background:#ffffff07}.frame-files .frame-file-row.selected{background:#ffffff18;border-color:#9bcff42e}.frame-files .frame-file-row.selected:hover:not(:disabled){background:#ffffff24}
.frame-file-symbol{display:grid;place-items:center;width:44px;height:44px;border-radius:10px;background:#ffffff0a;color:#afbecb}.frame-file-symbol.folder{color:#aecbe0;background:#ffffff0d}.frame-file-symbol.apk{color:#b8dcf7;background:#ffffff0d}
.frame-file-name{display:flex;flex-direction:column;gap:4px;min-width:0}.frame-file-name b{font-size:18px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-file-name small{font-size:13px;letter-spacing:.2px;color:var(--files-muted)}.frame-file-size,.frame-file-modified{font-size:15px;color:#aab7c3;font-variant-numeric:tabular-nums}.frame-file-check{display:grid;place-items:center;width:24px;height:24px;color:#8ba1b3}.selected .frame-file-check{border-radius:50%;background:var(--control-selected);color:#f3f3f3}
.frame-files-loading,.frame-files-empty{min-height:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;color:var(--files-muted)}.frame-files-loading p,.frame-files-empty .empty{padding:0;margin:0;font-size:18px}
.frame-files footer{border-top:1px solid var(--files-line);background:#1d242d;padding:12px 22px;display:flex;align-items:center;gap:12px;flex:none}
.frame-files-selection{flex:1;display:flex;align-items:center;gap:12px;min-width:0;color:#aab7c3;font-size:16px}.frame-files-selection>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-files-selection small{display:block;font-size:13px;color:var(--files-muted);margin-top:3px}.frame-files-selection-icon{width:38px;height:38px;display:grid;place-items:center;background:#27313c;border-radius:10px;flex:none}.frame-files-selection.has-selection{color:#e3edf5}.has-selection .frame-files-selection-icon{background:#2b4053;color:var(--files-accent)}
.frame-files .frame-files-cancel{padding:10px 23px;background:#2a333e;color:#d6e1e9}.frame-files .frame-files-cancel:hover:not(:disabled){background:#36424f}.frame-files .frame-files-confirm{min-width:112px;padding:10px 25px;background:var(--files-accent);color:#132637;font-weight:600}.frame-files .frame-files-confirm:hover:not(:disabled){background:#c7e8ff}
.frame-files-pages{display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--files-muted)}.frame-files-pages button{font-size:16px;background:#27313c;padding:8px 16px}.frame-files .error{margin:0;display:flex;align-items:center;gap:12px;justify-content:space-between;padding:10px 12px;background:#3b262d;border-radius:8px;font-size:16px}
.frame-files footer{border-top:1px solid var(--files-line);background:#1c1c1c80;padding:12px 22px;display:flex;align-items:center;gap:12px;flex:none}
.frame-files-selection{flex:1;display:flex;align-items:center;gap:12px;min-width:0;color:#aab7c3;font-size:16px}.frame-files-selection>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-files-selection small{display:block;font-size:13px;color:var(--files-muted);margin-top:3px}.frame-files-selection-icon{width:38px;height:38px;display:grid;place-items:center;background:#ffffff0a;border-radius:10px;flex:none}.frame-files-selection.has-selection{color:#e3edf5}.has-selection .frame-files-selection-icon{background:#ffffff18;color:var(--files-accent)}
.frame-files .frame-files-cancel{padding:10px 23px;background:#ffffff0d;color:#d6e1e9}.frame-files .frame-files-cancel:hover:not(:disabled){background:var(--control-hover)}.frame-files .frame-files-confirm{min-width:112px;padding:10px 25px;background:var(--control-selected);color:#f3f3f3;font-weight:600}.frame-files .frame-files-confirm:hover:not(:disabled){background:#ffffff30}
.frame-files-pages{display:flex;align-items:center;justify-content:space-between;font-size:15px;color:var(--files-muted)}.frame-files-pages button{font-size:16px;background:#ffffff0a;padding:8px 16px}.frame-files .error{margin:0;display:flex;align-items:center;gap:12px;justify-content:space-between;padding:10px 12px;background:#3b262d;border-radius:8px;font-size:16px}
@media(prefers-reduced-motion:no-preference){.frame-files{animation:frame-files-enter 160ms ease-out}@keyframes frame-files-enter{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}}
@media(prefers-reduced-motion:reduce){.frame-files button{transition:none}}
@media(max-width:1024px){.frame-files-body{grid-template-columns:190px minmax(0,1fr)}.frame-files section{padding:16px}.frame-files-columns{grid-template-columns:minmax(0,1fr) 86px 24px}.frame-files-columns span:nth-child(3){display:none}.frame-files .frame-file-row{grid-template-columns:44px minmax(0,1fr) 86px 24px}.frame-file-modified{display:none}.frame-files aside button{padding:14px 12px;font-size:17px}}
@media(max-width:768px){.frame-files-backdrop{padding:12px}.frame-files{height:calc(100dvh - 24px);border-radius:12px}.frame-files-body{grid-template-columns:150px minmax(0,1fr)}.frame-files-navigation{display:none}.frame-files aside{padding:18px 8px}.frame-files aside button{gap:8px;font-size:16px}.frame-files aside button>svg:last-child:not(:first-child){display:none}.frame-files header{padding:16px}.frame-files footer{padding:14px 16px}.frame-files-selection-icon{display:none}.frame-files-options{width:290px}.frame-files .frame-file-row{grid-template-columns:36px minmax(0,1fr) 24px;gap:10px}.frame-file-symbol{width:36px;height:44px}.frame-file-size{display:none}.frame-files-columns{grid-template-columns:minmax(0,1fr) 24px}.frame-files-columns span:nth-child(2){display:none}.frame-files-columns span:first-child{padding-left:46px}}
.frame-folder-tree{width:100%;min-width:0}.frame-folder-row{display:flex;align-items:center;min-height:48px;border-radius:8px;margin-bottom:3px;padding-right:8px;color:#b8c6d1}.frame-folder-row.active{background:#293f52;color:#c9e9ff}.frame-files .frame-folder-row>button{margin:0;padding:0;background:transparent;color:inherit;min-height:48px;border:0;border-radius:6px}.frame-files .frame-folder-toggle{width:30px;flex:none;justify-content:center}.frame-files .frame-folder-name{flex:1;min-width:0;display:flex;align-items:center;justify-content:flex-start;gap:10px;text-align:left;font-size:17px}.frame-folder-name span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-files .frame-folder-row>button:hover:not(:disabled){background:#ffffff08}.frame-folder-toggle svg{transition:transform 120ms}.frame-folder-status{display:flex;align-items:center;gap:8px;min-height:36px;font-size:14px;color:var(--files-muted)}.frame-files aside .frame-folder-retry,.frame-files aside .frame-folder-more{width:auto;max-width:100%;font-size:14px;min-height:44px;margin:2px 0 6px 28px;padding:6px 10px;white-space:normal}.frame-files aside .frame-folder-name.active{background:transparent}@media(prefers-reduced-motion:reduce){.frame-folder-toggle svg{transition:none}}
.frame-folder-tree{width:100%;min-width:0}.frame-folder-row{display:flex;align-items:center;min-height:48px;border-radius:8px;margin-bottom:3px;padding-right:8px;color:#b8c6d1}.frame-folder-row.active{background:var(--control-selected);color:#c9e9ff}.frame-files .frame-folder-row>button{margin:0;padding:0;background:transparent;color:inherit;min-height:48px;border:0;border-radius:6px}.frame-files .frame-folder-toggle{width:30px;flex:none;justify-content:center}.frame-files .frame-folder-name{flex:1;min-width:0;display:flex;align-items:center;justify-content:flex-start;gap:10px;text-align:left;font-size:17px}.frame-folder-name span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.frame-files .frame-folder-row>button:hover:not(:disabled){background:#ffffff08}.frame-folder-toggle svg{transition:transform 120ms}.frame-folder-status{display:flex;align-items:center;gap:8px;min-height:36px;font-size:14px;color:var(--files-muted)}.frame-files aside .frame-folder-retry,.frame-files aside .frame-folder-more{width:auto;max-width:100%;font-size:14px;min-height:44px;margin:2px 0 6px 28px;padding:6px 10px;white-space:normal}.frame-files aside .frame-folder-name.active{background:transparent}@media(prefers-reduced-motion:reduce){.frame-folder-toggle svg{transition:none}}
+35
View File
@@ -0,0 +1,35 @@
export type GazePoint={x:number;y:number};
export type GazeCalibration={space:'headAngles';matrix:number[];width:number;height:number;error:number;maxError:number};
export type CalibrationPair={raw:GazePoint;target:GazePoint;group?:number};
export function correctedGaze(p:GazePoint,c?:GazeCalibration|null):GazePoint{if(!c)return p;const m=c.matrix;return {x:m[0]*p.x+m[1]*p.y+m[2],y:m[3]*p.x+m[4]*p.y+m[5]};}
export function angularError(a:GazePoint,b:GazePoint){const dot=Math.sin(a.y)*Math.sin(b.y)+Math.cos(a.y)*Math.cos(b.y)*Math.cos(a.x-b.x);return Math.acos(Math.max(-1,Math.min(1,dot)));}
export function median(values:number[]){const v=[...values].sort((a,b)=>a-b);return v.length%2?v[(v.length-1)/2]:(v[v.length/2-1]+v[v.length/2])/2;}
export function fixation(samples:GazePoint[]){const center={x:median(samples.map(p=>p.x)),y:median(samples.map(p=>p.y))};const spread=median(samples.map(p=>Math.hypot(p.x-center.x,p.y-center.y)));return {center,spread};}
// Average paired directions over short time slices, never raw gaze alone.
// Head movement remains in both sides of each pair. This reduces the noisy
// regressor bias of fitting gains against every individual eye camera frame.
export function calibrationBlocks(samples:CalibrationPair[],group:number,size=6):CalibrationPair[]{
const blocks:CalibrationPair[]=[];for(let i=0;i+size<=samples.length;i+=size){const part=samples.slice(i,i+size);const average=(field:'raw'|'target')=>({x:part.reduce((s,p)=>s+p[field].x,0)/size,y:part.reduce((s,p)=>s+p[field].y,0)/size});blocks.push({raw:average('raw'),target:average('target'),group});}return blocks;
}
export function calibrationValidation(samples:CalibrationPair[],model:GazeCalibration){
const residuals=samples.map(p=>{const q=correctedGaze(p.raw,model);return {x:q.x-p.target.x,y:q.y-p.target.y};});
const center={x:median(residuals.map(p=>p.x)),y:median(residuals.map(p=>p.y))};
// Measure fixation bias in each frame's coordinates, then take its median.
// A world/window median would mix together different head poses.
const bias=median(samples.map(p=>angularError({x:p.target.x+center.x,y:p.target.y+center.y},p.target)));
const errors=samples.map(p=>angularError(correctedGaze(p.raw,model),p.target)).sort((a,b)=>a-b);
const baseline=median(samples.map(p=>angularError(p.raw,p.target)));
return {bias,baseline,p95:errors[Math.min(errors.length-1,Math.floor(errors.length*.95))],jitter:fixation(residuals).spread};
}
export function fitCalibration(pairs:CalibrationPair[],width:number,height:number):GazeCalibration|null{
if(pairs.length<6)return null;
const counts=new Map<number,number>();for(const p of pairs)counts.set(p.group??0,(counts.get(p.group??0)??0)+1);
const base=pairs.map(p=>1/counts.get(p.group??0)!);let weights=[...base],matrix:number[]=[];
const solve=(axis:'x'|'y')=>{const a=Array.from({length:3},()=>[0,0,0,0]);pairs.forEach((p,n)=>{const row=[p.raw.x,p.raw.y,1],w=weights[n];for(let i=0;i<3;i++){for(let j=0;j<3;j++)a[i][j]+=w*row[i]*row[j];a[i][3]+=w*row[i]*p.target[axis];}});
const prior=weights.reduce((s,w)=>s+w,0)*.0005;a[0][0]+=prior;a[1][1]+=prior;a[axis==='x'?0:1][3]+=prior;
for(let i=0;i<3;i++){let pivot=i;for(let j=i+1;j<3;j++)if(Math.abs(a[j][i])>Math.abs(a[pivot][i]))pivot=j;[a[i],a[pivot]]=[a[pivot],a[i]];if(Math.abs(a[i][i])<1e-7)return null;const divisor=a[i][i];for(let k=i;k<4;k++)a[i][k]/=divisor;for(let j=0;j<3;j++)if(j!==i){const factor=a[j][i];for(let k=i;k<4;k++)a[j][k]-=factor*a[i][k];}}return a.map(row=>row[3]);};
// Huber reweighting stops a bad fixation from steering the whole fit.
for(let iteration=0;iteration<6;iteration++){const x=solve('x'),y=solve('y');if(!x||!y)return null;matrix=[...x,...y];const candidate={space:'headAngles' as const,matrix,width,height,error:0,maxError:0};const errors=pairs.map(p=>angularError(correctedGaze(p.raw,candidate),p.target));const cutoff=Math.max(.008,median(errors)*2);weights=errors.map((e,i)=>base[i]*Math.min(1,cutoff/Math.max(e,1e-9)));}
const det=matrix[0]*matrix[4]-matrix[1]*matrix[3];if(matrix.some(v=>!Number.isFinite(v)||Math.abs(v)>4)||det<.25||det>4||Math.abs(matrix[2])>.5||Math.abs(matrix[5])>.5)return null;
const result:GazeCalibration={space:'headAngles',matrix,width,height,error:0,maxError:0};const errors=pairs.map(p=>angularError(correctedGaze(p.raw,result),p.target));result.error=Math.sqrt(errors.reduce((sum,e)=>sum+e*e,0)/errors.length);result.maxError=Math.max(...errors);return result;
}
+71
View File
@@ -0,0 +1,71 @@
import React,{useEffect,useRef,useState} from 'react';
import {IconApps,IconStar,IconPuzzle,IconBrandSteam,IconBrandAndroid,IconDeviceDesktop,IconPackageImport,IconSettings,IconSearch,IconEye,IconCheck,IconAlertCircle} from '@tabler/icons-react';
import {api} from './api';import {t} from './i18n';
import {fitCalibration,fixation,calibrationBlocks,calibrationValidation,type CalibrationPair,type GazeCalibration} from './gaze-calibration';
import type {GazeSample} from './launcher-gaze';
const categoryIcons=[IconStar,IconApps,IconPuzzle,IconBrandSteam,IconBrandAndroid,IconDeviceDesktop],actionIcons=[IconPackageImport,IconApps,IconEye,IconSettings];
export function LauncherCalibration({cells,onClose}:{cells:{row:number;column:number}[];onClose:()=>void}){
const [phase,setPhase]=useState<'intro'|'collect'|'verify'|'result'>('intro'),[step,setStep]=useState(0),[progress,setProgress]=useState(0),[tracking,setTracking]=useState(false),[problem,setProblem]=useState(''),[result,setResult]=useState<GazeCalibration|null>(null),[saving,setSaving]=useState(false),[saved,setSaved]=useState(false);
const root=useRef<HTMLDivElement>(null),pairs=useRef<CalibrationPair[]>([]),validation=useRef<CalibrationPair[]>([]),model=useRef<GazeCalibration|null>(null),samples=useRef<CalibrationPair[]>([]),entered=useRef(0),last=useRef(0),generation=useRef(0),geometry=useRef({width:0,height:0}),[targetErrors,setTargetErrors]=useState<{target:number;error:number;p95:number;baseline:number}[]>([]);
const summaries=useRef<ReturnType<typeof calibrationValidation>[]>([]);
const targets=phase==='verify'?[2,6,10,15,21]:Array.from({length:24},(_,n)=>n),active=phase==='collect'||phase==='verify'?targets[step]:-1;
function start(){generation.current++;pairs.current=[];validation.current=[];summaries.current=[];model.current=null;geometry.current={width:window.innerWidth,height:window.innerHeight};setSaved(false);setResult(null);setTargetErrors([]);setProblem('');setStep(0);setPhase('collect');}
useEffect(()=>{if(active<0||problem)return;samples.current=[];entered.current=performance.now();last.current=0;setProgress(0);const nonce=generation.current;let live=true;
const targetId=`${nonce}:${phase}:${step}:${performance.now()}`;
const node=root.current?.querySelector<HTMLElement>(`[data-calibration-target="${active}"]`);if(!node)return;const bounds=node.getBoundingClientRect();
void api('host.launcher.calibration.target',{id:targetId,x:(bounds.left+bounds.width/2)/window.innerWidth,y:(bounds.top+bounds.height/2)/window.innerHeight,preview:phase==='verify'?model.current:null}).catch(e=>{if(live)setProblem(String(e));});
const sample=(e:Event)=>{const d=(e as CustomEvent<GazeSample>).detail,now=performance.now();if(d?.targetId!==targetId)return;
if(d.targetUnavailable){setProblem(t('无法定位曲面目标,请重新打开校准。'));return;}
if(!d.valid||d.phase==='cancel'){samples.current=[];entered.current=now;last.current=0;setProgress(0);setTracking(false);return;}
if(!d.rawAngles||!d.targetAngles||!d.headPose||d.headPose.length!==12||![...d.rawAngles,...d.targetAngles,...d.headPose].every(Number.isFinite))return;
last.current=now;setTracking(true);if(now-entered.current<700)return;
// Each raw direction is paired with its target using that same frame's
// eye origin and head pose, so head movement is not fixation jitter.
samples.current.push({raw:{x:d.rawAngles[0],y:d.rawAngles[1]},target:{x:d.targetAngles[0],y:d.targetAngles[1]}});
};
const timer=window.setInterval(()=>{const now=performance.now();if(window.innerWidth!==geometry.current.width||window.innerHeight!==geometry.current.height){setProblem(t('窗口尺寸发生变化,请重新开始校准。'));return;}if(!last.current||now-last.current>180){samples.current=[];entered.current=now;setProgress(0);setTracking(false);return;}setProgress(Math.max(0,Math.min(1,samples.current.length/30,(now-entered.current-700)/1400)));if(now-entered.current<2100||samples.current.length<30)return;
const residuals=samples.current.map(p=>({x:p.raw.x-p.target.x,y:p.raw.y-p.target.y})),{center,spread}=fixation(residuals);
if(spread>.04){samples.current=[];entered.current=now;setProgress(0);setProblem(t('眼追与目标的相对抖动过大,请重试当前目标;可以自然转头。'));return;}
// Reject transient eye outliers, not different head positions. Keep
// the remaining per-frame pairs; never median together different poses.
const accepted=samples.current.filter((_,i)=>Math.hypot(residuals[i].x-center.x,residuals[i].y-center.y)<=Math.max(.02,spread*3));
if(accepted.length<30){samples.current=[];entered.current=now;setProgress(0);return;}
clearInterval(timer);if(generation.current!==nonce)return;
const blocks=calibrationBlocks(accepted,active);if(blocks.length<5){setProblem(t('有效采样不足,请重试当前目标。'));return;}
if(phase==='collect'){pairs.current.push(...blocks);console.log('FRAMELY_GAZE_TARGET '+JSON.stringify({target:active,blocks}));}
else{validation.current.push(...accepted);const summary=calibrationValidation(accepted,model.current!);summaries.current.push(summary);setTargetErrors(v=>[...v,{target:active,error:summary.bias,p95:summary.p95,baseline:summary.baseline}]);console.log('FRAMELY_GAZE_VALIDATION '+JSON.stringify({target:active,...summary}));}
if(step<targets.length-1){setStep(step+1);return;}
if(phase==='collect'){const fitted=fitCalibration(pairs.current,window.innerWidth,window.innerHeight);if(!fitted){setProblem(t('无法计算可靠校准,请重新开始。'));return;}model.current=fitted;console.log('FRAMELY_GAZE_MODEL '+JSON.stringify(fitted));setStep(0);setPhase('verify');}
else{const fitted=model.current!,errors=summaries.current.map(p=>p.bias),error=Math.sqrt(errors.reduce((s,e)=>s+e*e,0)/errors.length),maxError=Math.max(...errors);const output={...fitted,error,maxError};setResult(output);setPhase('result');console.log('FRAMELY_GAZE_RESULT '+JSON.stringify({model:output,targets:summaries.current}));if(error>.035||maxError>.065)setProblem(t('校准后与高亮目标的角度误差仍过大;查看各目标误差后重试,原校准保持不变。'));}
},50);window.addEventListener('framely.launcher.gaze',sample);return()=>{live=false;clearInterval(timer);window.removeEventListener('framely.launcher.gaze',sample);};
},[phase,step,problem]);
useEffect(()=>()=>{generation.current++;},[]);
async function save(){if(!result||problem||saving)return;setSaving(true);try{await api('launcher.gaze.calibration.save',result);window.dispatchEvent(new Event('framely.gaze.calibration.changed'));setSaved(true);}catch(e){setProblem(String(e));}finally{setSaving(false);}}
const targetClass=(id:number)=>active===id?'calibration-target-active':'';
const targetName=(id:number)=>id<13?t('应用 {0}',{0:id+1}):id<19?t(['收藏','全部','插件','Steam','Lepton','桌面程序'][id-13]):id<23?t(['安装APK','插件库','眼追校准','设置'][id-19]):t('搜索应用');
const collecting=phase==='collect'||phase==='verify';
const overlay=phase==='intro'||phase==='result'||!!problem;
const title=problem?t(phase==='result'?'校准需要重试':'采集已暂停'):t(phase==='intro'?'眼追校准':phase==='result'?saved?'校准已保存。':'校准完成':phase==='verify'?'验证校准':'请注视高亮目标');
return <div ref={root} className={`launcher-shell launcher-calibration ${overlay?'calibration-show-dialog':''}`}>
<div className={`launcher-search ${targetClass(23)}`} data-calibration-target={23}><IconSearch size={22}/><span>{t('搜索应用')}</span></div>
<nav className="launcher-tools launcher-filters" aria-label={t('校准分类按钮')}>{categoryIcons.map((Icon,i)=><span key={i} className={`calibration-rail-target ${targetClass(13+i)}`} data-calibration-target={13+i}><Icon size={26}/></span>)}</nav>
<nav className="launcher-tools launcher-shortcuts" aria-label={t('校准快捷按钮')}>{actionIcons.map((Icon,i)=><span key={i} className={`calibration-rail-target ${targetClass(19+i)}`} data-calibration-target={19+i}><Icon size={26}/></span>)}</nav>
<div className="launcher-grid"><div className="launcher-page">{cells.map((cell,i)=><div key={i} className="launch-item" style={{gridColumn:`${cell.column} / span 4`,gridRow:`${1+cell.row*2} / span 2`}}><span data-calibration-target={i} className={`launch-icon ${targetClass(i)}`}><span className="launch-disc"><IconApps size={42}/></span></span><span className="launch-name">{t('应用 {0}',{0:i+1})}</span></div>)}</div></div>
{collecting&&!problem&&<section className="calibration-live" aria-label={t('校准进度')}>
<span className="calibration-live-icon"><IconEye size={26} stroke={1.6}/></span>
<div className="calibration-live-copy"><strong>{tracking?t('看向 {0}',{0:targetName(active)}):t('等待眼动追踪')}</strong><span>{tracking?t('可以自然转头,无需按键。'):t('请保持佩戴,看向高亮目标。')}</span></div>
<div className="calibration-live-progress"><span>{t(phase==='verify'?'验证':'采集')} <b>{step+1} / {targets.length}</b></span><progress aria-label={t('当前目标进度')} max={1} value={progress}/></div>
<button className="calibration-secondary" onClick={onClose}>{t('取消')}</button>
</section>}
{overlay&&<div className="calibration-dialog-layer"><section className="calibration-dialog" role="dialog" aria-modal="true" aria-labelledby="calibration-title">
<span className={`calibration-symbol ${problem?'has-error':''}`}>{problem?<IconAlertCircle size={32} stroke={1.5}/>:phase==='result'?<IconCheck size={32} stroke={1.7}/>:<IconEye size={32} stroke={1.5}/>}</span>
<h1 id="calibration-title">{title}</h1>
{phase==='intro'&&<><p className="calibration-lead">{t('让注视更贴合启动台。')}</p><ul className="calibration-instructions"><li>{t('依次看向高亮图标或按钮,等待进度完成。')}</li><li>{t('边缘看不清时可以转头,也可以移动头部。')}</li><li>{t('无需点击目标;按 B 可随时退出。')}</li></ul><p className="calibration-footnote">{t('先采集,再验证;完成后由你确认保存。')}</p></>}
{problem&&<p className="calibration-message" role="alert">{problem}</p>}
{phase==='result'&&result&&<><p className="calibration-lead">{t(saved?'新的校准已应用到启动台。':problem?'之前的校准保持不变。':'验证已完成,保存后应用到启动台。')}</p><dl className="calibration-metrics"><div><dt>{t('平均目标偏差')}</dt><dd>{(result.error*180/Math.PI).toFixed(2)}<small>°</small></dd></div><div><dt>{t('最差目标偏差')}</dt><dd>{(result.maxError*180/Math.PI).toFixed(2)}<small>°</small></dd></div></dl><details className="calibration-details"><summary>{t('查看各目标误差')}</summary><table><thead><tr><th>{t('目标')}</th><th>{t('校准前')}</th><th>{t('校准后')}</th><th>{t('95%帧误差')}</th></tr></thead><tbody>{targetErrors.map(v=><tr key={v.target}><th>{targetName(v.target)}</th><td>{(v.baseline*180/Math.PI).toFixed(2)}°</td><td>{(v.error*180/Math.PI).toFixed(2)}°</td><td>{(v.p95*180/Math.PI).toFixed(2)}°</td></tr>)}</tbody></table></details></>}
<div className="calibration-actions">{phase==='intro'?<><button className="calibration-secondary" onClick={onClose}>{t('取消')}</button><button className="calibration-primary" onClick={start}>{t('开始校准')}</button></>:phase==='result'?<><button className="calibration-secondary" onClick={saved?onClose:start} disabled={saving}>{t(saved?'完成':'重新校准')}</button>{!problem&&!saved&&<button className="calibration-primary" onClick={()=>void save()} disabled={saving}>{t(saving?'保存中…':'保存校准')}</button>}{problem&&<button className="calibration-secondary" onClick={onClose}>{t('取消')}</button>}</>:<><button className="calibration-secondary" onClick={onClose}>{t('取消')}</button><button className="calibration-secondary" onClick={start}>{t('重新校准')}</button><button className="calibration-primary" onClick={()=>setProblem('')}>{t('重试当前目标')}</button></>}</div>
</section></div>}
</div>;
}
+41
View File
@@ -0,0 +1,41 @@
export type GazeSample={valid:boolean;x?:number;y?:number;phase?:'down'|'up'|'cancel';targetId?:string;rawAngles?:[number,number];targetAngles?:[number,number];headPose?:number[];targetUnavailable?:boolean};
// DOM targets stay fixed during a press; gaze movement must never become drag.
export function installLauncherGaze(root:HTMLElement,blocked:()=>boolean,feedback:()=>void){
let focus:HTMLElement|null=null,candidate:HTMLElement|null=null,since=0,lastFeedback=-Infinity,pressed:HTMLElement|null=null,armed=false,lastValid=-Infinity,filtered:{x:number;y:number;at:number}|null=null;
const selector='button:not(:disabled),input:not(:disabled)';
const usable=(node:HTMLElement|null):node is HTMLElement=>!!node&&node.isConnected&&!node.matches(':disabled')&&root.contains(node);
function highlight(next:HTMLElement|null){if(focus===next)return;focus?.classList.remove('gaze-focused');focus?.dispatchEvent(new PointerEvent('pointerout',{bubbles:true,pointerId:901,pointerType:'mouse',relatedTarget:next}));const previous=focus;focus=next;focus?.classList.add('gaze-focused');focus?.dispatchEvent(new PointerEvent('pointerover',{bubbles:true,pointerId:901,pointerType:'mouse',relatedTarget:previous}));if(next&&performance.now()-lastFeedback>=180){lastFeedback=performance.now();feedback();}}
function cancel(){if(pressed){pressed.dispatchEvent(new PointerEvent('pointercancel',{bubbles:true,pointerId:901,pointerType:'mouse'}));pressed=null;}armed=false;candidate=null;filtered=null;highlight(null);}
const invalidTimer=window.setInterval(()=>{if(performance.now()-lastValid>280)cancel();},50);
function event(e:Event){const sample=(e as CustomEvent<GazeSample>).detail;const d=sample?{...sample}:null;if(!d||d.phase==='cancel'||blocked()){cancel();return;}
// Brief sensor dropouts keep hover, but never complete a held click.
if(!d.valid){if(pressed)cancel();return;}
const now=performance.now();lastValid=now;
if(d.phase==='down'){
if(!armed||!usable(focus)||pressed)return;
pressed=focus;const b=pressed.getBoundingClientRect();pressed.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,button:0,buttons:1,pointerId:901,pointerType:'mouse',clientX:b.x+b.width/2,clientY:b.y+b.height/2}));return;
}
if(d.phase==='up'){
const node=pressed;pressed=null;armed=false;
if(usable(node)){const b=node.getBoundingClientRect();node.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,button:0,buttons:0,pointerId:901,pointerType:'mouse',clientX:b.x+b.width/2,clientY:b.y+b.height/2}));if(usable(node)){if(node instanceof HTMLInputElement)node.focus();node.click();}}return;
}
if(pressed){if(!usable(pressed))cancel();return;}
if(!Number.isFinite(d.x)||!Number.isFinite(d.y)){cancel();return;}
// Smooth normal fixation jitter; saccades jump immediately to avoid lag.
if(!filtered||now-filtered.at>150||Math.hypot(d.x!-filtered.x,d.y!-filtered.y)>150)filtered={x:d.x!,y:d.y!,at:now};
else{const alpha=1-Math.exp(-(now-filtered.at)/55);filtered={x:filtered.x+alpha*(d.x!-filtered.x),y:filtered.y+alpha*(d.y!-filtered.y),at:now};}
const {x,y}=filtered;
let next=document.elementFromPoint(x,y)?.closest<HTMLElement>(selector)??null;
// A modal/menu takes priority over icons behind it.
const modal=root.querySelector<HTMLElement>('.modal'),menu=root.querySelector<HTMLElement>('.launch-menu');
if(next&&(!usable(next)||(modal&&!modal.contains(next))))next=null;
if(!next){const nodes=Array.from((modal??menu??root).querySelectorAll<HTMLElement>(selector));let best=Infinity;for(const node of nodes){if(!usable(node)||node.closest('.launcher-adjacent-page'))continue;const b=node.getBoundingClientRect();if(b.width<=0||b.height<=0)continue;const padding=node.matches('.launch-icon')?32:16;if(x<b.left-padding||x>b.right+padding||y<b.top-padding||y>b.bottom+padding)continue;const distance=Math.hypot(x-(b.left+b.width/2),y-(b.top+b.height/2));if(distance<best){best=distance;next=node;}}}
if(usable(focus)){const b=focus.getBoundingClientRect();if(x>=b.left-32&&x<=b.right+32&&y>=b.top-32&&y<=b.bottom+32)next=focus;}
if(next!==candidate){candidate=next;since=now;}
if(now-since>=120){highlight(candidate);armed=!!candidate;}
}
const reset=()=>cancel();
window.addEventListener('framely.launcher.gaze',event);
for(const name of ['framely.launcher.open','framely.launcher.cancelInput','framely.launcher.closing','framely.launcher.navigate','framely.keyboard'])window.addEventListener(name,reset);
return()=>{clearInterval(invalidTimer);cancel();window.removeEventListener('framely.launcher.gaze',event);for(const name of ['framely.launcher.open','framely.launcher.cancelInput','framely.launcher.closing','framely.launcher.navigate','framely.keyboard'])window.removeEventListener(name,reset);};
}
+67 -26
View File
@@ -1,73 +1,73 @@
/* A transparent spatial surface, not a DOM backdrop-blur of the real world.
Layer order: content 0, categories 2, context menu 3, feedback 4, dialog 5. */
.launcher-view,.launcher-view body,.launcher-view #root{background:transparent!important}
.launcher-shell{--launch-ink:#eff4f8;--launch-blue:#b4dbf4;--launch-ease:cubic-bezier(.2,.75,.2,1);height:100dvh;min-height:600px;padding:90px 60px 104px;color:var(--launch-ink);background:transparent;display:flex;flex-direction:column;position:relative;overflow:hidden;font-size:20px}
.launcher-tools{position:absolute;left:0;top:calc(50% + 17px);transform:translateY(-50%);display:flex;flex-direction:column;gap:5px;padding:7px;border:1px solid #e8f4ff30;border-radius:999px;background:#a7bac72b;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b;z-index:2}
.launcher-shell{--launcher-glass:#b8b8b82b;--launcher-glass-focus:#b8b8b83b;--launch-ink:#eff4f8;--launch-blue:#b4dbf4;--launch-ease:cubic-bezier(.2,.75,.2,1);height:100dvh;min-height:600px;padding:90px 60px 104px;color:var(--launch-ink);background:transparent;display:flex;flex-direction:column;position:relative;overflow:hidden;font-size:20px}
.launcher-tools{position:absolute;left:0;top:calc(50% + 17px);transform:translateY(-50%);display:flex;flex-direction:column;gap:5px;padding:7px;border:1px solid #e8f4ff30;border-radius:999px;background:var(--launcher-glass);box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b;z-index:2}
:is(.launcher-filters,.launcher-shortcuts) button{position:relative;width:56px;height:56px;min-height:56px;padding:15px;border:0;border-radius:50%;background:transparent;color:#d9e3ec;transition:background .2s var(--launch-ease),color .2s var(--launch-ease)}
.launcher-category-active{position:absolute;left:7px;top:7px;width:56px;height:56px;border-radius:50%;background:#d3e4ef35;box-shadow:inset 0 1px 0 #eef9ff40,inset 0 0 0 1px #e0f2ff19;pointer-events:none;transition:transform 360ms cubic-bezier(.22,.65,.24,1)}
.launcher-category-active{position:absolute;left:7px;top:7px;width:56px;height:56px;border-radius:50%;background:var(--control-selected);box-shadow:inset 0 1px 0 #eef9ff40,inset 0 0 0 1px #e0f2ff19;pointer-events:none;transition:transform 360ms cubic-bezier(.22,.65,.24,1)}
:is(.launcher-filters,.launcher-shortcuts) button.active{background:transparent;box-shadow:none;color:var(--launch-blue)}
:is(.launcher-filters,.launcher-shortcuts) button:hover:not(:disabled){background:#e3f1ff26;color:#f3f8fc}
.launcher-category-label{position:absolute;left:calc(100% + 20px);white-space:nowrap;font-size:15px;font-weight:500;line-height:1.4;padding:7px 13px;border-radius:12px;background:#1d2934eb;color:#eaf4fb;box-shadow:inset 0 0 0 1px #e2f0fa1f;opacity:0;pointer-events:none;transition:opacity .18s}
:is(.launcher-filters,.launcher-shortcuts) button:hover:not(:disabled){background:var(--control-hover);color:#f3f8fc}
.launcher-category-label{position:absolute;left:calc(100% + 20px);white-space:nowrap;font-size:15px;font-weight:500;line-height:1.4;padding:7px 13px;border-radius:12px;background:var(--overlay-bg);color:#eaf4fb;box-shadow:inset 0 0 0 1px #e2f0fa1f;opacity:0;pointer-events:none;transition:opacity .18s}
:is(.launcher-filters,.launcher-shortcuts) button:hover .launcher-category-label,:is(.launcher-filters,.launcher-shortcuts) button:focus-visible .launcher-category-label{opacity:1}
.launcher-grid{position:absolute;left:50%;top:calc(50% + 17px);height:570px;width:calc(100% - 140px);transform:translate(-50%,-50%);isolation:isolate}
.launcher-page,.launcher-adjacent-page{position:absolute;inset:0;display:grid;grid-template-columns:repeat(24,minmax(0,1fr));grid-template-rows:repeat(6,minmax(0,1fr));gap:12px}
.launch-item{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;min-width:0;padding:4px}
.launch-icon{display:flex;align-items:center;justify-content:center;position:relative;width:138px;height:122px;min-height:122px;border:0;background:transparent;padding:13px 21px;border-radius:50%;cursor:pointer;touch-action:none;isolation:isolate}
.launch-icon:hover:not(:disabled){background:transparent}
.launch-disc{position:relative;display:flex;align-items:center;justify-content:center;width:96px;height:96px;flex:none;border-radius:50%;background:#283640bd;color:#dcebf6;box-shadow:inset 0 1px 1px #edf7ff38,inset 0 -2px 3px #06122150,0 9px 20px #08162533;overflow:hidden;transition:transform .23s var(--launch-ease),box-shadow .23s var(--launch-ease)}
.launch-disc{position:relative;display:flex;align-items:center;justify-content:center;width:96px;height:96px;flex:none;border-radius:50%;background:#242424b8;color:#dcebf6;box-shadow:inset 0 1px 1px #edf7ff38,inset 0 -2px 3px #06122150,0 9px 20px #08162533;overflow:hidden;transition:transform .23s var(--launch-ease),box-shadow .23s var(--launch-ease)}
.launch-disc img{position:absolute;inset:10%;display:block;width:80%;height:80%;max-width:none;max-height:none;object-fit:contain;object-position:center;border:0;pointer-events:none;filter:drop-shadow(0 2px 3px #0d162638)}
.launch-disc.plugin img,.launch-disc.framely img{inset:0;width:100%;height:100%;filter:none}
.launch-disc.cover-art img{inset:0;width:100%;height:100%;object-fit:cover;filter:none}
.launch-disc.no-art{background:#415665bb}
.launch-disc.no-art{background:#303030bb}
.launch-monogram{font-size:32px;font-weight:520;letter-spacing:-.035em;color:#d9e9f3;line-height:1;pointer-events:none}
.launch-icon:hover:not(:disabled) .launch-disc,.launch-icon:focus-visible .launch-disc{transform:translateY(-3px) scale(1.055);box-shadow:inset 0 1px 2px #edf7ff60,0 14px 28px #07172b40}
.launch-icon:active:not(:disabled) .launch-disc{transform:scale(.97);box-shadow:inset 0 1px 2px #edf7ff38,0 5px 12px #07172b28}
.launch-name{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-align:center;font-size:16px;font-weight:450;line-height:1.28;letter-spacing:.005em;width:100%;max-width:164px;height:47px;color:#eff4f8;text-shadow:0 1px 3px #0c172cd9,0 2px 10px #0c172c70;pointer-events:none}
.launch-favorite{position:absolute;top:10px;right:5px;padding:7px;background:#263440ce;border:1px solid #e2effa2c;border-radius:50%;color:#dbe6f0;opacity:0;min-height:32px;width:32px;height:32px;transition:opacity .16s;z-index:1}
.launch-item:hover .launch-favorite,.launch-favorite:focus-visible{opacity:1}.launch-favorite.selected{color:var(--launch-blue);background:#142b3aee;border-color:#a6d4f280;box-shadow:0 2px 8px #07172b40}.launch-favorite.selected:hover:not(:disabled){background:#25465bee;border-color:#bfe4ff}
.launch-favorite{position:absolute;top:10px;right:5px;padding:7px;background:#1c1c1cce;border:1px solid #e2effa2c;border-radius:50%;color:#dbe6f0;opacity:0;min-height:32px;width:32px;height:32px;transition:opacity .16s;z-index:1}
.launch-item:hover .launch-favorite,.launch-favorite:focus-visible{opacity:1}.launch-favorite.selected{color:var(--launch-blue);background:#242424ee;border-color:#a6d4f280;box-shadow:0 2px 8px #07172b40}.launch-favorite.selected:hover:not(:disabled){background:#353535ee;border-color:#bfe4ff}
.launch-item-expanded{z-index:3}
.launch-menu{position:absolute;top:calc(100% - 6px);left:50%;transform:translateX(-50%);width:260px;max-height:404px;overflow:auto;padding:7px;background:#24323eef;border:1px solid #e6f3ff30;border-radius:22px;box-shadow:inset 0 1px 0 #ecf6ff25,0 14px 36px #05101f4a;z-index:3;animation:launcher-menu-in .18s var(--launch-ease)}
.launch-menu{position:absolute;top:calc(100% - 6px);left:50%;transform:translateX(-50%);width:260px;max-height:404px;overflow:auto;padding:7px;background:var(--overlay-bg);border:1px solid #e6f3ff30;border-radius:22px;box-shadow:inset 0 1px 0 #ecf6ff25,0 14px 36px #05101f4a;z-index:3;animation:launcher-menu-in .18s var(--launch-ease)}
.launch-menu.align-right{left:auto;right:0;transform:none}.launch-menu.above{top:auto;bottom:100%}
.launch-menu button{display:flex;align-items:center;gap:13px;width:100%;padding:12px 15px;background:transparent;border:0;border-radius:15px;text-align:left;justify-content:flex-start;color:#eef3f7;font:inherit;font-size:17px;font-weight:470;min-height:49px;white-space:nowrap}
.launch-menu button svg{color:#c5d6e4}.launch-menu button:hover:not(:disabled){background:#b9d6ee1c}.launch-menu .danger,.launch-menu .danger svg{color:#eeb5b5}.launch-menu hr{border:0;border-top:1px solid #e4f0fc19;margin:6px 12px}.launch-menu small{display:block;padding:10px 15px;font-size:15px;color:var(--launch-blue)}
.launch-menu button svg{color:#c5d6e4}.launch-menu button:hover:not(:disabled){background:var(--control-hover)}.launch-menu .danger,.launch-menu .danger svg{color:#eeb5b5}.launch-menu hr{border:0;border-top:1px solid #e4f0fc19;margin:6px 12px}.launch-menu small{display:block;padding:10px 15px;font-size:15px;color:var(--launch-blue)}
.launcher-pages{position:absolute;bottom:46px;left:50%;transform:translateX(-50%);display:flex;gap:0;max-width:80%;overflow:auto;align-items:center}
.launcher-pages button{min-width:32px;width:32px;min-height:36px;padding:12px;border:0;background:transparent;border-radius:50%}
.launcher-pages button span{display:block;width:6px;height:6px;border-radius:50%;background:#dceaf373;box-shadow:0 1px 3px #0c172c99;transition:background .18s,transform .18s}
.launcher-pages button[aria-current=page] span{background:#e9f4fc;transform:scale(1.2)}.launcher-pages button:hover:not(:disabled){background:#e3f1fc16}
.launcher-error,.launcher-shell>.banner{position:absolute;left:138px;right:62px;top:18px;z-index:4;display:flex;align-items:center;justify-content:space-between;background:#24313eea;padding:12px 18px;border-radius:16px;text-shadow:none;font-size:17px}
.launcher-pages button span{display:block;width:6px;height:6px;border-radius:50%;background:#ffffff73;box-shadow:0 1px 3px #0c172c99;transition:background .18s,transform .18s}
.launcher-pages button[aria-current=page] span{background:#f4f4f4;transform:scale(1.2)}.launcher-pages button:hover:not(:disabled){background:#ffffff16}
.launcher-error,.launcher-shell>.banner{position:absolute;left:138px;right:62px;top:18px;z-index:4;display:flex;align-items:center;justify-content:space-between;background:var(--overlay-bg);padding:12px 18px;border-radius:16px;text-shadow:none;font-size:17px}
.launcher-error{background:#462c36ef}.launcher-error button{background:transparent;border:0}
.launcher-empty{position:absolute;inset:45% 20% auto;text-align:center;font-size:20px;color:#e5eff7;text-shadow:0 1px 5px #0c172c}
.launcher-loading{position:absolute;bottom:14px;left:0;right:0;text-align:center;font-size:13px;text-shadow:0 1px 4px #0c172c;color:#d7e3ed}
.launcher-action-frame{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;overflow:hidden}.launcher-action-frame iframe{width:1px;height:1px}
.launcher-shell button:disabled{opacity:.4;cursor:default}.launcher-shell button:not(.launch-icon):focus-visible{outline:2px solid var(--launch-blue);outline-offset:4px}
.launcher-shell .modal-backdrop{position:absolute;inset:0;background:#101d2b65;z-index:5;display:grid;place-items:center}.launcher-shell .modal{position:relative;transform:none;inset:auto;width:560px;max-width:90%;margin:0;border-radius:22px}
.launcher-shell .modal-backdrop{position:absolute;inset:0;background:#00000065;z-index:5;display:grid;place-items:center}.launcher-shell .modal{position:relative;transform:none;inset:auto;width:560px;max-width:90%;margin:0;border-radius:22px}
@keyframes launcher-menu-in{from{opacity:0;translate:0 6px}to{opacity:1;translate:0 0}}
@media(max-width:900px){.launcher-shell{padding:65px 22px 80px 96px}.launcher-grid{width:calc(100% - 176px)}.launcher-page,.launcher-adjacent-page{gap:8px}.launch-icon{width:92px;height:92px;min-height:92px}.launch-disc{width:82px;height:82px}.launch-name{font-size:15px;height:39px}.launch-monogram{font-size:26px}.launcher-error,.launcher-shell>.banner{left:96px;right:22px}.launcher-pages{left:50%}}
@media(prefers-reduced-transparency:reduce){.launch-disc{background:#273644}.launcher-tools{background:#273644}.launch-menu{background:#20303d}}
@media(prefers-reduced-transparency:reduce){.launch-disc{background:#242424}.launcher-tools{background:#242424}.launch-menu{background:#181818}}
@media(prefers-reduced-motion:reduce){.launch-disc,.launch-favorite,.launcher-category-label,:is(.launcher-filters,.launcher-shortcuts) button,.launcher-category-active,.launcher-pages button span{transition:none}.launch-menu,.launch-item{animation:none}}
.launcher-settings label{display:flex;align-items:center;justify-content:space-between;padding:16px 0;width:100%;gap:24px;border-bottom:1px solid #ffffff10}.launcher-settings label small{display:block;color:#a1adb7;margin-top:6px}.launcher-settings input[type=checkbox]{width:26px;height:26px}.launcher-settings input[type=number]{width:100px;padding:10px;background:#111a22;color:#fff;border:1px solid #44515c;border-radius:8px;font:inherit}
.launcher-settings label{display:flex;align-items:center;justify-content:space-between;padding:16px 0;width:100%;gap:24px;border-bottom:1px solid #ffffff10}.launcher-settings label small{display:block;color:#a1adb7;margin-top:6px}.launcher-settings input[type=checkbox]{width:26px;height:26px}.launcher-settings input[type=number]{width:100px;padding:10px;background:#141414;color:#fff;border:1px solid #44515c;border-radius:8px;font:inherit}
.launcher-settings input[type=number]:not([type=checkbox]){width:100px;margin:0}
.launcher-edit-toolbar{position:absolute;top:26px;left:160px;right:70px;display:flex;align-items:center;justify-content:space-between;gap:24px;z-index:4;color:#edf5fb;font-size:17px;text-shadow:0 1px 4px #061320}
.launcher-edit-toolbar span,.launcher-edit-toolbar button{display:flex;align-items:center;gap:10px}.launcher-edit-toolbar button{border-radius:32px;padding:10px 20px;background:#a7bac72b;border:1px solid #e8f4ff30;color:#d9e3ec;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b}
.launcher-edit-toolbar span,.launcher-edit-toolbar button{display:flex;align-items:center;gap:10px}.launcher-edit-toolbar button{border-radius:32px;padding:10px 20px;background:var(--overlay-bg);border:1px solid #e8f4ff30;color:#d9e3ec;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b}
.launcher-editing .launch-disc{animation:launcher-wiggle .32s ease-in-out infinite alternate}.launcher-editing .launch-item:nth-child(2n) .launch-disc{animation-direction:alternate-reverse}
.launch-item-dragged .launch-disc{opacity:.22;animation:none}.launcher-drag-ghost{position:fixed;transform:translate(-50%,-50%) scale(1.1);pointer-events:none;z-index:7;filter:drop-shadow(0 12px 18px #06132066)}
.launcher-drag-edge{pointer-events:none;z-index:6}
@keyframes launcher-wiggle{from{rotate:-1.4deg}to{rotate:1.4deg}}
@media(prefers-reduced-motion:reduce){.launcher-editing .launch-disc{animation:none}}
.launcher-edit-toolbar button:hover:not(:disabled){background:#e3f1ff26;color:#f3f8fc}
.launcher-edit-toolbar button:hover:not(:disabled){background:var(--control-hover);color:#f3f8fc}
.launcher-panning{overflow:hidden}.launcher-adjacent-page{pointer-events:none}.launcher-adjacent-page .launch-icon{display:block}.launcher-panning .launcher-page{will-change:translate}
/* Search shares the category capsule material without moving the icon canvas. */
.launcher-search{position:absolute;top:20px;left:50%;transform:translateX(-50%);width:420px;height:56px;display:flex;align-items:center;gap:12px;padding:0 18px;border:1px solid #e8f4ff30;border-radius:999px;background:#a7bac72b;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b;z-index:2;color:#d9e3ec}
.launcher-search:focus-within{background:#a7bac73b;box-shadow:inset 0 1px 1px #f0f7ff40,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b}
.launcher-search{position:absolute;top:20px;left:50%;transform:translateX(-50%);width:420px;height:56px;display:flex;align-items:center;gap:12px;padding:0 18px;border:1px solid #e8f4ff30;border-radius:999px;background:var(--launcher-glass);box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b;z-index:2;color:#d9e3ec}
.launcher-search:focus-within{background:var(--launcher-glass-focus);box-shadow:inset 0 1px 1px #f0f7ff40,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b}
.launcher-search input,.launcher-search input:focus,.launcher-search input:focus-visible{flex:1;min-width:0;width:0;height:100%;padding:0;border:0!important;outline:0;background:transparent!important;color:var(--launch-ink);font-size:18px;box-shadow:none!important}
.launcher-search input::placeholder{color:#d9e3ec9c}
.launcher-search input::-webkit-search-cancel-button{display:none}
.launcher-search button{display:flex;align-items:center;justify-content:center;width:32px;height:32px;min-height:32px;padding:0;border:0;border-radius:50%;background:transparent;color:inherit}
.launcher-search button:hover{background:#e3f1ff26}
.launcher-search button:hover{background:var(--control-hover)}
.launcher-editing .launcher-search{visibility:hidden}
.launcher-initializing .launcher-grid,.launcher-initializing .launcher-category-active{visibility:hidden}
@@ -82,10 +82,51 @@
The invisible 8px boundary remains exact; feathered shading faces the grid. */
.launcher-shell .launcher-drag-edge{position:fixed;right:auto;bottom:auto;width:8px;background:transparent;border:0;box-shadow:none}
.launcher-drag-edge::before,.launcher-drag-edge::after{content:"";position:absolute;top:8%;bottom:8%;left:-4px;width:76px;pointer-events:none;opacity:.35;transform-origin:left center;transition:opacity 220ms,transform 220ms}
.launcher-drag-edge::before{background:radial-gradient(ellipse at 0% 50%,#d9eaf744 0%,#c6e0f021 30%,#b4dbf408 58%,transparent 78%)}
.launcher-drag-edge::after{left:-12px;width:62px;background:radial-gradient(ellipse at 20% 50%,#10213252 0%,#10213219 45%,transparent 75%)}
.launcher-drag-edge.right::before{left:auto;right:-4px;transform-origin:right center;background:radial-gradient(ellipse at 100% 50%,#d9eaf744 0%,#c6e0f021 30%,#b4dbf408 58%,transparent 78%)}
.launcher-drag-edge.right::after{left:auto;right:-12px;transform-origin:right center;background:radial-gradient(ellipse at 80% 50%,#10213252 0%,#10213219 45%,transparent 75%)}
.launcher-drag-edge::before{background:radial-gradient(ellipse at 0% 50%,#ffffff44 0%,#ffffff21 30%,#ffffff08 58%,transparent 78%)}
.launcher-drag-edge::after{left:-12px;width:62px;background:radial-gradient(ellipse at 20% 50%,#00000052 0%,#00000019 45%,transparent 75%)}
.launcher-drag-edge.right::before{left:auto;right:-4px;transform-origin:right center;background:radial-gradient(ellipse at 100% 50%,#ffffff44 0%,#ffffff21 30%,#ffffff08 58%,transparent 78%)}
.launcher-drag-edge.right::after{left:auto;right:-12px;transform-origin:right center;background:radial-gradient(ellipse at 80% 50%,#00000052 0%,#00000019 45%,transparent 75%)}
.launcher-drag-edge.active::before{opacity:1;transform:scaleX(1.18)}.launcher-drag-edge.active::after{opacity:.8;transform:scaleX(1.12)}
@media(prefers-reduced-motion:reduce){.launcher-drag-edge::before,.launcher-drag-edge::after{transition:none}}
@media(prefers-reduced-transparency:reduce){.launcher-drag-edge::before{opacity:.8}.launcher-drag-edge.active::before{opacity:1}}
/* Gaze focus shares the launcher scale language, without adding icon borders. */
.launch-icon.gaze-focused .launch-disc{transform:scale(1.08);filter:brightness(1.12);}
.launcher-tools button.gaze-focused,.launch-menu button.gaze-focused,.launcher-pages button.gaze-focused,.launcher-edit-toolbar button.gaze-focused{background:var(--control-selected);color:#edf7ff;}
.launcher-search input.gaze-focused{background:var(--control-hover);}
.launch-item:has(.gaze-focused) .launch-favorite{opacity:1;pointer-events:auto;}
.launch-favorite.gaze-focused{opacity:1;background:#ffffff30;}
/* Preserve spatial launcher: DESIGN_VARIANCE 4 / MOTION_INTENSITY 2 / VISUAL_DENSITY 3.
Guidance stays outside the sampling area; details appear only after validation. */
.launcher-calibration .launch-icon{cursor:default}
.launcher-calibration .launch-disc{background:#242424b8}
.launcher-calibration>.launcher-tools,.launcher-calibration>.launcher-search{background:var(--launcher-glass);box-shadow:inset 0 1px 1px #ffffff20,0 10px 30px #00000030}
.calibration-rail-target{width:56px;height:56px;display:flex;align-items:center;justify-content:center;border-radius:50%;color:#dcebf6;opacity:.35}
.launcher-calibration .launch-disc{transition:opacity 180ms,box-shadow 180ms;opacity:.3}.launcher-calibration .launch-name{opacity:.35}
.launcher-calibration .calibration-target-active .launch-disc{opacity:1;box-shadow:0 0 0 3px var(--launch-blue),0 0 0 9px #b4dbf414}.launcher-calibration .launch-item:has(.calibration-target-active) .launch-name{opacity:1}
.launcher-calibration .calibration-target-active:not(.launch-icon){opacity:1;background:#ffffff18;box-shadow:0 0 0 2px var(--launch-blue)}
.calibration-live,.calibration-dialog{background:var(--overlay-bg);color:var(--launch-ink);border:1px solid #ffffff26;box-shadow:inset 0 1px 0 #ffffff12,0 18px 50px #00000060}
.calibration-live{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);width:min(860px,calc(100% - 160px));min-height:78px;padding:14px 18px;display:flex;align-items:center;gap:18px;border-radius:999px;z-index:5}
.calibration-live-icon{width:44px;height:44px;display:grid;place-items:center;border-radius:50%;background:#ffffff0d;color:var(--launch-blue);flex:none}
.calibration-live-copy{display:flex;flex-direction:column;gap:4px;flex:1;min-width:0}.calibration-live-copy strong{font-size:18px;font-weight:550}.calibration-live-copy>span{font-size:14px;color:#c8c8c8}
.calibration-live-progress{width:128px;flex:none}.calibration-live-progress>span{display:flex;justify-content:space-between;font-size:13px;color:#c8c8c8}.calibration-live-progress b{font-weight:500;color:var(--launch-ink);font-variant-numeric:tabular-nums}
.calibration-live progress{display:block;width:100%;height:4px;margin-top:9px;accent-color:var(--launch-blue);border:0;border-radius:999px;overflow:hidden}.calibration-live progress::-webkit-progress-bar{background:#ffffff1a}.calibration-live progress::-webkit-progress-value{background:var(--launch-blue);border-radius:999px}
.calibration-dialog-layer{position:absolute;inset:0;display:grid;place-items:center;z-index:6;padding:24px;pointer-events:auto}
.calibration-show-dialog>.launcher-grid,.calibration-show-dialog>.launcher-tools,.calibration-show-dialog>.launcher-search{opacity:.22;pointer-events:none}
.calibration-dialog{width:min(560px,100%);max-height:calc(100dvh - 48px);overflow:auto;border-radius:32px;padding:30px 36px;animation:calibration-appear 200ms var(--launch-ease)}
.calibration-symbol{width:62px;height:62px;display:grid;place-items:center;margin-bottom:20px;border-radius:50%;color:var(--launch-blue);background:#ffffff0a;box-shadow:inset 0 0 0 1px #ffffff14}.calibration-symbol.has-error{background:#ffffff12}
.calibration-dialog h1{margin:0;font-size:28px;line-height:1.25;font-weight:550;letter-spacing:-.025em}.calibration-lead{margin:10px 0 24px;font-size:16px;line-height:1.5;color:#c8c8c8}
.calibration-instructions{list-style:none;margin:0;padding:0;display:grid;gap:16px;font-size:16px;line-height:1.5}.calibration-instructions li{padding-left:18px;border-left:2px solid #ffffff30}
.calibration-footnote{margin:24px 0 0;font-size:14px;color:#ababab}.calibration-message{font-size:16px;line-height:1.55;padding:14px 16px;margin:18px 0;background:#ffffff0a;border-radius:16px}
.calibration-actions{display:flex;justify-content:flex-end;gap:12px;margin-top:28px;flex-wrap:wrap}.launcher-calibration :is(.calibration-actions,.calibration-live) button{min-height:44px;padding:10px 22px;border-radius:999px;font-size:16px;line-height:1.3;white-space:nowrap;border:1px solid transparent}
.launcher-calibration .calibration-secondary{color:var(--launch-ink);background:#ffffff08;border-color:#ffffff26!important}.launcher-calibration .calibration-primary{background:#ffffff22;color:#f3f3f3;border-color:#ffffff30!important;font-weight:550}
.launcher-calibration button:hover:not(:disabled){filter:brightness(1.12)}.launcher-calibration button:focus-visible,.calibration-details summary:focus-visible{outline:2px solid var(--launch-blue);outline-offset:4px}
.calibration-metrics{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin:24px 0}.calibration-metrics>div+div{padding-left:24px;border-left:1px solid #e8f4ff1f}.calibration-metrics dt{font-size:14px;color:#c8c8c8}.calibration-metrics dd{margin:8px 0 0;font-size:36px;font-weight:500;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:-.04em}.calibration-metrics small{font-size:22px;margin-left:4px;color:#ababab}
.calibration-details{font-size:14px;color:#c8c8c8}.calibration-details summary{cursor:pointer;min-height:36px;line-height:36px;color:var(--launch-ink)}.calibration-details table{width:100%;margin-top:10px;border-collapse:collapse;font-size:13px;white-space:nowrap}.calibration-details th,.calibration-details td{padding:8px 4px;text-align:right;font-variant-numeric:tabular-nums;font-weight:400}.calibration-details th:first-child{text-align:left}.calibration-details thead{color:#ababab}.calibration-details tbody tr:nth-child(odd){background:#ffffff05}
@keyframes calibration-appear{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
@media(max-width:700px){.calibration-live{width:calc(100% - 24px);gap:10px;padding:12px;border-radius:24px}.calibration-live-icon{display:none}.calibration-live-progress{width:86px}.calibration-live-copy strong{font-size:15px}.calibration-live-copy>span{font-size:12px}.calibration-dialog{padding:24px}.calibration-actions{gap:8px}.calibration-metrics{gap:12px}}
@media(prefers-reduced-motion:reduce){.calibration-dialog{animation:none}.launcher-calibration .launch-disc{transition:none}}
@media(prefers-reduced-transparency:reduce){.calibration-live,.calibration-dialog{background:#121212}}
/* Keep spatial controls readable when system transparency is reduced. */
@media(prefers-reduced-transparency:reduce){.launcher-shell{--launcher-glass:#242424;--launcher-glass-focus:#303030}}
+13 -6
View File
@@ -2,11 +2,13 @@ import {SwitchRow} from './switch';
import React,{useEffect,useLayoutEffect,useMemo,useRef,useState} from 'react';
import {api} from './api';
import {t} from './i18n';
import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck,IconSearch,IconX,IconApps,IconSettings,IconPackageImport} from '@tabler/icons-react';
import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck,IconSearch,IconX,IconApps,IconSettings,IconPackageImport,IconEye} from '@tabler/icons-react';
import {installedIcon} from './store';
import {PluginFrame} from './plugin-frame';
import type {Plugin} from './main';
import './launcher.css';
import {installLauncherGaze} from './launcher-gaze';
import {LauncherCalibration} from './launcher-calibration';
import {matchesSearch,type SearchIndex} from './launcher-search';
export type LauncherConfig={primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
const defaults:LauncherConfig={primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
@@ -27,7 +29,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
const[busy,setBusy]=useState(false),[error,setError]=useState(''),[seconds,setSeconds]=useState(config.menuTimeoutSeconds);
useEffect(()=>setSeconds(config.menuTimeoutSeconds),[config.menuTimeoutSeconds]);
async function save(p:Partial<LauncherConfig>){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('主扳机打开启动台')} description={t('开启后,上扳机打开快捷面板。')} checked={config.primaryTrigger} disabled={busy} onChange={primaryTrigger=>void save({primaryTrigger})}/><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section></>;
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('主扳机打开启动台')} description={t('开启后,上扳机打开快捷面板。')} checked={config.primaryTrigger} disabled={busy} onChange={primaryTrigger=>void save({primaryTrigger})}/><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
}
type LocalApp={id:string;name:string;kind:'desktop'|'lepton';icon?:string};
type LaunchItem={key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean};
@@ -43,9 +45,13 @@ async function launcherRequest<T=any>(method:string,params:unknown={}):Promise<T
export function Launcher({plugins,safeMode,config=defaults,favorites,localFavorites=emptyOrder,order=emptyOrder,refresh}:{plugins:Record<string,Plugin>;safeMode:boolean;config?:LauncherConfig;favorites:number[];localFavorites?:string[];order?:string[];refresh:()=>Promise<void>}){
const remembered=useRef<string|null>(null);
const[query,setQuery]=useState(''),[indexes,setIndexes]=useState<Record<string,SearchIndex>>({}),keyboard=useRef(false),opening=useRef(true),props=useRef({plugins,favorites,localFavorites});props.current={plugins,favorites,localFavorites};
const [calibrating,setCalibrating]=useState(location.hash==='#calibrate'),calibrationRef=useRef(calibrating);calibrationRef.current=calibrating;
function endCalibration(){void api('host.launcher.calibrate',{active:false}).catch(e=>setError(String(e)));location.hash='';setCalibrating(false);}
useEffect(()=>{const changed=()=>setCalibrating(location.hash==='#calibrate');window.addEventListener('hashchange',changed);return()=>window.removeEventListener('hashchange',changed);},[]);
const[steam,setSteam]=useState<SteamApp[]>([]),[local,setLocal]=useState<LocalApp[]>([]),[loading,setLoading]=useState(true),[error,setError]=useState(''),[filter,setFilter]=useState('all'),[page,setPage]=useState(0),[menu,setMenu]=useState<string|null>(null),[busyKeys,setBusyKeys]=useState<Set<string>>(new Set()),[uninstall,setUninstall]=useState<(Plugin&{dependents:string[];fingerprint:string})|null>(null),[frontends,setFrontends]=useState<Record<string,{plugin:string;action:string;context:any;ticket:number}>>({}),[outside,setOutside]=useState(false),[editing,setEditing]=useState(false),[manual,setManual]=useState(order),[dragging,setDragging]=useState<string|null>(null),[ghost,setGhost]=useState<{item:LaunchItem;x:number;y:number;left:number;right:number;top:number;bottom:number}|null>(null),[edge,setEdge]=useState(0),[panView,setPanView]=useState<{offset:number;direction:number;width:number;settling:boolean}|null>(null);
const panTimer=useRef<ReturnType<typeof setTimeout>|null>(null),skipPageAnimation=useRef(false),grid=useRef<HTMLDivElement>(null),pageContent=useRef<HTMLDivElement>(null),swipe=useRef<{x:number;y:number;active?:boolean;dx?:number}|null>(null),shell=useRef<HTMLDivElement>(null),hold=useRef<ReturnType<typeof setTimeout>|null>(null),editHold=useRef<ReturnType<typeof setTimeout>|null>(null),edgeTimer=useRef<ReturnType<typeof setTimeout>|null>(null),long=useRef(false),point=useRef({x:0,y:0}),holdTarget=useRef<string|null>(null),mounted=useRef(true),operations=useRef(new Map<string,{started:number;expire:()=>void}>()),actionSequence=useRef(0),menuRef=useRef<HTMLDivElement>(null),drag=useRef<{key:string;before:string[];order:string[];moved:boolean;over:string|null;edge:number;lastX:number;lastY:number;startedInEdit:boolean}|null>(null),state=useRef({editing:false,current:0,pages:1,filter:'all',panning:false,items:[] as LaunchItem[],all:[] as LaunchItem[]});
useEffect(()=>{if(!drag.current)setManual(order);},[order]);
useEffect(()=>{if(!shell.current)return;return installLauncherGaze(shell.current,()=>calibrationRef.current||keyboard.current||state.current.panning,()=>console.log('FRAMELY_GAZE_FOCUS'));},[calibrating]);
function selectCategory(key:string){remembered.current=key;opening.current=false;setFilter(key);}
function cancelHold(){if(hold.current)clearTimeout(hold.current);if(editHold.current)clearTimeout(editHold.current);hold.current=null;editHold.current=null;holdTarget.current=null;}
function stopEdge(){if(edgeTimer.current)clearTimeout(edgeTimer.current);edgeTimer.current=null;setEdge(0);if(drag.current)drag.current.edge=0;}
@@ -54,15 +60,15 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
useEffect(()=>{if(!drag.current)cancelHold();setMenu(null);setOutside(false);},[filter,page]);
useEffect(()=>{if(!menu||!outside||!config.menuAutoClose||!!(menu&&busyKeys.has(menu)))return;const timer=setTimeout(()=>setMenu(null),config.menuTimeoutSeconds*1000);return()=>clearTimeout(timer);},[menu,outside,busyKeys,config.menuAutoClose,config.menuTimeoutSeconds]);
function cancelPan(){if(panTimer.current)clearTimeout(panTimer.current);panTimer.current=null;swipe.current=null;setPanView(null);}
function close(){if(keyboard.current)return;cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void launcherRequest('host.launcher.close');}
function close(){if(calibrationRef.current){endCalibration();return;}if(keyboard.current)return;cancelHold();cancelDrag();cancelPan();setMenu(null);if(state.current.editing){setEditing(false);return;}void launcherRequest('host.launcher.close');}
function settleSwipe(gesture:{dx?:number;active?:boolean}){if(!gesture.active)return;const s=state.current,width=grid.current?.clientWidth??1300,dx=gesture.dx??0,direction=dx<0?1:-1,next=s.current+direction,valid=next>=0&&next<s.pages,advance=valid&&Math.abs(dx)>=70;setPanView({offset:advance?-direction*width:0,direction,width,settling:true});long.current=true;panTimer.current=setTimeout(()=>{panTimer.current=null;if(advance){skipPageAnimation.current=true;setPage(next);}setPanView(null);},240);}
useEffect(()=>{const back=()=>close();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')close();};window.addEventListener('framely.back',back);window.addEventListener('keydown',key);const navigate=(e:Event)=>{if(keyboard.current||state.current.panning)return;const direction=(e as CustomEvent<number>).detail;const s=state.current;if(drag.current){if(Math.abs(direction)===1){stopEdge();moveDraggedPage(direction);}return;}setMenu(null);if(Math.abs(direction)===1)setPage(Math.max(0,Math.min(s.pages-1,s.current+direction)));else if(Math.abs(direction)===2){const index=categories.findIndex(c=>c.key===s.filter);selectCategory(categories[Math.max(0,Math.min(categories.length-1,index+(direction>0?-1:1)))].key);setPage(0);}};window.addEventListener('framely.launcher.navigate',navigate);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',key);window.removeEventListener('framely.launcher.navigate',navigate);};},[]);
useEffect(()=>{const back=()=>close();const key=(e:KeyboardEvent)=>{if(e.key==='Escape')close();};window.addEventListener('framely.back',back);window.addEventListener('keydown',key);const navigate=(e:Event)=>{if(calibrationRef.current||keyboard.current||state.current.panning)return;const direction=(e as CustomEvent<number>).detail;const s=state.current;if(drag.current){if(Math.abs(direction)===1){stopEdge();moveDraggedPage(direction);}return;}setMenu(null);if(Math.abs(direction)===1)setPage(Math.max(0,Math.min(s.pages-1,s.current+direction)));else if(Math.abs(direction)===2){const index=categories.findIndex(c=>c.key===s.filter);selectCategory(categories[Math.max(0,Math.min(categories.length-1,index+(direction>0?-1:1)))].key);setPage(0);}};window.addEventListener('framely.launcher.navigate',navigate);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',key);window.removeEventListener('framely.launcher.navigate',navigate);};},[]);
useEffect(()=>{const preference=window.matchMedia('(prefers-reduced-motion: reduce)'),report=()=>console.log(`FRAMELY_LAUNCHER_REDUCED_MOTION=${preference.matches?1:0}`);report();preference.addEventListener('change',report);return()=>preference.removeEventListener('change',report);},[]);
const surfaceAnimation=useRef<Animation|null>(null);
function animateSurface(closing=false){const node=shell.current;if(!node)return;surfaceAnimation.current?.cancel();const reduced=window.matchMedia('(prefers-reduced-motion: reduce)').matches;surfaceAnimation.current=node.animate(closing?[{opacity:1,transform:'translateY(0) scale(1)'},{opacity:0,transform:'translateY(32px) scale(.88)'}]:[{opacity:0,transform:'translateY(44px) scale(.86)'},{opacity:1,transform:'translateY(0) scale(1)'}],{duration:reduced?0:closing?440:580,easing:'cubic-bezier(.4,0,.2,1)',fill:'both'});node.style.pointerEvents=closing?'none':'';}
useEffect(()=>{if(!loading)animateSurface();},[loading]);
useEffect(()=>{const closing=()=>animateSurface(true);window.addEventListener('framely.launcher.closing',closing);return()=>{window.removeEventListener('framely.launcher.closing',closing);surfaceAnimation.current?.cancel();};},[]);
useEffect(()=>{const closing=()=>{if(calibrationRef.current){location.hash='';setCalibrating(false);}animateSurface(true);};window.addEventListener('framely.launcher.closing',closing);return()=>{window.removeEventListener('framely.launcher.closing',closing);surfaceAnimation.current?.cancel();};},[]);
const all=useMemo(()=>{const entries:LaunchItem[]=[{key:'framely',kind:'framely',id:'framely',name:'Framely',icon:'/assets/branding/framely-app-icon.svg',fit:'contain',favorite:localFavorites.includes('framely')},...Object.values(plugins).filter(p=>p.enabled).map(p=>({key:`plugin:${p.manifest.id}`,kind:'plugin',id:p.manifest.id,name:p.manifest.name,icon:installedIcon(p),fit:'contain',favorite:p.favorite})),...steam.map(a=>({key:`steam:${a.id}`,kind:a.kind??'steam',id:String(a.id),name:a.name,icon:a.icon,fit:a.iconFit,favorite:favorites.includes(a.id)})),...local.map(a=>({key:`${a.kind}:${a.id}`,kind:a.kind,id:a.id,name:a.name,icon:a.icon,fit:'contain',favorite:localFavorites.includes(`${a.kind}:${a.id}`)}))];
const rank=new Map(manual.map((key,index)=>[key,index]));entries.sort((a,b)=>(rank.get(a.key)??Infinity)-(rank.get(b.key)??Infinity)||a.name.localeCompare(b.name)||a.key.localeCompare(b.key));return entries;},[plugins,steam,local,favorites,localFavorites,manual]);
const indexKey=useMemo(()=>JSON.stringify(all.map(a=>a.name).sort()),[all]);
@@ -104,10 +110,11 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
async function custom(id:string,action:string){const key=`plugin:${id}`;await run(key,async active=>{const v=await launcherRequest('plugin.launcher.action',{plugin:id,action});if(!active())return;if(v.frontend){await new Promise<void>((resolve,reject)=>{const ticket=++actionSequence.current;const timer=setTimeout(()=>done(key,'Launcher action timed out',ticket),90000);pending.current.set(key,{resolve,reject,timer,ticket});setFrontends(items=>({...items,[key]:{plugin:id,action,context:v.context,ticket}}));});}if(active()&&v.opened)setMenu(current=>current===key?null:current);});}
const pending=useRef(new Map<string,{resolve:()=>void;reject:(e:Error)=>void;timer:ReturnType<typeof setTimeout>;ticket:number}>());
function done(key:string,error?:string,ticket?:number){const p=pending.current.get(key);if(!p||(ticket!==undefined&&p.ticket!==ticket))return;clearTimeout(p.timer);pending.current.delete(key);if(mounted.current)setFrontends(items=>{const next={...items};delete next[key];return next;});if(error)p.reject(new Error(error));else p.resolve();}
if(calibrating)return <LauncherCalibration cells={launcherCells(13)} onClose={endCalibration}/>;
return <div ref={shell} onPointerDown={e=>{if(e.button===0&&!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error')){if(!keyboard.current&&!state.current.panning)swipe.current={x:e.clientX,y:e.clientY};long.current=false;try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}}}} className={`launcher-shell ${editing?'launcher-editing':''} ${loading&&opening.current?'launcher-initializing':''}`} onClick={e=>{if(long.current){long.current=false;return;}if(!(e.target as HTMLElement).closest('button,input,.launcher-search,.launch-menu,.modal,.launcher-error'))setMenu(null);}}>
<div className="launcher-search"><IconSearch size={22} stroke={1.65}/><input type="search" aria-label={t('搜索应用')} placeholder={t('搜索应用')} value={query} disabled={editing} onChange={e=>{cancelPan();cancelHold();setMenu(null);setQuery(e.target.value);setPage(0);}}/>{query&&<button aria-label={t('清除搜索')} onClick={()=>{setQuery('');setPage(0);}}><IconX size={20}/></button>}</div>
<nav className="launcher-tools launcher-filters" aria-label={t('启动台')}><span className="launcher-category-active" aria-hidden="true" style={{transform:`translateY(${Math.max(0,categories.findIndex(c=>c.key===filter))*61}px)`}}/>{categories.map(({key,label,icon:CategoryIcon})=><button key={key} aria-label={t(label)} title={t(label)} aria-pressed={filter===key} disabled={!!dragging||!!panView} className={filter===key?'active':''} onClick={()=>{if(editing){close();return;}selectCategory(key);setPage(0);}}><CategoryIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
<nav className="launcher-tools launcher-shortcuts" aria-label={t('快捷操作')}>{[{page:'apk',label:'安装APK',icon:IconPackageImport},{page:'catalog',label:'插件库',icon:IconApps},{page:'settings',label:'设置',icon:IconSettings}].map(({page,label,icon:ActionIcon})=><button key={page} aria-label={t(label)} title={t(label)} disabled={busyKeys.has('framely')||editing||!!dragging||!!panView} onClick={()=>{cancelHold();setMenu(null);void run('framely',()=>launcherRequest('host.manager.open',{page}));}}><ActionIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
<nav className="launcher-tools launcher-shortcuts" aria-label={t('快捷操作')}>{[{page:'apk',label:'安装APK',icon:IconPackageImport},{page:'catalog',label:'插件库',icon:IconApps},{page:'calibration',label:'眼追校准',icon:IconEye},{page:'settings',label:'设置',icon:IconSettings}].map(({page,label,icon:ActionIcon})=><button key={page} aria-label={t(label)} title={t(label)} disabled={busyKeys.has('framely')||editing||!!dragging||!!panView} onClick={()=>{cancelHold();setMenu(null);void run('framely',()=>page==='calibration'?launcherRequest('host.launcher.calibrate',{active:true}):launcherRequest('host.manager.open',{page}));}}><ActionIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
{editing&&<div className="launcher-edit-toolbar"><span><IconGridDots size={22}/>{t('拖动图标排列,移到两侧翻页')}</span><button disabled={busyKeys.has('order')||!!dragging} onClick={()=>setEditing(false)}><IconCheck size={22}/>{t('完成')}</button></div>}
{safeMode&&<p className="banner">{t('安全模式:第三方插件暂停运行。')}</p>}{error&&<div className="launcher-error" role="alert">{error}<button onClick={()=>setError('')}>{t('关闭')}</button></div>}
<div ref={grid} className={`launcher-grid ${panView?'launcher-panning':''}`}><div ref={pageContent} className="launcher-page" style={panView?{translate:`${panView.offset}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}:undefined}>{visible.map((a,index)=><div key={a.key} data-launch-key={a.key} style={{gridColumn:`${cells[index].column} / span 4`,gridRow:`${1+cells[index].row*2} / span 2`}} className={`launch-item ${menu===a.key?'launch-item-expanded':''} ${dragging===a.key?'launch-item-dragged':''}`} onPointerEnter={()=>{if(menu===a.key)setOutside(false);}} onPointerLeave={()=>{if(menu===a.key)setOutside(true);}}>
+28 -28
View File
@@ -1,20 +1,20 @@
:root{color-scheme:dark;--bg:#17191c;--surface:#202226;--line:#35383e;--muted:#a4a8b0;--blue:#93c5ed;--text:#f0f1f3}
*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{color:var(--text);background:transparent;font:21px/1.45 system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}svg{flex:none;display:block}button,input,textarea,select{font:inherit;color:inherit}button{display:inline-flex;align-items:center;justify-content:center;gap:10px;min-height:52px;padding:11px 18px;border:1px solid var(--line);border-radius:5px;background:#292c31;cursor:pointer}button:hover:not(:disabled){background:#363a41}button:disabled{opacity:.4;cursor:default}button:focus-visible,input:focus-visible,summary:focus-visible,select:focus-visible{outline:3px solid var(--blue);outline-offset:2px}h1,h2,h3,p{margin:0}h1{font-size:32px;font-weight:600;letter-spacing:-.6px}h2{font-size:25px;font-weight:600;margin-bottom:12px}h3{font-size:22px;font-weight:550;margin-bottom:10px}p{margin-bottom:14px}small,.sub{color:var(--muted)}small{font-size:17px}.sub{font-size:18px;overflow-wrap:anywhere}.primary{background:#dce5ec;color:#192129;border-color:#dce5ec;font-weight:600}.primary:hover:not(:disabled){background:#fff}.danger{background:transparent;color:#efa9a8;border-color:#674647}
input:not([type=checkbox]):not([type=file]),textarea,select{width:100%;padding:12px 15px;border:1px solid #494d54;border-radius:4px;background:#131518;min-height:52px;margin:8px 0 14px}input[type=checkbox]{width:24px;height:24px;accent-color:var(--blue);vertical-align:middle;margin:8px 12px 8px 0}label{display:block;font-size:19px;margin:10px 0}input[type=file]{width:100%;padding:18px;border:1px dashed #565c66;border-radius:4px}input[type=file]::file-selector-button{font:inherit;padding:10px 14px;margin-right:14px;background:#30343a;color:var(--text);border:0;border-radius:4px}
.app{height:100%;display:flex;flex-direction:column;position:relative;background:var(--bg);border-radius:8px;clip-path:inset(0 round 8px);overflow:hidden}.shell-header{display:flex;align-items:center;gap:14px;padding:18px 26px;border-bottom:1px solid var(--line);flex:none;min-height:90px}.shell-header strong{flex:1;font-size:28px;font-weight:600;letter-spacing:-.7px}.header-label{font-size:18px;font-weight:400;color:var(--muted);margin-left:18px;letter-spacing:0}.brand-mark{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;flex:none;color:#dce2e8}.brand-mark img{width:32px;height:32px;image-rendering:pixelated}.brand-wordmark{width:200px;height:48px;max-width:100%}.brand-wordmark img{width:100%;height:auto}.shell-header .brand-title{min-width:0;line-height:0}.icon-button{min-width:52px;min-height:52px;padding:12px;border-color:transparent;background:transparent;color:var(--muted)}.icon-button:hover:not(:disabled){background:#30343a;color:#fff}.favorite.selected{color:#e2c783}
.workspace{flex:1;min-height:0;display:flex;flex-direction:column}.manager .workspace{display:grid;grid-template-columns:222px minmax(0,1fr)}nav{display:flex;gap:6px;flex:none}nav button{border-color:transparent;background:transparent;color:var(--muted)}nav button.active{background:#30343a;color:#fff}.manager nav{flex-direction:column;background:#131518;border-right:1px solid var(--line);padding:26px 14px;gap:6px}.manager nav button{justify-content:flex-start;text-align:left;padding:14px 16px;min-height:60px}.manager nav button.active{border-left:3px solid var(--blue);padding-left:14px;border-radius:3px}.nav-foot{margin-top:auto;padding:24px 16px 0;color:var(--muted);font-size:16px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px}.nav-foot small{font-size:15px}.content{min-height:0;overflow:auto;flex:1;padding:28px 32px 36px}.list-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:28px}.list-heading p{font-size:18px;color:var(--muted);margin:5px 0 0}.search-field{display:flex;align-items:center;gap:12px;min-width:250px;width:360px;background:#131518;border:1px solid #41464f;border-radius:4px;padding:0 14px;color:var(--muted)}.search-field input{background:transparent!important;border:0!important;padding:12px 0!important;margin:0!important;min-width:0;font-size:19px;outline:none}.search-field:focus-within{outline:2px solid var(--blue)}
.list-columns{display:grid;grid-template-columns:minmax(0,1fr) 125px 110px 184px;gap:14px;padding:0 12px 14px;border-bottom:1px solid var(--line);font-size:16px;color:var(--muted)}.list-columns span:last-child{text-align:right;padding-right:20px}.plugin-list{display:flex;flex-direction:column}.plugin-row{display:flex;align-items:center;gap:14px;min-height:102px;padding:14px 10px;border-bottom:1px solid var(--line)}.plugin-row:hover{background:#202327}.plugin-main{flex:1;min-width:0;display:flex;gap:18px;justify-content:flex-start;text-align:left;border:0;background:transparent;padding:8px 0;min-height:64px}.plugin-main:hover:not(:disabled){background:transparent}.plugin-avatar{flex:none;display:flex;align-items:center;justify-content:center;background:#2c3036;color:#aeb6c2;border-radius:5px}.plugin-avatar img{width:100%;height:100%;object-fit:contain;border-radius:5px}.plugin-copy{min-width:0;display:flex;flex-direction:column;gap:4px}.plugin-copy b{font-size:23px;font-weight:550;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.plugin-copy small{font-size:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.plugin-meta{display:flex;align-items:center;gap:8px;font-size:16px;color:var(--muted)}.manager .plugin-meta{display:none}.status-dot{width:6px;height:6px;border-radius:50%;background:#a5cdb2}.status-dot.off{background:#747982}.status-dot.failed{background:#edaaaa}.version{color:#949ba6;margin-left:8px}.row-state{width:125px;font-size:18px;color:#c3c8cf;flex:none}.row-version{width:110px;font:17px/1.45 system-ui;color:var(--muted);flex:none;overflow-wrap:anywhere}.plugin-switch{width:52px;min-height:52px;height:52px;border:0;display:block;padding:11px 0;flex:none;background:transparent}.plugin-switch span{display:block;width:52px;height:30px;border:1px solid #626770;background:#2a2e34;border-radius:18px;position:relative}.plugin-switch span:after{content:'';position:absolute;left:3px;top:3px;width:22px;height:22px;background:#a4aab3;border-radius:50%}.plugin-switch.on span{background:#445b69;border-color:#7697aa}.plugin-switch.on span:after{left:25px;background:#e5f0f8}.disabled .plugin-copy b{color:#aaaeb5}.disabled .plugin-avatar{opacity:.6}
.quick{border-radius:8px}.quick .shell-header{padding:18px 24px;min-height:80px}.quick .workspace{display:flex}.quick nav{order:2;margin-top:auto;background:#131518;border-top:1px solid var(--line);padding:8px;gap:4px}.quick nav button{flex:1;flex-direction:column;gap:5px;padding:12px 8px;min-height:82px;font-size:18px}.quick .content{padding:10px 18px 24px}.quick .plugin-row{gap:6px;padding:10px 6px;min-height:93px}.quick .plugin-copy b{font-size:22px}.quick .plugin-copy{gap:7px}.quick .plugin-avatar{width:44px!important;height:44px!important}.quick .plugin-main{gap:14px}.quick .icon-button{min-width:48px}.quick .empty-state{min-height:280px}.quick .settings-card{padding:24px 0}.quick-settings{padding:10px 4px}.setting-entry{width:100%;background:transparent;border:0;padding:14px 0;justify-content:flex-start;text-align:left;gap:16px}.setting-entry>span:nth-child(2){flex:1}.setting-entry b,.setting-toggle b{display:block;font-size:23px;font-weight:550}.setting-entry small,.setting-toggle small{display:block;margin-top:5px;font-size:17px}.setting-symbol{color:#adb7c4}.forward{font-size:32px;color:var(--muted)}.setting-toggle{width:100%;text-align:left;background:transparent;border:0;justify-content:space-between;padding:0}.toggle{width:54px;height:30px;border-radius:20px;background:#3e444e;position:relative;flex:none}.toggle:after{content:'';position:absolute;left:4px;top:4px;width:22px;height:22px;border-radius:50%;background:#bbc2cc}.toggle.on{background:#526d80}.toggle.on:after{left:28px;background:#e6f1f9}.about{display:flex;align-items:center;gap:10px;padding:24px 0 0;font-size:18px;color:var(--muted)}.about small{margin-left:auto;font-size:15px;overflow-wrap:anywhere}
.store-filters{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:22px}.store-filters .search-field{flex:1}.store-grid{display:flex;flex-direction:column;border-top:1px solid var(--line)}.store-card{width:100%;display:flex;align-items:center;gap:20px;justify-content:flex-start;text-align:left;padding:24px 12px;min-height:140px;border:0;border-bottom:1px solid var(--line);border-radius:0;background:transparent}.store-card:hover:not(:disabled){background:#24272c}.store-copy{flex:1;min-width:0}.store-copy h2{font-size:24px;margin-bottom:6px;font-weight:550}.store-copy p{font-size:19px;color:#bec3cb;margin:0 0 8px;overflow-wrap:anywhere}.store-copy small{font-size:16px}.store-version{font-size:17px;color:var(--muted);max-width:140px;overflow-wrap:anywhere}.store-card>.badge{min-width:86px;justify-content:center}.store-card>svg{transform:rotate(180deg);color:#858d98}.badge,.badges span{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border:1px solid #464b54;border-radius:3px;background:transparent;font-size:16px;color:#bcc3ce;white-space:nowrap}.badge.accent{border-color:#7498b1;color:#b3d9f2}.back-link{margin-bottom:26px;background:transparent;border-color:transparent;padding-left:0;color:#c4d7e6}.detail-heading{display:flex;align-items:center;gap:22px;margin-bottom:22px}.detail-heading>div{flex:1;min-width:0}.detail-heading p{color:var(--muted);margin:6px 0 0}.badges{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px}.detail-summary{font-size:24px;color:#dddfe5;max-width:950px}.store-detail{max-width:1100px}.store-detail h2{margin-top:30px}.prose{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.7;color:#bec5d0}.screenshots{display:flex;gap:16px;overflow:auto;margin:24px 0}.screenshots figure{margin:0;flex:0 0 75%;height:320px;background:#101215}.screenshots img{width:100%;height:100%;object-fit:contain}.fingerprint{font-size:16px;overflow-wrap:anywhere;color:var(--muted)}
.settings-card{border-top:1px solid var(--line);padding:26px 0;margin:0 0 16px}.settings-card h2{color:#e5e7eb;font-size:24px;font-weight:550}.settings-card>p,.source-form,.settings-card>label{max-width:860px}.settings-card .sub{line-height:1.6}.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:16px}.row h2{flex:1}.source-row{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;padding:24px 0;border-bottom:1px solid var(--line)}.source-row>div:first-child{flex:1;min-width:0}.source-row .row{margin-top:0;max-width:470px}.source-row b{margin-right:14px;font-weight:550}.source-row p{margin:8px 0}.source-row small{font-size:16px}.source-url{display:block;margin:8px 0;color:var(--muted);font-size:17px;overflow-wrap:anywhere}.source-form{padding-top:28px}.confirm-box,.release-card{padding:22px;border:1px solid #565d68;background:#202328;margin-top:24px;border-radius:4px}.confirm-box p{color:#c9cfd9}.settings-card details{font-size:18px;margin-top:10px}.settings-card summary{cursor:pointer;padding:8px 0}.plugin{padding:18px 0;border-bottom:1px solid var(--line)}.plugin>small{display:block;overflow-wrap:anywhere}
.empty-state{min-height:320px;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:38px;text-align:center;gap:16px}.empty-state h2{font-size:26px;margin:0}.empty-state p{color:var(--muted);font-size:19px;max-width:520px;margin:0}.empty-symbol{color:#808994}.empty{padding:36px;text-align:center;color:var(--muted)}.page-title{padding:16px 24px;display:flex;align-items:center;gap:12px;flex:none;border-bottom:1px solid var(--line)}.page-title strong{flex:1;font-size:24px}.quick:has(.page-title) .shell-header{display:none}iframe{width:100%;flex:1;min-height:0;border:0;background:var(--bg)}.error,.banner{padding:14px 16px;margin:12px 0;border-radius:3px;line-height:1.5;overflow-wrap:anywhere;font-size:18px;flex:none;border-left:3px solid #929ca9;background:#252930;color:#d5dbe4}.error{border-color:#db9797;background:#3a292d;color:#f1b8b8}.app>.error,.app>.banner{margin:12px 26px}.error button{float:right;min-height:30px;padding:0 8px;background:transparent;border:0}.banner p{margin:8px 0}
.modal{position:absolute;top:50%;bottom:auto;transform:translateY(-50%);max-height:calc(100% - 56px);left:max(24px,calc((100% - 780px)/2));right:max(24px,calc((100% - 780px)/2));padding:30px;background:#202328;border:1px solid #626974;border-radius:6px;overflow:auto;box-shadow:0 0 0 2000px #000a;z-index:20}.modal h2{font-size:28px;margin-bottom:16px}.modal h3{margin-top:24px}.modal button{margin:8px 8px 8px 0}.modal .source-row{display:block}.modal .row{margin:12px 0}.modal code{display:block;font-size:17px;overflow-wrap:anywhere;background:#141619;padding:16px;border-radius:3px}.modal pre{font:17px/1.6 monospace;white-space:pre-wrap;overflow-wrap:anywhere}.modal-close{float:right}.transfer{z-index:30;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:16px}.transfer>p{margin:0}progress{width:100%;height:10px;accent-color:var(--blue)}.download-progress{margin:24px 0}.system-updates>h2{display:none}.system-updates .error small{display:block;margin-top:8px;color:inherit}.manage-dialog>button{min-width:150px}.manage-dialog>.danger{display:block}
:root{color-scheme:dark;--bg:rgba(18,18,18,.94);--surface:rgba(26,26,26,.86);--overlay-bg:rgba(18,18,18,.86);--control-hover:#ffffff14;--control-selected:#ffffff22;--line:#353535;--muted:#a4a8b0;--blue:#93c5ed;--text:#f0f1f3}
*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{color:var(--text);background:transparent;font:21px/1.45 system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}svg{flex:none;display:block}button,input,textarea,select{font:inherit;color:inherit}button{display:inline-flex;align-items:center;justify-content:center;gap:10px;min-height:52px;padding:11px 18px;border:1px solid var(--line);border-radius:5px;background:#292929;cursor:pointer}button:hover:not(:disabled){background:#363636}button:disabled{opacity:.4;cursor:default}button:focus-visible,input:focus-visible,summary:focus-visible,select:focus-visible{outline:3px solid var(--blue);outline-offset:2px}h1,h2,h3,p{margin:0}h1{font-size:32px;font-weight:600;letter-spacing:-.6px}h2{font-size:25px;font-weight:600;margin-bottom:12px}h3{font-size:22px;font-weight:550;margin-bottom:10px}p{margin-bottom:14px}small,.sub{color:var(--muted)}small{font-size:17px}.sub{font-size:18px;overflow-wrap:anywhere}.primary{background:var(--control-selected);color:var(--text);border-color:#ffffff30;font-weight:600}.primary:hover:not(:disabled){background:#ffffff30}.danger{background:transparent;color:#efa9a8;border-color:#674647}
input:not([type=checkbox]):not([type=file]),textarea,select{width:100%;padding:12px 15px;border:1px solid #494d54;border-radius:4px;background:#131313;min-height:52px;margin:8px 0 14px}input[type=checkbox]{width:24px;height:24px;accent-color:var(--blue);vertical-align:middle;margin:8px 12px 8px 0}label{display:block;font-size:19px;margin:10px 0}input[type=file]{width:100%;padding:18px;border:1px dashed #565c66;border-radius:4px}input[type=file]::file-selector-button{font:inherit;padding:10px 14px;margin-right:14px;background:#303030;color:var(--text);border:0;border-radius:4px}
.app{height:100%;display:flex;flex-direction:column;position:relative;background:var(--bg);border-radius:8px;clip-path:inset(0 round 8px);overflow:hidden}.shell-header{display:flex;align-items:center;gap:14px;padding:18px 26px;border-bottom:1px solid var(--line);flex:none;min-height:90px}.shell-header strong{flex:1;font-size:28px;font-weight:600;letter-spacing:-.7px}.header-label{font-size:18px;font-weight:400;color:var(--muted);margin-left:18px;letter-spacing:0}.brand-mark{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;flex:none;color:#dce2e8}.brand-mark img{width:32px;height:32px;image-rendering:pixelated}.brand-wordmark{width:200px;height:48px;max-width:100%}.brand-wordmark img{width:100%;height:auto}.shell-header .brand-title{min-width:0;line-height:0}.icon-button{min-width:52px;min-height:52px;padding:12px;border-color:transparent;background:transparent;color:var(--muted)}.icon-button:hover:not(:disabled){background:#303030;color:#fff}.favorite.selected{color:#e2c783}
.workspace{flex:1;min-height:0;display:flex;flex-direction:column}.manager .workspace{display:grid;grid-template-columns:222px minmax(0,1fr)}nav{display:flex;gap:6px;flex:none}nav button{border-color:transparent;background:transparent;color:var(--muted)}nav button.active{background:#303030;color:#fff}.manager nav{flex-direction:column;background:#131313;border-right:1px solid var(--line);padding:26px 14px;gap:6px}.manager nav button{justify-content:flex-start;text-align:left;padding:14px 16px;min-height:60px}.manager nav button.active{border-left:3px solid var(--blue);padding-left:14px;border-radius:3px}.nav-foot{margin-top:auto;padding:24px 16px 0;color:var(--muted);font-size:16px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px}.nav-foot small{font-size:15px}.content{min-height:0;overflow:auto;flex:1;padding:28px 32px 36px}.list-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:28px}.list-heading p{font-size:18px;color:var(--muted);margin:5px 0 0}.search-field{display:flex;align-items:center;gap:12px;min-width:250px;width:360px;background:#131313;border:1px solid #41464f;border-radius:4px;padding:0 14px;color:var(--muted)}.search-field input{background:transparent!important;border:0!important;padding:12px 0!important;margin:0!important;min-width:0;font-size:19px;outline:none}.search-field:focus-within{outline:2px solid var(--blue)}
.list-columns{display:grid;grid-template-columns:minmax(0,1fr) 125px 110px 184px;gap:14px;padding:0 12px 14px;border-bottom:1px solid var(--line);font-size:16px;color:var(--muted)}.list-columns span:last-child{text-align:right;padding-right:20px}.plugin-list{display:flex;flex-direction:column}.plugin-row{display:flex;align-items:center;gap:14px;min-height:102px;padding:14px 10px;border-bottom:1px solid var(--line)}.plugin-row:hover{background:#202020}.plugin-main{flex:1;min-width:0;display:flex;gap:18px;justify-content:flex-start;text-align:left;border:0;background:transparent;padding:8px 0;min-height:64px}.plugin-main:hover:not(:disabled){background:transparent}.plugin-avatar{flex:none;display:flex;align-items:center;justify-content:center;background:#2c2c2c;color:#aeb6c2;border-radius:5px}.plugin-avatar img{width:100%;height:100%;object-fit:contain;border-radius:5px}.plugin-copy{min-width:0;display:flex;flex-direction:column;gap:4px}.plugin-copy b{font-size:23px;font-weight:550;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.plugin-copy small{font-size:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.plugin-meta{display:flex;align-items:center;gap:8px;font-size:16px;color:var(--muted)}.manager .plugin-meta{display:none}.status-dot{width:6px;height:6px;border-radius:50%;background:#a5cdb2}.status-dot.off{background:#7a7a7a}.status-dot.failed{background:#edaaaa}.version{color:#949ba6;margin-left:8px}.row-state{width:125px;font-size:18px;color:#c3c8cf;flex:none}.row-version{width:110px;font:17px/1.45 system-ui;color:var(--muted);flex:none;overflow-wrap:anywhere}.plugin-switch{width:52px;min-height:52px;height:52px;border:0;display:block;padding:11px 0;flex:none;background:transparent}.plugin-switch span{display:block;width:52px;height:30px;border:1px solid #626770;background:#2f2f2f;border-radius:18px;position:relative}.plugin-switch span:after{content:'';position:absolute;left:3px;top:3px;width:22px;height:22px;background:#ababab;border-radius:50%}.plugin-switch.on span{background:#505050;border-color:#7697aa}.plugin-switch.on span:after{left:25px;background:#f0f0f0}.disabled .plugin-copy b{color:#aaaeb5}.disabled .plugin-avatar{opacity:.6}
.quick{border-radius:8px}.quick .shell-header{padding:18px 24px;min-height:80px}.quick .workspace{display:flex}.quick nav{order:2;margin-top:auto;background:#131313;border-top:1px solid var(--line);padding:8px;gap:4px}.quick nav button{flex:1;flex-direction:column;gap:5px;padding:12px 8px;min-height:82px;font-size:18px}.quick .content{padding:10px 18px 24px}.quick .plugin-row{gap:6px;padding:10px 6px;min-height:93px}.quick .plugin-copy b{font-size:22px}.quick .plugin-copy{gap:7px}.quick .plugin-avatar{width:44px!important;height:44px!important}.quick .plugin-main{gap:14px}.quick .icon-button{min-width:48px}.quick .empty-state{min-height:280px}.quick .settings-card{padding:24px 0}.quick-settings{padding:10px 4px}.setting-entry{width:100%;background:transparent;border:0;padding:14px 0;justify-content:flex-start;text-align:left;gap:16px}.setting-entry>span:nth-child(2){flex:1}.setting-entry b,.setting-toggle b{display:block;font-size:23px;font-weight:550}.setting-entry small,.setting-toggle small{display:block;margin-top:5px;font-size:17px}.setting-symbol{color:#adb7c4}.forward{font-size:32px;color:var(--muted)}.setting-toggle{width:100%;text-align:left;background:transparent;border:0;justify-content:space-between;padding:0}.toggle{width:54px;height:30px;border-radius:20px;background:#454545;position:relative;flex:none}.toggle:after{content:'';position:absolute;left:4px;top:4px;width:22px;height:22px;border-radius:50%;background:#c3c3c3}.toggle.on{background:#505050}.toggle.on:after{left:28px;background:#f0f0f0}.about{display:flex;align-items:center;gap:10px;padding:24px 0 0;font-size:18px;color:var(--muted)}.about small{margin-left:auto;font-size:15px;overflow-wrap:anywhere}
.store-filters{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:22px}.store-filters .search-field{flex:1}.store-grid{display:flex;flex-direction:column;border-top:1px solid var(--line)}.store-card{width:100%;display:flex;align-items:center;gap:20px;justify-content:flex-start;text-align:left;padding:24px 12px;min-height:140px;border:0;border-bottom:1px solid var(--line);border-radius:0;background:transparent}.store-card:hover:not(:disabled){background:#282828}.store-copy{flex:1;min-width:0}.store-copy h2{font-size:24px;margin-bottom:6px;font-weight:550}.store-copy p{font-size:19px;color:#bec3cb;margin:0 0 8px;overflow-wrap:anywhere}.store-copy small{font-size:16px}.store-version{font-size:17px;color:var(--muted);max-width:140px;overflow-wrap:anywhere}.store-card>.badge{min-width:86px;justify-content:center}.store-card>svg{transform:rotate(180deg);color:#858d98}.badge,.badges span{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border:1px solid #464b54;border-radius:3px;background:transparent;font-size:16px;color:#bcc3ce;white-space:nowrap}.badge.accent{border-color:#7498b1;color:#b3d9f2}.back-link{margin-bottom:26px;background:transparent;border-color:transparent;padding-left:0;color:#c4d7e6}.detail-heading{display:flex;align-items:center;gap:22px;margin-bottom:22px}.detail-heading>div{flex:1;min-width:0}.detail-heading p{color:var(--muted);margin:6px 0 0}.badges{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px}.detail-summary{font-size:24px;color:#dddfe5;max-width:950px}.store-detail{max-width:1100px}.store-detail h2{margin-top:30px}.prose{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.7;color:#bec5d0}.screenshots{display:flex;gap:16px;overflow:auto;margin:24px 0}.screenshots figure{margin:0;flex:0 0 75%;height:320px;background:#101215}.screenshots img{width:100%;height:100%;object-fit:contain}.fingerprint{font-size:16px;overflow-wrap:anywhere;color:var(--muted)}
.settings-card{border-top:1px solid var(--line);padding:26px 0;margin:0 0 16px}.settings-card h2{color:#e5e7eb;font-size:24px;font-weight:550}.settings-card>p,.source-form,.settings-card>label{max-width:860px}.settings-card .sub{line-height:1.6}.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:16px}.row h2{flex:1}.source-row{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;padding:24px 0;border-bottom:1px solid var(--line)}.source-row>div:first-child{flex:1;min-width:0}.source-row .row{margin-top:0;max-width:470px}.source-row b{margin-right:14px;font-weight:550}.source-row p{margin:8px 0}.source-row small{font-size:16px}.source-url{display:block;margin:8px 0;color:var(--muted);font-size:17px;overflow-wrap:anywhere}.source-form{padding-top:28px}.confirm-box,.release-card{padding:22px;border:1px solid #565d68;background:#202020;margin-top:24px;border-radius:4px}.confirm-box p{color:#c9cfd9}.settings-card details{font-size:18px;margin-top:10px}.settings-card summary{cursor:pointer;padding:8px 0}.plugin{padding:18px 0;border-bottom:1px solid var(--line)}.plugin>small{display:block;overflow-wrap:anywhere}
.empty-state{min-height:320px;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:38px;text-align:center;gap:16px}.empty-state h2{font-size:26px;margin:0}.empty-state p{color:var(--muted);font-size:19px;max-width:520px;margin:0}.empty-symbol{color:#808994}.empty{padding:36px;text-align:center;color:var(--muted)}.page-title{padding:16px 24px;display:flex;align-items:center;gap:12px;flex:none;border-bottom:1px solid var(--line)}.page-title strong{flex:1;font-size:24px}.quick:has(.page-title) .shell-header{display:none}iframe{width:100%;flex:1;min-height:0;border:0;background:var(--bg)}.error,.banner{padding:14px 16px;margin:12px 0;border-radius:3px;line-height:1.5;overflow-wrap:anywhere;font-size:18px;flex:none;border-left:3px solid #929ca9;background:#2a2a2a;color:#d5dbe4}.error{border-color:#db9797;background:#3a292d;color:#f1b8b8}.app>.error,.app>.banner{margin:12px 26px}.error button{float:right;min-height:30px;padding:0 8px;background:transparent;border:0}.banner p{margin:8px 0}
.modal{position:absolute;top:50%;bottom:auto;transform:translateY(-50%);max-height:calc(100% - 56px);left:max(24px,calc((100% - 780px)/2));right:max(24px,calc((100% - 780px)/2));padding:30px;background:#202020;border:1px solid #626974;border-radius:6px;overflow:auto;box-shadow:0 0 0 2000px #000a;z-index:20}.modal h2{font-size:28px;margin-bottom:16px}.modal h3{margin-top:24px}.modal button{margin:8px 8px 8px 0}.modal .source-row{display:block}.modal .row{margin:12px 0}.modal code{display:block;font-size:17px;overflow-wrap:anywhere;background:#141619;padding:16px;border-radius:3px}.modal pre{font:17px/1.6 monospace;white-space:pre-wrap;overflow-wrap:anywhere}.modal-close{float:right}.transfer{z-index:30;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:16px}.transfer>p{margin:0}progress{width:100%;height:10px;accent-color:var(--blue)}.download-progress{margin:24px 0}.system-updates>h2{display:none}.system-updates .error small{display:block;margin-top:8px;color:inherit}.manage-dialog>button{min-width:150px}.manage-dialog>.danger{display:block}
.update-confirmation,.update-release-dialog{position:fixed;margin:0;width:auto;color:var(--text);box-shadow:none}.update-confirmation::backdrop,.update-release-dialog::backdrop{background:#000a}
html.notification-view,html.notification-view body{background:transparent}.toasts{height:100%;overflow:auto;padding:8px;background:transparent;font-size:18px}.toast{padding:16px;background:#202328;border:1px solid #454b55;border-radius:8px;margin-bottom:8px}.toast-heading{display:flex;align-items:center;gap:10px}.toast-heading>div{flex:1;min-width:0}.toast-heading small{font-size:14px}.toast h2{font-size:21px;line-height:1.3;margin:2px 0 0;overflow-wrap:anywhere}.toast-close{min-width:36px;min-height:36px;padding:6px;border:0;background:transparent;color:var(--muted)}.toast-content{display:flex;align-items:flex-start;gap:12px;margin-top:12px}.toast-content img{width:80px;height:64px;object-fit:contain;flex:none}.toast-content p{margin:0;line-height:1.45;max-height:104px;overflow:auto;overflow-wrap:anywhere;flex:1}.toast-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}.toast-actions button{min-width:44px;min-height:40px;padding:6px 10px;font-size:20px;border-radius:4px}
html.notification-view,html.notification-view body{background:transparent}.toasts{height:100%;overflow:auto;padding:8px;background:transparent;font-size:18px}.toast{padding:16px;background:#202020;border:1px solid #454b55;border-radius:8px;margin-bottom:8px}.toast-heading{display:flex;align-items:center;gap:10px}.toast-heading>div{flex:1;min-width:0}.toast-heading small{font-size:14px}.toast h2{font-size:21px;line-height:1.3;margin:2px 0 0;overflow-wrap:anywhere}.toast-close{min-width:36px;min-height:36px;padding:6px;border:0;background:transparent;color:var(--muted)}.toast-content{display:flex;align-items:flex-start;gap:12px;margin-top:12px}.toast-content img{width:80px;height:64px;object-fit:contain;flex:none}.toast-content p{margin:0;line-height:1.45;max-height:104px;overflow:auto;overflow-wrap:anywhere;flex:1}.toast-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}.toast-actions button{min-width:44px;min-height:40px;padding:6px 10px;font-size:20px;border-radius:4px}
@media(max-width:1000px){.manager .workspace{grid-template-columns:186px minmax(0,1fr)}.manager nav{padding:20px 8px}.manager nav button{padding:12px 10px;font-size:18px}.content{padding:24px}.row-state{width:90px}.row-version{width:85px}.list-columns{grid-template-columns:minmax(0,1fr) 90px 85px 184px}.list-heading{flex-wrap:wrap}.manager .plugin-copy small{white-space:normal}.source-row{flex-direction:column}.store-version{display:none}}
@media(max-width:700px){.manager .workspace{display:flex}.manager nav{flex-direction:row;padding:8px;overflow:auto;border-right:0;border-bottom:1px solid var(--line)}.manager nav button{white-space:nowrap}.nav-foot{display:none}.shell-header{padding:14px 18px}.header-label{display:none}.shell-header .primary{font-size:18px}.content{padding:18px}.list-columns,.row-state,.row-version,.manager .plugin-switch{display:none}.manager .plugin-meta{display:flex}.list-heading .search-field{width:100%}.store-card{gap:14px;padding:18px 0}.store-card>.badge{min-width:65px;font-size:15px}.store-card>.plugin-avatar{width:64px!important;height:64px!important}.store-card>svg{display:none}.store-copy p{font-size:17px}.store-copy h2{font-size:22px}.store-filters .search-field{min-width:100%}.detail-heading{flex-wrap:wrap}.detail-heading .primary{width:100%}.modal{left:14px;right:14px;max-height:calc(100% - 28px);padding:22px}}
.manage-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:20px 0}.manage-grid button{margin:0}.manage-grid .danger{grid-column:1/-1}.dialog-footer{border-top:1px solid var(--line);padding-top:16px;display:flex;justify-content:flex-end}.plugin-relations{border-block:1px solid var(--line);padding:16px 0;margin:20px 0}.plugin-relations summary{cursor:pointer;color:var(--muted);font-size:19px}.plugin-relations section{margin-top:16px}.framely-select [role=listbox]{background:#202328!important;border-color:#59616d!important;border-radius:4px}.framely-select [role=option][aria-selected=true]{background:#3c434e!important}.framely-select [role=option]{border:0;border-radius:2px}.framely-select [role=option]:hover{background:#363c45!important}
.manage-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:20px 0}.manage-grid button{margin:0}.manage-grid .danger{grid-column:1/-1}.dialog-footer{border-top:1px solid var(--line);padding-top:16px;display:flex;justify-content:flex-end}.plugin-relations{border-block:1px solid var(--line);padding:16px 0;margin:20px 0}.plugin-relations summary{cursor:pointer;color:var(--muted);font-size:19px}.plugin-relations section{margin-top:16px}.framely-select [role=listbox]{background:#242424!important;border-color:#59616d!important;border-radius:4px}.framely-select [role=option][aria-selected=true]{background:#404040!important}.framely-select [role=option]{border:0;border-radius:2px}.framely-select [role=option]:hover{background:#363636!important}
.source-add{margin:20px 0 0}.app:has(.modal) .shell-header,.app:has(.modal) .workspace{pointer-events:none}.modal{pointer-events:auto}.modal button{pointer-events:auto}
@@ -85,7 +85,7 @@ button{overflow-wrap:anywhere}
.source-icon-button{width:52px;height:52px;padding:12px;color:var(--muted)}
.source-icon-button:hover:not(:disabled){color:var(--text)}
.source-icon-button.danger{color:#efa9a8}
.button-tooltip{position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);z-index:15;pointer-events:none;visibility:hidden;opacity:0;transition:opacity .1s;background:#30343a;color:var(--text);border:1px solid #59616d;border-radius:4px;padding:6px 10px;font-size:16px;line-height:1.4;white-space:nowrap;box-shadow:0 3px 10px #0006}
.button-tooltip{position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);z-index:15;pointer-events:none;visibility:hidden;opacity:0;transition:opacity .1s;background:#303030;color:var(--text);border:1px solid #59616d;border-radius:4px;padding:6px 10px;font-size:16px;line-height:1.4;white-space:nowrap;box-shadow:0 3px 10px #0006}
.tooltip-button:hover .button-tooltip,.tooltip-button:focus-within .button-tooltip{visibility:visible;opacity:1}
.source-actions .tooltip-button:last-child .button-tooltip{left:auto;right:0;transform:none}
@media(max-width:700px){.source-row:has(.source-actions){flex-direction:column}}
@@ -100,7 +100,7 @@ button{overflow-wrap:anywhere}
.install-package-meta{display:flex;justify-content:space-between;align-items:center;gap:16px;color:var(--muted);font-size:18px}
.install-version{display:flex;align-items:center;gap:12px;font-variant-numeric:tabular-nums;white-space:nowrap}.install-version strong{font-weight:550;color:var(--text)}
.install-review-body{padding:24px 30px;overflow:auto;min-height:0;overscroll-behavior:contain}
.install-run-user{--install-warning:#f2b5ad;--install-warning-bg:#332426;--install-warning-line:#a56560;padding:14px 18px;background:#282c32;border:1px solid #4b535e;border-radius:5px;margin-bottom:24px}
.install-run-user{--install-warning:#f2b5ad;--install-warning-bg:#332426;--install-warning-line:#a56560;padding:14px 18px;background:#282828;border:1px solid #4b535e;border-radius:5px;margin-bottom:24px}
.install-run-heading{display:flex;align-items:center;gap:12px;font-size:18px;color:#bcc4ce}.install-account-kind{margin-left:auto;font-size:16px}
.install-account{display:inline-block;margin:0;font:650 24px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:-.5px;color:var(--text)}
.install-run-user p{font-size:17px;line-height:1.5;margin:8px 0 0;max-width:65ch;color:#c7cdd5}
@@ -110,10 +110,10 @@ button{overflow-wrap:anywhere}
.install-root-dependencies{padding:12px 18px;margin-bottom:24px;border:1px solid var(--install-warning-line);border-left-width:4px;border-radius:5px;background:var(--install-warning-bg);color:#ecd4d1;font-size:18px}
.install-root-dependencies div{display:flex;flex-wrap:wrap;gap:8px}.install-root-dependencies strong{color:var(--install-warning);font-size:18px}.install-root-dependencies p{font-size:17px;margin:6px 0 0;overflow-wrap:anywhere}
.install-review .install-plan h3{margin:0 0 12px;font-size:20px;color:#c7cdd5}.install-plan-items{display:grid;gap:0}.install-plan-item{padding:16px 0;border-bottom:1px solid var(--line)}.install-plan-item:first-child{padding-top:4px}.install-plan-item:last-child{border-bottom:0}
.install-item-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.install-item-heading b{font-size:21px;font-weight:550;overflow-wrap:anywhere}.run-user-badge{display:inline-flex;flex:none;align-items:center;padding:4px 10px;border:1px solid #505a68;background:#2b3038;color:#d4dce6;border-radius:4px;font:550 16px/1.3 ui-monospace,SFMono-Regular,Consolas,monospace}.run-user-badge.is-root{color:#f2b5ad;border-color:#a56560;background:#332426}
.install-item-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.install-item-heading b{font-size:21px;font-weight:550;overflow-wrap:anywhere}.run-user-badge{display:inline-flex;flex:none;align-items:center;padding:4px 10px;border:1px solid #505a68;background:#2b2b2b;color:#d4dce6;border-radius:4px;font:550 16px/1.3 ui-monospace,SFMono-Regular,Consolas,monospace}.run-user-badge.is-root{color:#f2b5ad;border-color:#a56560;background:#332426}
.install-item-version{display:flex;gap:12px;margin-top:6px;color:#d1d5db;font-size:18px;font-variant-numeric:tabular-nums}.install-item-version span:first-child{color:var(--muted)}.install-item-source{display:block;margin-top:6px;overflow-wrap:anywhere}.install-user-change{display:inline-block;margin-left:14px;color:#c7cdd5}.install-plan-effect{font-size:17px;color:#c7cdd5;margin:14px 0 0}.install-plan-effect span{color:var(--muted)}
.install-review .install-plan h3:not(:first-child){margin-top:18px}.install-user-confirm{display:flex;align-items:flex-start;gap:12px;margin-top:20px;padding-top:18px;border-top:1px solid var(--line);font-size:18px;cursor:pointer}.install-user-confirm input{width:22px;height:22px;flex:none;margin:3px 0 0;accent-color:var(--blue)}.install-user-confirm:has(input:focus-visible){outline:3px solid var(--blue);outline-offset:5px}
.install-review-footer{flex:none;display:flex;align-items:center;justify-content:space-between;gap:18px;padding:18px 30px;border-top:1px solid var(--line);background:#202328}.install-footer-note{font-size:17px;color:var(--muted)}.includes-root .install-footer-note{color:var(--install-warning)}.install-review-actions{display:flex;gap:12px;flex:none}.install-review .install-review-actions button{margin:0;min-width:100px;white-space:nowrap}.install-review .install-review-actions .primary{min-width:144px}.includes-root .install-review-actions .primary{background:#f2b5ad;border-color:#f2b5ad;color:#321c1a}.includes-root .install-review-actions .primary:hover:not(:disabled){background:#f8c9c2}.install-review-actions button:active:not(:disabled){transform:scale(.98)}
.install-review-footer{flex:none;display:flex;align-items:center;justify-content:space-between;gap:18px;padding:18px 30px;border-top:1px solid var(--line);background:#202020}.install-footer-note{font-size:17px;color:var(--muted)}.includes-root .install-footer-note{color:var(--install-warning)}.install-review-actions{display:flex;gap:12px;flex:none}.install-review .install-review-actions button{margin:0;min-width:100px;white-space:nowrap}.install-review .install-review-actions .primary{min-width:144px}.includes-root .install-review-actions .primary{background:#f2b5ad;border-color:#f2b5ad;color:#321c1a}.includes-root .install-review-actions .primary:hover:not(:disabled){background:#f8c9c2}.install-review-actions button:active:not(:disabled){transform:scale(.98)}
@media(max-width:700px){.install-review{left:14px;right:14px;padding:0}.install-review-heading{padding:22px}.install-review-heading h2{font-size:26px}.install-package-meta{align-items:flex-start;flex-direction:column;gap:8px}.install-review-body{padding:20px 22px}.install-run-user{--install-warning:#f2b5ad;--install-warning-bg:#332426;--install-warning-line:#a56560;padding:18px}.install-run-heading{flex-wrap:wrap;gap:8px}.install-account{font-size:24px}.install-account-kind{font-size:16px}.install-review-footer{padding:16px 22px;flex-direction:column;align-items:stretch;gap:12px}.install-review-actions{display:grid;grid-template-columns:1fr 1fr}.install-review .install-review-actions button{min-width:0}.install-item-heading{align-items:flex-start}}
.install-plugin-info{margin-bottom:24px}.install-review .install-plugin-info h3{font-size:20px;color:#c7cdd5;margin:0 0 10px}.install-description{margin:0;font-size:18px;line-height:1.6;color:#d4d9e0;white-space:pre-wrap;overflow-wrap:anywhere}.install-plugin-info .plugin-links{margin-top:12px}.install-details{margin-top:14px;font-size:18px}.install-details summary{cursor:pointer;color:#b7c6d8;min-height:38px;display:list-item}.install-details p{white-space:pre-wrap;overflow-wrap:anywhere;margin:10px 0 0;line-height:1.65;color:#c7cdd5}
@@ -124,10 +124,10 @@ button{overflow-wrap:anywhere}
.quick-library-button{flex-direction:column;gap:2px;padding:4px 8px}.quick-library-button span{font-size:12px;line-height:16px;font-weight:400;white-space:nowrap}
/* Notification redesign preserves Framely's charcoal / blue palette and 8px surface, 5px control radii. */
.notification-count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 5px;margin-left:6px;border-radius:5px;background:var(--blue);color:#17212a;font-size:12px;font-weight:650}
.notification-count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 5px;margin-left:6px;border-radius:5px;background:#414141;color:#f4f4f4;font-size:12px;font-weight:650}
.notification-card{padding:18px;background:var(--surface);border:1px solid #454b55;border-radius:8px;color:var(--text)}
.notification-heading{display:flex;align-items:center;gap:12px;margin-bottom:14px}.notification-source-icon{display:flex;align-items:center;justify-content:center;width:32px;height:32px;flex:none;color:var(--blue)}.notification-origin{flex:1;min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:6px 14px}.notification-origin b{font-size:16px;font-weight:600;overflow-wrap:anywhere}.notification-origin time,.notification-countdown{color:var(--muted);font-size:14px;font-variant-numeric:tabular-nums}.notification-dismiss{flex:none;width:44px;height:44px;min-height:44px;padding:10px;border:0;background:transparent;color:#bec5d0}.notification-dismiss:hover:not(:disabled){background:#34383f;color:var(--text)}
.notification-message{min-height:0;overflow:auto}.notification-message h2{font-size:23px;line-height:1.3;margin:0 0 8px;font-weight:600;overflow-wrap:anywhere}.notification-message p{font-size:18px;line-height:1.55;color:#c6cbd4;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.notification-image{display:block;max-width:100%;max-height:180px;object-fit:contain;border-radius:5px;margin-top:14px}.notification-card .toast-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px;justify-content:flex-start}.notification-card .toast-actions button{margin:0;min-height:44px;font-size:17px;font-weight:550;padding:9px 14px;color:#e3e9f0;border:1px solid #535b66;border-radius:5px;background:#2b3037;white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis}.notification-card .toast-actions .notification-primary{background:var(--blue);border-color:var(--blue);color:#17212a}.notification-card .toast-actions .notification-primary:hover:not(:disabled){background:#b2d8f4}.notification-card button:active:not(:disabled){transform:scale(.98)}.notification-unavailable{font-size:16px;color:var(--muted);margin:12px 0 0}
.notification-heading{display:flex;align-items:center;gap:12px;margin-bottom:14px}.notification-source-icon{display:flex;align-items:center;justify-content:center;width:32px;height:32px;flex:none;color:var(--blue)}.notification-origin{flex:1;min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:6px 14px}.notification-origin b{font-size:16px;font-weight:600;overflow-wrap:anywhere}.notification-origin time,.notification-countdown{color:var(--muted);font-size:14px;font-variant-numeric:tabular-nums}.notification-dismiss{flex:none;width:44px;height:44px;min-height:44px;padding:10px;border:0;background:transparent;color:#bec5d0}.notification-dismiss:hover:not(:disabled){background:#343434;color:var(--text)}
.notification-message{min-height:0;overflow:auto}.notification-message h2{font-size:23px;line-height:1.3;margin:0 0 8px;font-weight:600;overflow-wrap:anywhere}.notification-message p{font-size:18px;line-height:1.55;color:#c6cbd4;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.notification-image{display:block;max-width:100%;max-height:180px;object-fit:contain;border-radius:5px;margin-top:14px}.notification-card .toast-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px;justify-content:flex-start}.notification-card .toast-actions button{margin:0;min-height:44px;font-size:17px;font-weight:550;padding:9px 14px;color:#e3e9f0;border:1px solid #535b66;border-radius:5px;background:#2b2b2b;white-space:nowrap;max-width:100%;overflow:hidden;text-overflow:ellipsis}.notification-card .toast-actions .notification-primary{background:var(--control-selected);border-color:#ffffff30;color:var(--text)}.notification-card .toast-actions .notification-primary:hover:not(:disabled){background:#ffffff30}.notification-card button:active:not(:disabled){transform:scale(.98)}.notification-unavailable{font-size:16px;color:var(--muted);margin:12px 0 0}
.toasts{padding:8px;height:100%;overflow:auto;background:transparent}.toasts .toast{display:flex;flex-direction:column;max-height:344px;margin:0 0 8px}.toasts .notification-heading,.toasts .toast-actions{flex:none}.toasts .notification-message h2{font-size:22px}.toasts .notification-message p{font-size:18px;line-height:1.4}.toasts .notification-card .error{font-size:15px;margin-bottom:0}
.notification-inbox{padding:12px 4px 4px}.inbox-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 8px 20px;color:var(--blue)}.inbox-heading h1{font-size:27px;color:var(--text);letter-spacing:-.4px}.inbox-heading p{display:flex;align-items:center;gap:10px;font-size:16px;color:var(--muted);margin:4px 0 0}.inbox-heading p span{font-variant-numeric:tabular-nums;color:#c6cbd4}.notification-inbox .notification-card{margin-bottom:12px}.inbox-empty{min-height:300px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:28px 16px;text-align:center;color:var(--muted)}.inbox-empty h2{color:var(--text);font-size:24px;margin:0}.inbox-empty p{font-size:18px;margin:0;max-width:28ch}
.automatic-update-settings{margin:24px 0;padding:18px 0;border-block:1px solid var(--line)}.automatic-update-settings>label{display:flex;align-items:center;gap:12px;margin:0 0 12px}.automatic-update-settings input[type=checkbox]{margin:0;flex:none}.automatic-update-settings .framely-select{max-width:350px}.automatic-update-settings .sub{margin:12px 0 0}
@@ -159,10 +159,10 @@ button{overflow-wrap:anywhere}
.notification-option>div{flex:1;min-width:0}.notification-option b{font-size:19px;font-weight:550}.notification-option p{font-size:16px;line-height:1.5;color:var(--muted);margin:5px 0 0}.notification-option>svg{flex:none;color:var(--blue)}
.notification-global-options{width:100%;border-block:1px solid var(--line);margin-bottom:30px}.notification-global-options>.notification-option+.notification-option{border-top:1px solid var(--line)}
.notification-switch{display:flex;align-items:center;justify-content:center;flex:none;width:60px;min-width:60px;min-height:48px;padding:6px 4px;background:transparent;border:0}
.notification-switch>span{position:relative;display:block;width:48px;height:28px;border-radius:16px;background:#2a2e34;border:1px solid #626770}.notification-switch>span:after{content:'';position:absolute;left:3px;top:3px;width:20px;height:20px;border-radius:50%;background:#adb4bf}.notification-switch.on>span{background:#445b69;border-color:#7697aa}.notification-switch.on>span:after{left:23px;background:#e5f0f8}.notification-switch:hover:not(:disabled){background:#252a30}.notification-switch:active:not(:disabled){transform:scale(.98)}
.notification-switch>span{position:relative;display:block;width:48px;height:28px;border-radius:16px;background:#2f2f2f;border:1px solid #626770}.notification-switch>span:after{content:'';position:absolute;left:3px;top:3px;width:20px;height:20px;border-radius:50%;background:#b5b5b5}.notification-switch.on>span{background:#505050;border-color:#7697aa}.notification-switch.on>span:after{left:23px;background:#f0f0f0}.notification-switch:hover:not(:disabled){background:#252525}.notification-switch:active:not(:disabled){transform:scale(.98)}
.notification-settings{width:100%;max-width:1050px}.notification-plugin-heading{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:12px}.notification-plugin-heading h2{font-size:23px;margin:0}.notification-plugin-heading .search-field{width:320px}.notification-settings-help{font-size:16px;color:var(--muted);line-height:1.5;margin:0 0 20px}
.notification-plugin-columns,.notification-plugin-row{display:grid;grid-template-columns:minmax(0,1fr) 80px 80px;align-items:center;gap:12px}.notification-plugin-columns{font-size:15px;color:var(--muted);padding:0 8px 10px}.notification-plugin-columns>span:not(:first-child){text-align:center}.notification-plugin-row{min-height:80px;padding:12px 8px;border-radius:5px}.notification-plugin-row:nth-child(odd){background:#1c1f23}.notification-plugin-row>.notification-switch{justify-self:center}.notification-plugin-name{display:flex;align-items:center;gap:12px;min-width:0}.notification-plugin-name>div{min-width:0}.notification-plugin-name b{display:block;font-size:18px;font-weight:550;overflow-wrap:anywhere}.notification-plugin-name small{display:block;font-size:14px;color:var(--muted);margin-top:4px;overflow-wrap:anywhere}
.inbox-permission{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:4px 12px;margin:0 0 18px}.inbox-permission .notification-option{gap:8px;padding:6px 0}.inbox-permission .notification-option b{font-size:17px}.inbox-permission .notification-option p{font-size:14px}.notification-settings-link{width:40px;min-height:44px;flex:none;padding:8px;border:0;background:transparent;color:#bec5d0}.notification-settings-link:hover{background:#34383f}
.notification-plugin-columns,.notification-plugin-row{display:grid;grid-template-columns:minmax(0,1fr) 80px 80px;align-items:center;gap:12px}.notification-plugin-columns{font-size:15px;color:var(--muted);padding:0 8px 10px}.notification-plugin-columns>span:not(:first-child){text-align:center}.notification-plugin-row{min-height:80px;padding:12px 8px;border-radius:5px}.notification-plugin-row:nth-child(odd){background:#1f1f1f}.notification-plugin-row>.notification-switch{justify-self:center}.notification-plugin-name{display:flex;align-items:center;gap:12px;min-width:0}.notification-plugin-name>div{min-width:0}.notification-plugin-name b{display:block;font-size:18px;font-weight:550;overflow-wrap:anywhere}.notification-plugin-name small{display:block;font-size:14px;color:var(--muted);margin-top:4px;overflow-wrap:anywhere}
.inbox-permission{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:4px 12px;margin:0 0 18px}.inbox-permission .notification-option{gap:8px;padding:6px 0}.inbox-permission .notification-option b{font-size:17px}.inbox-permission .notification-option p{font-size:14px}.notification-settings-link{width:40px;min-height:44px;flex:none;padding:8px;border:0;background:transparent;color:#bec5d0}.notification-settings-link:hover{background:#343434}
@media(max-width:700px){.notification-plugin-heading{flex-wrap:wrap;gap:12px}.notification-plugin-heading .search-field{width:100%}.notification-plugin-columns,.notification-plugin-row{grid-template-columns:minmax(0,1fr) 60px 60px;gap:4px}.notification-plugin-name{gap:8px}.notification-plugin-name b{font-size:16px}.notification-plugin-name .plugin-avatar{width:32px!important;height:32px!important}.notification-option{gap:8px}.notification-option p{font-size:15px}.notification-global-options{margin-bottom:24px}}
.quick-update-entry{margin-top:10px}
@@ -176,10 +176,10 @@ button{overflow-wrap:anywhere}
/* All built-in options use the same switch and full-width setting row. */
.ui-switch{display:flex;align-items:center;justify-content:center;flex:none;width:60px;min-width:60px;height:52px;min-height:52px;padding:0 4px;border:0;background:transparent}
.ui-switch>span{position:relative;display:block;width:52px;height:30px;border:1px solid #626770;background:#2a2e34;border-radius:999px}
.ui-switch>span:after{content:'';position:absolute;left:3px;top:3px;width:22px;height:22px;background:#a4aab3;border-radius:50%;transition:transform .16s,background .16s}
.ui-switch.on>span{background:#445b69;border-color:#7697aa}.ui-switch.on>span:after{left:3px;transform:translateX(22px);background:#e5f0f8}
.ui-switch:hover:not(:disabled){background:#252a30}.switch-row{width:100%;max-width:none;display:flex;align-items:center;justify-content:space-between;gap:24px;padding:14px 0;min-height:72px}
.ui-switch>span{position:relative;display:block;width:52px;height:30px;border:1px solid #626770;background:#2f2f2f;border-radius:999px}
.ui-switch>span:after{content:'';position:absolute;left:3px;top:3px;width:22px;height:22px;background:#ababab;border-radius:50%;transition:transform .16s,background .16s}
.ui-switch.on>span{background:#505050;border-color:#7697aa}.ui-switch.on>span:after{left:3px;transform:translateX(22px);background:#f0f0f0}
.ui-switch:hover:not(:disabled){background:#252525}.switch-row{width:100%;max-width:none;display:flex;align-items:center;justify-content:space-between;gap:24px;padding:14px 0;min-height:72px}
.switch-row>div{flex:1;min-width:0}.switch-row b{display:block;font-size:19px;font-weight:550;overflow-wrap:anywhere}.switch-row small{display:block;margin-top:5px;line-height:1.6}
.manager .content{min-width:0}.manager .content>.settings-card,.manager .content>section,.manager .content .store-detail{width:100%;max-width:none}
.manager .settings-card>p,.manager .settings-card>label,.manager .source-form,.manager .detail-summary{width:100%;max-width:none}
@@ -189,4 +189,4 @@ button{overflow-wrap:anywhere}
.manager .launcher-settings input[type=number]{width:100%;margin:0}.manager .source-row:has(.switch-row){flex-direction:column}.manager .source-row:has(.switch-row)>div{width:100%;max-width:none}.manager .source-row:has(.switch-row)>.row{gap:8px}
.manager .source-row .switch-row{flex-basis:100%}.manager .notification-plugin-list,.manager .notification-global-options{width:100%}
.manager .network-port{flex-direction:column;align-items:stretch}.manager .network-port>label{flex:auto;width:100%}.manager .network-port>button{align-self:flex-start}
.manager-switcher{display:flex;gap:4px;padding:4px;border:1px solid var(--line);border-radius:8px;background:#121416}.manager-switcher button{background:transparent;border:0;min-height:44px;padding:8px 16px;color:var(--muted);font-size:18px;white-space:nowrap}.manager-switcher button.active{background:#30343a;color:var(--text);border-radius:5px}@media(max-width:700px){.manager .shell-header{flex-wrap:wrap}.manager .shell-header .manager-switcher{order:3;width:100%}.manager-switcher button{flex:1}.manager .brand-wordmark{width:156px}}
.manager-switcher{display:flex;gap:4px;padding:4px;border:1px solid var(--line);border-radius:8px;background:#121416}.manager-switcher button{background:transparent;border:0;min-height:44px;padding:8px 16px;color:var(--muted);font-size:18px;white-space:nowrap}.manager-switcher button.active{background:#303030;color:var(--text);border-radius:5px}@media(max-width:700px){.manager .shell-header{flex-wrap:wrap}.manager .shell-header .manager-switcher{order:3;width:100%}.manager-switcher button{flex:1}.manager .brand-wordmark{width:156px}}