Compare commits

...
30 changed files with 289 additions and 68 deletions

No files matched your search

Generated
+1 -1
View File
@@ -347,7 +347,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.4.3-preview.3"
version = "0.4.3-preview.5"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.4.3-preview.3"
version = "0.4.3-preview.5"
edition = "2021"
license = "AGPL-3.0-only"
+4
View File
@@ -150,3 +150,7 @@ Standalone plugin windows display the plugin page without a host title bar. Plug
## Launcher actions and host compatibility
[Launcher actions and host compatibility](launcher.md)
### Host page scrolling
The host bootstrap installs vertical trigger-drag scrolling for existing plugin pages as well as newly built SDK pages. It selects the scrollable ancestors under the pointer and chains at their boundaries. Controls such as inputs, textareas, editable content and sliders are excluded. Use `data-framely-no-scroll-drag` on an element (or ancestor) whose vertical dragging is handled by the plugin; native `draggable="true"` elements are also excluded. No new manifest field or plugin rebuild is required.
+2 -2
View File
@@ -2,9 +2,9 @@
[简体中文](zh-CN/launcher.md)
Point at the Framely Dock icon: use the upper trigger for the launcher and the primary trigger for the quick panel. **Manager → Launcher → Open launcher with the primary trigger** swaps these shortcuts immediately. Other controllers keep the ordinary click entry.
Point at the Framely Dock icon: by default, use the primary trigger for the launcher and the upper trigger for the quick panel. **Manager → Launcher → Open launcher with the primary trigger** is enabled by default; turning it off swaps these shortcuts immediately. Existing saved trigger preferences are preserved. Other controllers keep the ordinary click entry.
The launcher is a native Dock dashboard panel, 3 meters wide. Steam places and curves it and switches from the current Dock panel when it opens. It closes when the Dock is hidden. UI drawing is capped at 60 FPS independently of the headset refresh rate to limit pixel readback and texture upload load. This is a drawing limit, not a guaranteed measured frame rate. The renderer retains the high-resolution texture and uploads only changed regions. The icon area always reserves space for six columns and three rows, regardless of the number of apps. Each page holds up to 13 icons. Fill the first row with four icons, the second with five, then the third with four. The second row starts half a slot to the left; the third aligns with the first. Partial rows retain those starting positions: nine icons are 4 / 5, ten are 4 / 5 / 1. Collections of up to four icons use one centered row. The Done button uses the same translucent surface, border and highlights as the category selector. The visible gap beside the category selector is wider than the gap between adjacent icon artwork. The icon area is centered independently of the category selector, with a fixed 570-pixel height. All three row positions stay fixed, including when only one or two rows are populated. Button and joystick page changes slide and fade over 300 ms. Category changes slide vertically and fade in over 360 ms, with the category selector’s active surface sliding to the selected item; reduced-motion mode skips these transitions. Switching to another Dock panel replaces the launcher normally. Framely does not change other windows’ transparency or input settings. Closing the launcher returns to Steam’s Dock panel. Steam uses local cover artwork first and falls back to logos or initials. Use the side category selector for Favorites, All, Plugins, Steam, Lepton or Desktop apps, the bottom dots to change pages, and the star on hover to favorite apps. Favorited apps keep a visible filled blue star; click it again to remove the favorite without changing the icon order. The transparent surface curves around you; only icons, names and categories are normally visible. Disabled plugins are hidden; safe mode prevents third-party plugin launch. Steam lists installed apps from the Frame's local libraries, not account-only or remote-streaming apps.
The launcher is a native Dock dashboard panel, 3.6 meters wide, with the previous 2-meter height retained. Steam places and curves it and switches from the current Dock panel when it opens. It closes when the Dock is hidden. UI drawing is capped at 60 FPS independently of the headset refresh rate to limit pixel readback and texture upload load. This is a drawing limit, not a guaranteed measured frame rate. The launcher uses a 4320 × 2400 texture (3× the logical resolution), retains mipmap filtering for edge quality and uploads only changed regions. App ordering and search results are cached during drag animations. The icon area always reserves space for six columns and three rows, regardless of the number of apps. Each page holds up to 13 icons. Fill the first row with four icons, the second with five, then the third with four. The second row starts half a slot to the left; the third aligns with the first. Partial rows retain those starting positions: nine icons are 4 / 5, ten are 4 / 5 / 1. Collections of up to four icons use one centered row. The Done button uses the same translucent surface, border and highlights as the category selector. The visible gap beside the category selector is wider than the gap between adjacent icon artwork. The icon area is centered independently of the category selector, with a fixed 570-pixel height. All three row positions stay fixed, including when only one or two rows are populated. Button and joystick page changes slide and fade over 300 ms. Category changes slide vertically and fade in over 360 ms, with the category selector’s active surface sliding to the selected item; reduced-motion mode skips these transitions. Switching to another Dock panel replaces the launcher normally. Framely does not change other windows’ transparency or input settings. Closing the launcher returns to Steam’s Dock panel. Steam uses local cover artwork first and falls back to logos or initials. The right capsule opens the plugin library or management settings. Use the left category selector for Favorites, All, Plugins, Steam, Lepton or Desktop apps, the bottom dots to change pages, and the star on hover to favorite apps. Favorited apps show a filled blue star on hover; click it again to remove the favorite without changing the icon order. The transparent surface curves around you; only icons, names and categories are normally visible. Disabled plugins are hidden; safe mode prevents third-party plugin launch. Steam lists installed apps from the Frame's local libraries, not account-only or remote-streaming apps.
Press and release may drift by up to 36 UI pixels without changing the initial hit. Icon and category hit areas are larger than the artwork. Deliberate movement cancels stabilization. Hold a plugin icon with the primary trigger for 400 ms to open its action menu; ordinary jitter keeps the hold active. It contains developer actions plus Quick panel, Manage plugin and Uninstall plugin. Press **B** or click an unused area to close the launcher. During icon arrangement, the first press or click exits arrangement and keeps the launcher open. Hold the primary trigger and swipe horizontally: the current page follows your movement while the adjacent page slides into view. Release to snap to that page, or return to the current page if the drag is too short or reaches the boundary. By default the action menu closes ten seconds after the pointer leaves; returning cancels the timer. Configure the switch and delay (1–300 seconds) in **Manager → Launcher**. Uninstall requires confirmation and keeps plugin data.
+6
View File
@@ -56,3 +56,9 @@ sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs
```
The output must be a new file. If the binary cannot run, an extracted release also includes `tools/export-diagnostics.py`, usable with `sudo python3 tools/export-diagnostics.py --output /tmp/framely-logs.zip`.
## Scrolling in VR
Point the laser at a page and move either controller's stick to scroll, without clicking or focusing the page first. Scrolling follows the element under the laser, including nested plugin panels. Keyboard input pauses page scrolling. Launcher stick navigation keeps its category/page controls.
Hold the primary trigger and move the laser up or down to drag scrollable page content. Small movements remain ordinary clicks; once a vertical drag starts, releasing does not activate the pressed button. Text inputs, sliders and explicit custom drag targets keep their own interactions. The launcher retains its icon arrangement and horizontal page-drag gestures.
+4
View File
@@ -157,3 +157,7 @@ const off = framely.ui.onVisibilityChanged(state => {
## 启动台操作与本体兼容
[启动台操作与本体兼容](launcher.md)
### 本体页面滚动
本体桥接脚本会为现有插件页面和新版 SDK 页面安装主扳机纵向拖动滚动。它选择指针下可滚动的祖先区域,并在边界继续滚动外层。输入框、文本域、可编辑内容和滑块保留自身交互。若插件需要自行处理纵向拖拽,请在对应元素或其祖先上设置 `data-framely-no-scroll-drag`;原生 `draggable="true"` 元素也会排除。无需新增清单字段或重新打包插件。
+3 -3
View File
@@ -2,9 +2,9 @@
[English](../launcher.md)
激光指向 Framely Dock 图标:上扳机打开启动台,主扳机打开快捷面板。在 **管理面板 → 启动台 → 主扳机打开启动台** 开启开关,两个入口立即互换。其他手柄保留普通点击入口。
激光指向 Framely Dock 图标:默认主扳机打开启动台,上扳机打开快捷面板。在 **管理面板 → 启动台 → 主扳机打开启动台** 开关默认开启,关闭后两个入口立即互换。已保存的扳机偏好会继续生效。其他手柄保留普通点击入口。
启动台是 Dock 内宽 3 米的原生面板,由 Steam 管理位置和曲面;打开时通过 Dock 的面板切换机制替代当前面板,Dock 隐藏时关闭。界面绘制上限固定为 60 FPS,不跟随头显刷新率升高,以限制像素读回与纹理上传负担。这是绘制上限,并不保证实测达到该帧率。渲染保留高分辨率纹理,只上传发生变化的区域。图标区域始终保留六列三行的空间,不随应用数量变化;每页最多 13 个图标,按第一排 4 个、第二排 5 个、第三排 4 个的顺序填充。第二排向左错开半格,第三排与第一排对齐;未填满的第二、三排也从相同槽位开始,不按数量重新居中分配。9 个为 4/5,10 个为 4/5/1;1–4 个使用一排居中排列。图标区域独立于分类器居中,高度固定为 570 个界面像素;只有一排或两排图标时,每排的槽位位置仍保持不变。“完成”按钮使用与分类器相同的半透明背景、边框和高光,分类器与图标之间的可见留白大于图标之间的水平留白。按钮与摇杆翻页采用 300 毫秒横向滑动与淡入动效,分类切换采用 360 毫秒纵向滑动与淡入动效,分类器的激活底块同步滑动到选中项,系统启用减少动态效果时跳过这些过渡动画。切换到其他 Dock 面板会正常替代启动台,Framely 不修改其他窗口的透明度或输入设置。关闭启动台后返回 Steam 的 Dock 面板。Steam 优先使用本地封面,缺图时回退到 Logo 或首字母。透明界面沿用户面前弯曲,显示应用图标、名称、分类器和顶部搜索框。左侧切换收藏、全部、插件、Steam、Lepton 和桌面程序,底部圆点翻页,悬停图标显示收藏按钮。停用插件不显示,安全模式阻止第三方插件启动。Steam 仅展示 Frame 本机各库已安装应用,不包含仅在账号库或串流电脑中的应用。
启动台是 Dock 内宽 3.6 米、高度保留为 2 米的原生面板,由 Steam 管理位置和曲面;打开时通过 Dock 的面板切换机制替代当前面板,Dock 隐藏时关闭。界面绘制上限固定为 60 FPS,不跟随头显刷新率升高,以限制像素读回与纹理上传负担。这是绘制上限,并不保证实测达到该帧率。启动台使用 4320×2400 的纹理(逻辑分辨率的 3 倍),保留多级纹理过滤以平滑图标边缘,只上传发生变化的区域;拖动动画期间缓存应用排序和搜索结果。图标区域始终保留六列三行的空间,不随应用数量变化;每页最多 13 个图标,按第一排 4 个、第二排 5 个、第三排 4 个的顺序填充。第二排向左错开半格,第三排与第一排对齐;未填满的第二、三排也从相同槽位开始,不按数量重新居中分配。9 个为 4/5,10 个为 4/5/1;1–4 个使用一排居中排列。图标区域独立于分类器居中,高度固定为 570 个界面像素;只有一排或两排图标时,每排的槽位位置仍保持不变。“完成”按钮使用与分类器相同的半透明背景、边框和高光,分类器与图标之间的可见留白大于图标之间的水平留白。按钮与摇杆翻页采用 300 毫秒横向滑动与淡入动效,分类切换采用 360 毫秒纵向滑动与淡入动效,分类器的激活底块同步滑动到选中项,系统启用减少动态效果时跳过这些过渡动画。切换到其他 Dock 面板会正常替代启动台,Framely 不修改其他窗口的透明度或输入设置。关闭启动台后返回 Steam 的 Dock 面板。Steam 优先使用本地封面,缺图时回退到 Logo 或首字母。透明界面沿用户面前弯曲,显示应用图标、名称、分类器和顶部搜索框。右侧胶囊按钮打开管理面板的插件库或设置页。左侧切换收藏、全部、插件、Steam、Lepton 和桌面程序,底部圆点翻页,悬停图标显示收藏按钮,已收藏时显示蓝色实心星标,再次点击可取消收藏。停用插件不显示,安全模式阻止第三方插件启动。Steam 仅展示 Frame 本机各库已安装应用,不包含仅在账号库或串流电脑中的应用。
按下后的激光落点允许在 36 个界面像素内抖动,点击区域大于图标本身;明显拖动会取消落点稳定处理。主扳机按住插件图标 400 毫秒展开操作菜单,普通抖动不会取消长按,包含开发者按钮和“快捷面板、管理插件、卸载插件”。按 **B** 或点击空白区域关闭启动台;处于图标整理状态时,第一次操作先退出整理,启动台继续显示。按住主扳机横向拖动时,当前页随手移动,相邻页同步滑入;松手后吸附到相邻页,拖动距离不足或到达边界则回到当前页。操作菜单默认在指针离开 10 秒后关闭,重新移入取消计时。在 **管理面板 → 启动台** 调整自动关闭开关与时长(1–300 秒)。卸载需要确认,并保留插件数据。
@@ -20,7 +20,7 @@ Steam 启动成功表示请求已交给 Steam,并不代表游戏加载完成
管理面板新增独立的 **启动台** 页面,集中设置扳机分配、菜单自动关闭及“全部包含的分类”。默认包含插件(含 Framely)、Steam、Lepton、桌面程序。关闭某个分类只影响“全部”,其单独分类和收藏仍可访问;全部关闭时,“全部”显示空状态。
所有类型的应用均可用悬停显示的星标收藏,已有插件与 Steam 收藏保持不变。停用插件不显示,暂时不可用的应用仍保留收藏记录。每次打开会清空搜索并重置分页,本地应用枚举完成后,有可显示的收藏则默认进入收藏,否则进入全部。在收藏页取消最后一个收藏时保持当前分类,显示空状态;下次打开才回退至全部。
所有类型的应用均可用悬停显示的星标收藏,已有插件与 Steam 收藏保持不变。停用插件不显示,暂时不可用的应用仍保留收藏记录。每次打开会清空搜索并重置分页,但保持上次所选分类;当前 Framely 运行期间的分类选择会一直保留。仅本次运行首次打开时,本地应用枚举完成后,有可显示的收藏则进入收藏,否则进入全部。在收藏页取消最后一个收藏时保持收藏分类,重新打开也显示空状态,不自动切换分类。
## 搜索
+6
View File
@@ -60,3 +60,9 @@ sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs
```
输出路径必须是新文件。本体程序无法运行时,解压发行包中的 `tools/export-diagnostics.py` 也可独立使用:`sudo python3 tools/export-diagnostics.py --output /tmp/framely-logs.zip`。
## VR 页面滚动
激光指向页面后,拨动任意手柄的摇杆即可滚动,无需先点击或聚焦页面。滚动作用于激光下的元素,也支持插件页面的内层滚动区域。键盘打开时暂停页面摇杆滚动;启动台仍使用原有的摇杆分类与翻页操作。
按住主扳机并上下移动激光,可拖动可滚动的页面内容。小幅抖动仍视为普通点击;纵向拖动开始后,松开不会触发原先按下的按钮。文本输入、滑块及明确声明的自定义拖拽区域保留自身交互。启动台保留图标排列和横向拖动翻页操作。
+30 -11
View File
@@ -10,6 +10,7 @@
#include "menu_input.h"
#include "launcher_input.h"
#include "launcher_pointer.h"
#include "page_scroll.h"
#include <filesystem>
#include "overlay_placement.h"
#include "render_resolution.h"
@@ -53,10 +54,12 @@ static LauncherDismissGuard launcher_dismiss;
static LauncherDockGuard launcher_dock;
static EntryTrigger launcher_close_button;
static LauncherStick launcher_stick;
static PageScrollStick page_scroll_stick;
static vr::VROverlayHandle_t page_scroll_target=0;
static std::chrono::steady_clock::time_point launcher_outside_at{};
static bool launcher_selected=false;
static std::chrono::steady_clock::time_point launcher_keyboard_back_until{};
static std::atomic<bool> launcher_primary{false};
static std::atomic<bool> launcher_primary{true};
static bool entry_actions_ready=false;
static vr::VRActionSetHandle_t entry_set=0;
static vr::VRActionHandle_t primary_action=0,upper_action=0,aim_action=0,close_action=0,navigate_action=0;
@@ -85,6 +88,7 @@ template<class T> struct Handler {
struct View {
std::string key,plugin,title;int width=600,height=840,render_scale=1;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false;
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_at{};
cef_mouse_event_t pointer{};bool pointer_valid=false,stick_scrolling=false;vr::TrackedDeviceIndex_t pointer_device=vr::k_unTrackedDeviceIndexInvalid;std::chrono::steady_clock::time_point pointer_at{};
bool active_seen=false,notification_content=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
vr::VROverlayHandle_t overlay=0,thumbnail=0;GLuint textures[2]{};int texture_slot=0;bool texture_ready[2]{};PaintDamage texture_damage[2];std::vector<uint8_t> pixels;
};
@@ -206,7 +210,9 @@ static void anchor_notification(View& v){
}
static View& create_view(const std::string& key,const std::string& title,const std::string& url,bool dock,int width,int height,const std::string& plugin="",float physical_width=0){
if(views.count(key)){auto& v=*views.at(key);if(dock&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="notifications"&&key!="launcher")show(v,true);return v;}
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dock;v->width=width;v->height=height;v->render_scale=(key=="framely.manager"||key=="launcher")?4:(key=="menu"||key=="notifications")?2:1;
// Use 3x for launcher artwork and 2x for scrolling panels. CEF readback,
// texture upload and launcher mipmap generation scale with the pixel count.
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dock;v->width=width;v->height=height;v->render_scale=key=="launcher"?3:(key=="framely.manager"||key=="menu"||key=="notifications")?2:1;
auto error=dock?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(v->overlay,0);
@@ -220,7 +226,7 @@ static View& create_view(const std::string& key,const std::string& title,const s
if(result)std::cerr<<"Dashboard control unavailable "<<key<<" "<<result<<"\n";
}
}
if(dock){auto pixels=icon(false,true);overlays->SetOverlayRaw(v->thumbnail,pixels.data(),128,128,4);overlays->SetOverlayWidthInMeters(v->overlay,physical_width>0?physical_width:(key=="framely.manager"?3.2f:key=="launcher"?3.f:1.2f));}else if(key!="menu"&&key!="notifications"&&key!="launcher"){anchor_to_head(*v,physical_width>0?physical_width:1.0f);}
if(dock){auto pixels=icon(false,true);overlays->SetOverlayRaw(v->thumbnail,pixels.data(),128,128,4);overlays->SetOverlayWidthInMeters(v->overlay,physical_width>0?physical_width:(key=="framely.manager"?3.2f:key=="launcher"?3.6f:1.2f));}else if(key!="menu"&&key!="notifications"&&key!="launcher"){anchor_to_head(*v,physical_width>0?physical_width:1.0f);}
cef_window_info_t info{};info.size=sizeof(info);info.windowless_rendering_enabled=1;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=browser_frame_rate();bs.background_color=0;std::cout<<"Browser frame rate "<<key<<" "<<bs.windowless_frame_rate<<"\n";cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&info,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);
if(!browser){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);throw std::runtime_error("Browser creation failed");}v->browser=browser;v->browser_id=browser->get_identifier(browser);v->host=browser->get_host(browser);glGenTextures(2,v->textures);auto* ptr=v.get();views.emplace(key,std::move(v));ptr->host->notify_screen_info_changed(ptr->host);ptr->host->was_resized(ptr->host);ptr->host->was_hidden(ptr->host,key!="notifications");if(dock&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications"&&key!="launcher")show(*ptr,true);return *ptr;
}
@@ -273,7 +279,7 @@ static void toggle_entry(bool launcher){
if(launcher){
menu_open=false;if(views.count("menu"))show(*views.at("menu"),false);
if(launcher_open){hide_launcher();return;}
auto& view=create_view("launcher","Framely launcher",origin+"/launcher",true,1200,800);
auto& view=create_view("launcher","Framely launcher",origin+"/launcher",true,1440,800);
vr::VREvent_t stale{};while(overlays->PollNextOverlayEvent(view.overlay,&stale,sizeof(stale))){}
launcher_dock.clear();launcher_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={};
overlays->ShowDashboard("launcher");
@@ -291,11 +297,23 @@ static void setup_entry_actions(){
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;
}
static void page_scroll(float x,float y,bool active,vr::TrackedDeviceIndex_t device,std::chrono::steady_clock::time_point now){
View* target=nullptr;
if(!keyboard_open&&!launcher_open)for(auto&[key,v]:views){
if(key=="launcher"||!v->pointer_valid||!capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay))||!overlays->IsHoverTargetOverlay(v->overlay))continue;
if(!target||(v->pointer_device==device&&target->pointer_device!=device)||(v->pointer_device==target->pointer_device&&v->pointer_at>target->pointer_at))target=v.get();
}
if(!target){page_scroll_stick.clear();page_scroll_target=0;return;}
if(page_scroll_target!=target->overlay){page_scroll_stick.clear();page_scroll_target=target->overlay;}
target->stick_scrolling=active&&std::isfinite(x)&&std::isfinite(y)&&std::max(std::abs(x),std::abs(y))>.18f;
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 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;
auto now=std::chrono::steady_clock::now();
float stick_x=0,stick_y=0;bool stick_active=false;
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);
bool pose_ok=available&&input->GetPoseActionDataRelativeToNow(aim_action,vr::TrackingUniverseStanding,0,&pose,sizeof(pose),hands[hand])==vr::VRInputError_None&&pose.bActive&&pose.pose.bPoseIsValid;
@@ -336,23 +354,23 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
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);
}
if(!launcher_open)continue;
if(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(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);}
if(launcher_close_button.update(active,down,true,now))launcher_back();
}
vr::InputAnalogActionData_t axis{};bool active=input->GetAnalogActionData(navigate_action,&axis,sizeof(axis),hands[hand])==vr::VRInputError_None&&axis.bActive;float x=axis.x,y=axis.y;
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 rest{},full_x{},full_y{},current{};vr::VRControllerState_t neutral{},sx{},sy{};sx.rAxis[0].x=1;sy.rAxis[0].y=1;auto* models=vr::VRRenderModels();active=models->GetComponentStateForDevicePath(model,"thumbstick",hands[hand],&mode,&current)&&models->GetComponentState(model,"thumbstick",&neutral,&mode,&rest)&&models->GetComponentState(model,"thumbstick",&sx,&mode,&full_x)&&models->GetComponentState(model,"thumbstick",&sy,&mode,&full_y)&&launcher_stick_axes(rest,full_x,full_y,current,x,y);}
// 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;}
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;}
}
if(launcher_open){int direction=launcher_stick.update(keyboard_open?0:stick_x,keyboard_open?0:stick_y,!keyboard_open&&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(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=="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=="updates"||page=="notification-settings"||page=="launcher-settings"||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(page=="catalog"||page=="updates"||page=="notification-settings"||page=="launcher-settings"){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=="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.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"){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 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_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);switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!launcher_dock_interaction())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:mouse.x=v.width/2;mouse.y=v.height/2;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_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())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];}
@@ -380,7 +398,7 @@ int main(int argc,char** argv){
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",false));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;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));}});
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;
@@ -420,6 +438,7 @@ int main(int argc,char** argv){
}
}
if(!anchor_ok)entry_pointer.leave();
for(auto&[key,v]:views)v->stick_scrolling=false;
entry_actions(button,anchor_ok);
if(launcher_open){bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held;launcher_dismiss.observe(held);}
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){if(!active&&key=="launcher"){if(v->launcher_pointer.active){cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;v->host->send_mouse_move_event(v->host,&outside,0);outside.modifiers=0;v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);}v->launcher_pointer.clear();auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->base.release(&frame->base);}v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;if(key!="launcher")for(auto&[other,t]:views)if(!t->dashboard&&other!="menu"&&other!="notifications"&&other!="launcher"&&!keyboard_open)close_view(*t);}}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <algorithm>
#include <chrono>
#include <cmath>
struct PageScrollDelta {int x=0,y=0;};
// Integrate speed in logical UI pixels, independently of render scale/FPS.
struct PageScrollStick {
std::chrono::steady_clock::time_point last{};float remainder=0;int axis=0;
PageScrollDelta update(float x,float y,bool active,std::chrono::steady_clock::time_point now){
float dt=last.time_since_epoch().count()?std::clamp(std::chrono::duration<float>(now-last).count(),0.f,.05f):1.f/60;
last=now;PageScrollDelta out;
if(!active||!std::isfinite(x)||!std::isfinite(y)||std::max(std::abs(x),std::abs(y))<=.18f){remainder=0;axis=0;return out;}
int next=std::abs(y)>=std::abs(x)?2:1;if(next!=axis){remainder=0;axis=next;}
float value=axis==2?y:-x;
remainder+=std::copysign((std::min(std::abs(value),1.f)-.18f)/.82f,value)*1100.f*dt;
int pixels=int(remainder);remainder-=pixels;if(axis==2)out.y=pixels;else out.x=pixels;return out;
}
void clear(){last={};remainder=0;axis=0;}
};
+37
View File
@@ -0,0 +1,37 @@
// Installed by the host bootstrap as well as the manager; old plugins benefit
// without rebuilding their own SDK. The launcher owns its separate gestures.
export function installScrollGestures(){
if((document as any).__framelyScrollGestures)return;
(document as any).__framelyScrollGestures=true;
type Drag={id:number;x:number;y:number;last:number;nodes:HTMLElement[];active:boolean;styles:string[]};
let drag:Drag|null=null,suppressClick=false,timer:ReturnType<typeof setTimeout>|null=null;
const clear=()=>{if(drag?.active){drag.nodes.forEach((n,i)=>n.style.scrollBehavior=drag!.styles[i]);try{drag.nodes[0].releasePointerCapture(drag.id);}catch{}}drag=null;};
const cancel=()=>{clear();suppressClick=false;if(timer)clearTimeout(timer);timer=null;};
const scroll=(dy:number)=>{if(!drag)return;for(const node of drag.nodes){const before=node.scrollTop;node.scrollTop+=dy;dy-=node.scrollTop-before;if(Math.abs(dy)<.5)break;}};
document.addEventListener('pointerdown',e=>{
cancel();if(e.button!==0||document.documentElement.classList.contains('launcher-view'))return;
const target=e.target instanceof Element?e.target:null;
if(!target||target.closest('input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role=slider],[draggable=true],[data-framely-no-scroll-drag]'))return;
const nodes:HTMLElement[]=[];
for(let n:Element|null=target;n;n=n.parentElement){if(n instanceof HTMLElement&&n.scrollHeight>n.clientHeight+1&&/auto|scroll/.test(getComputedStyle(n).overflowY))nodes.push(n);}
const root=document.scrollingElement;if(root instanceof HTMLElement&&root.scrollHeight>root.clientHeight+1&&!nodes.includes(root))nodes.push(root);
if(nodes.length)drag={id:e.pointerId,x:e.clientX,y:e.clientY,last:e.clientY,nodes,active:false,styles:nodes.map(n=>n.style.scrollBehavior)};
},true);
document.addEventListener('pointermove',e=>{
if(!drag||e.pointerId!==drag.id)return;
if(!e.buttons){clear();return;}
const dy=e.clientY-drag.y,dx=e.clientX-drag.x;
if(!drag.active){if(Math.abs(dy)<20||Math.abs(dy)<Math.abs(dx)*1.25)return;drag.active=true;suppressClick=true;drag.nodes.forEach(n=>n.style.scrollBehavior='auto');try{drag.nodes[0].setPointerCapture(e.pointerId);}catch{}window.getSelection()?.removeAllRanges();}
e.preventDefault();e.stopImmediatePropagation();scroll(drag.last-e.clientY);drag.last=e.clientY;
},{capture:true,passive:false});
document.addEventListener('pointerup',e=>{
if(!drag||e.pointerId!==drag.id)return;
if(drag.active){e.preventDefault();e.stopImmediatePropagation();scroll(drag.last-e.clientY);timer=setTimeout(()=>{suppressClick=false;timer=null;},200);}
clear();
},true);
document.addEventListener('click',e=>{if(suppressClick){e.preventDefault();e.stopImmediatePropagation();suppressClick=false;if(timer)clearTimeout(timer);timer=null;}},true);
document.addEventListener('pointercancel',cancel,true);
document.addEventListener('lostpointercapture',()=>{if(drag?.active)clear();},true);
window.addEventListener('blur',cancel);window.addEventListener('pagehide',cancel);
document.addEventListener('visibilitychange',()=>{if(document.hidden)cancel();});
}
+2
View File
@@ -1,5 +1,7 @@
import {installScrollGestures} from './scroll-gestures';
// Shared by the manager and host bootstrap, including already-installed plugins.
export function installScrollbars(){
installScrollGestures();
if(document.getElementById('framely-scrollbars'))return;
const style=document.createElement('style');style.id='framely-scrollbars';
style.textContent=`
+1 -1
View File
@@ -4,8 +4,8 @@ mod diagnostics;
mod http;
mod ipc;
mod jobs;
mod localization;
mod launcher_search;
mod localization;
mod model;
mod package;
mod planner;
+2 -2
View File
@@ -179,7 +179,7 @@ pub struct LauncherAction {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LauncherSettings {
#[serde(default)]
#[serde(default = "yes")]
pub primary_trigger: bool,
#[serde(default = "yes")]
pub menu_auto_close: bool,
@@ -200,7 +200,7 @@ fn menu_timeout() -> u32 {
impl Default for LauncherSettings {
fn default() -> Self {
Self {
primary_trigger: false,
primary_trigger: true,
menu_auto_close: true,
menu_timeout_seconds: 10,
all_categories: launcher_categories(),
+13 -2
View File
@@ -798,7 +798,13 @@ impl Agent {
if method == "host.manager.open"
&& matches!(
p["page"].as_str(),
Some("catalog" | "updates" | "notification-settings" | "launcher-settings")
Some(
"catalog"
| "settings"
| "updates"
| "notification-settings"
| "launcher-settings"
)
)
{
command["page"] = p["page"].clone();
@@ -1736,6 +1742,7 @@ mod tests {
json!({"page":"updates"}),
json!({"page":"notification-settings"}),
json!({"page":"launcher-settings"}),
json!({"page":"settings"}),
json!({}),
json!({"page":"https://example.org"}),
] {
@@ -1754,8 +1761,12 @@ mod tests {
commands[3],
json!({"kind":"manager.open","page":"launcher-settings"})
);
assert_eq!(commands[4], json!({"kind":"manager.open"}));
assert_eq!(
commands[4],
json!({"kind":"manager.open","page":"settings"})
);
assert_eq!(commands[5], json!({"kind":"manager.open"}));
assert_eq!(commands[6], json!({"kind":"manager.open"}));
}
#[test]
fn declining_agreement_only_closes_current_view() {
+13 -1
View File
@@ -817,7 +817,7 @@ fn apply_prepared(core: &mut Service, prepared: crate::planner::Prepared) -> any
fn batch_install_in_safe_mode_preserves_packages_without_starting_plugins() {
let tmp = tempfile::tempdir().unwrap();
let _tools = fake_backend_tools(tmp.path());
let mut core = accepted_service(&tmp.path().join("state"), 1000).unwrap();
let mut core = accepted_service(&tmp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
core.handle("safeMode", json!({"enabled":true})).unwrap();
for version in ["1.0.0", "1.1.0"] {
let bytes = relation_package(
@@ -2059,3 +2059,15 @@ fn launcher_categories_migrate_validate_and_favorites_survive_reload() {
.unwrap();
assert_eq!(loaded.db.launcher.all_categories.len(), 4);
}
#[test]
fn launcher_trigger_defaults_preserve_explicit_preferences() {
assert!(LauncherSettings::default().primary_trigger);
let missing: LauncherSettings = serde_json::from_value(json!({})).unwrap();
assert!(missing.primary_trigger);
let saved: LauncherSettings = serde_json::from_value(json!({"primaryTrigger":false})).unwrap();
assert!(!saved.primary_trigger);
let restored: LauncherSettings =
serde_json::from_value(serde_json::to_value(saved).unwrap()).unwrap();
assert!(!restored.primary_trigger);
}
+39
View File
@@ -419,6 +419,14 @@ impl Updater {
request["version"].as_str() == Some(&release.version),
"待安装版本已变,请重新确认"
);
let current = fs::read_to_string(root.join("current/VERSION"))
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into());
ensure!(
descriptor_version(&release.version)?
.cmp_precedence(&descriptor_version(current.trim())?)
!= std::cmp::Ordering::Equal,
"当前已是此版本,无需重复更新。"
);
launch_helper(root, manager, Some(&stage))?;
*self.prepared.lock().unwrap() = None;
write_status(
@@ -786,6 +794,37 @@ mod tests {
}
}
#[test]
fn same_version_with_different_build_cannot_launch_update() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("current")).unwrap();
fs::write(
dir.path().join("current/VERSION"),
"0.4.3-preview.2-3640daa86d54\n",
)
.unwrap();
let updater = Updater::default();
let (_, bytes) = fixture("0.4.3-preview.2", false);
let mut release = verify_descriptor(&bytes).unwrap();
release.version = "0.4.3-preview.2-16320a685fb1".into();
*updater.prepared.lock().unwrap() = Some((
dir.path().into(),
release.clone(),
(source(), UpdateChannel::Testing),
));
let error = updater
.apply(
dir.path(),
1000,
&source(),
UpdateChannel::Testing,
json!({"approve":true,"version":release.version}),
)
.unwrap_err();
assert!(error.to_string().contains("无需重复更新"));
assert!(updater.prepared.lock().unwrap().is_some());
assert!(!dir.path().join("update-status.json").exists());
}
#[test]
fn changing_channel_invalidates_a_downloaded_release() {
let dir = tempfile::tempdir().unwrap();
let updater = Updater::default();
+2 -2
View File
@@ -36,8 +36,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,1200,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,1200,800,4);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?4800:std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(launcher_mode?3200: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;}};
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_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){int ink=0;for(int py=std::max(0,y*4);py<std::min(pixel_height,(y+h)*4);py++)for(int px=std::max(0,x*4);px<std::min(pixel_width,(x+w)*4);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){int ink=0;for(int py=std::max(0,y*4);py<std::min(pixel_height,(y+h)*4);py++)for(int px=std::max(0,x*4);px<std::min(pixel_width,(x+w)*4);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";}}}
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;}};
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();};
cef_window_info_t win{};win.size=sizeof(win);win.windowless_rendering_enabled=1;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=60;cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&win,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);if(!browser)return 4;auto* host=browser->get_host(browser);
+4 -4
View File
@@ -16,7 +16,7 @@ window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected f
else if(method==='launcher.settings.save'){config=params;}
else if(method==='plugin.launcher.action')result={frontend:'actions.js',context:{source:'launcher',trigger:'menuAction',actionId:params.action}};
return new Response(JSON.stringify({result}));};
function Fixture(){const[,render]=useState(0);redraw=()=>render(n=>n+1);return settings?<div className="app manager"><LauncherSettings config={config} refresh={async()=>redraw()}/></div>:<Launcher plugins={plugins} safeMode={false} config={config} favorites={[]} order={order} refresh={async()=>redraw()}/>;}
function Fixture(){const[,render]=useState(0);redraw=()=>render(n=>n+1);return settings?<div className="app manager"><LauncherSettings config={config} refresh={async()=>redraw()}/></div>:<Launcher plugins={{...plugins}} safeMode={false} config={config} favorites={[]} order={order} refresh={async()=>redraw()}/>;}
function button(text:string){return [...document.querySelectorAll<HTMLButtonElement>('button')].find(b=>b.textContent===text)!;}
function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Termix]')!;b.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));}
(window as any).runInstallReviewChecks=async()=>{try{
@@ -24,9 +24,9 @@ function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-i
if(document.body.textContent?.includes('Disabled app'))throw Error('Disabled plugin displayed');
if(document.querySelector('.launcher-header')||!document.querySelector('.launcher-search input'))throw Error('Launcher contains panel controls');
if(getComputedStyle(document.querySelector('.launcher-shell')!).backgroundColor!=='rgba(0, 0, 0, 0)')throw Error('Launcher is not transparent');
const grid=document.querySelector('.launcher-grid')!,filters=document.querySelector('.launcher-filters')!;const gridBefore=grid.getBoundingClientRect(),filterBefore=filters.getBoundingClientRect();if(gridBefore.width<1110)throw Error('Icon spacing was not widened');if(document.querySelector('.launcher-filters button')!.getBoundingClientRect().width<56)throw Error('Category hit target is too small');
const grid=document.querySelector('.launcher-grid')!,filters=document.querySelector('.launcher-filters')!;const gridBefore=grid.getBoundingClientRect(),filterBefore=filters.getBoundingClientRect();if(gridBefore.width<1300)throw Error('Icon canvas was narrowed too far');if(document.querySelector('.launcher-filters button')!.getBoundingClientRect().width<56)throw Error('Category hit target is too small');
const shellRect=document.querySelector('.launcher-shell')!.getBoundingClientRect();if(Math.abs(gridBefore.x+gridBefore.width/2-shellRect.x-shellRect.width/2)>1)throw Error('Icon area is not independently centered');const categoryRight=filterBefore.right;
const discs=[...document.querySelectorAll<HTMLElement>('.launch-disc')].map(n=>n.getBoundingClientRect());const middle=discs.filter(r=>Math.abs(r.y-discs[4].y)<1);const betweenIcons=middle[1].left-middle[0].right,gapToCategories=middle[0].left-categoryRight;if(betweenIcons<90||gapToCategories<=betweenIcons)throw Error('Category gap must exceed horizontal artwork gap');
const discs=[...document.querySelectorAll<HTMLElement>('.launch-disc')].map(n=>n.getBoundingClientRect());const middle=discs.filter(r=>Math.abs(r.y-discs[4].y)<1);const betweenIcons=middle[1].left-middle[0].right,gapToCategories=middle[0].left-categoryRight;const gapToShortcuts=document.querySelector('.launcher-shortcuts')!.getBoundingClientRect().left-middle[middle.length-1].right;if(betweenIcons<106||gapToCategories<132||gapToShortcuts<132||gapToCategories<=betweenIcons||gapToShortcuts<=betweenIcons)throw Error('Both capsule gaps must exceed the wider horizontal artwork gap '+JSON.stringify({betweenIcons,gapToCategories,gapToShortcuts}));
const firstSlotY=document.querySelector<HTMLElement>('.launch-item')!.offsetTop;
const sameLayout=()=>{const g=document.querySelector('.launcher-grid')!.getBoundingClientRect(),f=filters.getBoundingClientRect();if(g.width!==gridBefore.width||g.height!==gridBefore.height||g.x!==gridBefore.x||g.y!==gridBefore.y||f.x!==filterBefore.x||f.y!==filterBefore.y)throw Error('App count moved the fixed grid or categories '+JSON.stringify({before:gridBefore.toJSON(),after:g.toJSON(),filterBefore:filterBefore.toJSON(),filterAfter:f.toJSON()}));};
const assertRows=(expected:number[])=>{const groups=new Map<string,DOMRect[]>();for(const item of document.querySelectorAll<HTMLElement>('.launch-item')){const r=item.querySelector('.launch-icon')!.getBoundingClientRect();if(item.style.gridRow.startsWith('1 /')&&Math.abs(item.offsetTop-firstSlotY)>1)throw Error('Partial rows moved the first icon row');const key=item.style.gridRow;groups.set(key,[...(groups.get(key)??[]),r]);}const rows=[...groups.values()];if(rows.map(row=>row.length).join(',')!==expected.join(','))throw Error('Unexpected row counts '+rows.map(row=>row.length));const center=(r:DOMRect)=>r.x+r.width/2;if(rows.length===1){const mean=rows[0].reduce((sum,r)=>sum+center(r),0)/rows[0].length;if(Math.abs(mean-gridBefore.x-gridBefore.width/2)>1)throw Error('Single row is not centered');}else{const step=center(rows[0][1])-center(rows[0][0]);if(Math.abs(center(rows[1][0])-center(rows[0][0])+step/2)>1)throw Error('Second row did not start half a slot left');if(rows[2]&&Math.abs(center(rows[2][0])-center(rows[0][0]))>1)throw Error('Third row is not aligned with the first');}};
@@ -34,7 +34,7 @@ function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-i
plugins['demo.ten']={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:'demo.ten',name:'Tenth app'}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===11);sameLayout();assertRows([4,5,2]);await wait(300);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_TEN_EN');delete plugins['demo.ten'];redraw();await until(()=>document.querySelectorAll('.launch-icon').length===10);
for(let n=0;n<3;n++)plugins[`demo.extra${n}`]={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:`demo.extra${n}`,name:`Extra ${n}`}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===13);sameLayout();assertRows([4,5,4]);for(let n=3;n<7;n++)plugins[`demo.extra${n}`]={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:`demo.extra${n}`,name:`Extra ${n}`}};redraw();await until(()=>document.querySelectorAll('.launcher-pages button').length===2);sameLayout();if(document.querySelectorAll('.launch-icon').length!==13)throw Error('AVP page capacity changed');document.querySelectorAll<HTMLButtonElement>('.launcher-pages button')[1].click();await until(()=>document.querySelectorAll('.launch-icon').length===4);sameLayout();document.querySelectorAll<HTMLButtonElement>('.launcher-pages button')[0].click();await until(()=>document.querySelectorAll('.launch-icon').length===13);if(!document.querySelector('.launcher-page')!.getAnimations().length)throw Error('Page change has no animation');await wait(340);for(let n=0;n<7;n++)delete plugins[`demo.extra${n}`];redraw();await until(()=>document.querySelectorAll('.launch-icon').length===10);
if(Math.abs(document.querySelector('.launch-disc')!.getBoundingClientRect().width-96)>.1)throw Error('Launcher artwork is too large');
document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=Plugin]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===3);sameLayout();if(!document.querySelector('.launcher-page')!.getAnimations().length)throw Error('Category change has no animation');const categoryMotion=document.querySelector('.launcher-page')!.getAnimations()[0].effect as KeyframeEffect;if(!String(categoryMotion.getKeyframes()[0].translate).includes('110px'))throw Error('Category motion is too subtle');const activeSurface=document.querySelector<HTMLElement>('.launcher-category-active')!;await wait(60);const surfaceY=activeSurface.getBoundingClientRect().top,selectedY=document.querySelector('.launcher-filters button.active')!.getBoundingClientRect().top;if(surfaceY<=filterBefore.top+7||surfaceY>=selectedY)throw Error('Category active surface did not slide through intermediate positions');await wait(360);if(Math.abs(activeSurface.getBoundingClientRect().top-selectedY)>1)throw Error('Category surface did not settle under active item');
document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=Plugin]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===3);sameLayout();if(!document.querySelector('.launcher-page')!.getAnimations().length)throw Error('Category change has no animation');const categoryMotion=document.querySelector('.launcher-page')!.getAnimations()[0].effect as KeyframeEffect;if(!String(categoryMotion.getKeyframes()[0].translate).includes('110px'))throw Error('Category motion is too subtle');const activeSurface=document.querySelector<HTMLElement>('.launcher-category-active')!;await wait(60);const surfaceY=activeSurface.getBoundingClientRect().top,selectedY=document.querySelector('.launcher-filters button.active')!.getBoundingClientRect().top;if(surfaceY<=filterBefore.top+7||surfaceY>=selectedY)throw Error('Category active surface did not slide through intermediate positions');await until(()=>Math.abs(activeSurface.getBoundingClientRect().top-selectedY)<=1);
const arrangements=[[1],[2],[3],[4],[4,1],[4,2],[4,3],[4,4],[4,5],[4,5,1],[4,5,2],[4,5,3],[4,5,4]];
plugins['demo.alpha'].enabled=false;plugins['demo.beta'].enabled=false;
redraw();await until(()=>document.querySelectorAll('.launch-icon').length===1);sameLayout();assertRows([1]);
+9 -7
View File
@@ -15,7 +15,7 @@ window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected f
else if(method==='launcher.favorite'){const{key,favorite}=params;if(key.startsWith('plugin:'))plugins[key.slice(7)].favorite=favorite;else if(key.startsWith('steam:'))steamFavorites=favorite?[...steamFavorites,+key.slice(6)]:steamFavorites.filter(id=>id!==+key.slice(6));else saved=favorite?[...saved,key]:saved.filter(k=>k!==key);}
else if(method==='launcher.settings.save')config=params;
return new Response(JSON.stringify({result}));};
function Fixture(){const[,render]=useState(0);redraw=()=>render(n=>n+1);return settings?<div className="app manager"><main className="content"><div className="list-heading"><div><h1>{document.documentElement.lang==='zh-CN'?'启动台':'Launcher'}</h1><p>{document.documentElement.lang==='zh-CN'?'启动方式、菜单与分类。':'Launch controls, menus and categories.'}</p></div></div><LauncherSettings config={config} refresh={async()=>redraw()}/></main></div>:<Launcher plugins={plugins} safeMode={false} config={config} favorites={steamFavorites} localFavorites={saved} order={order} refresh={async()=>redraw()}/>;}
function Fixture(){const[,render]=useState(0);redraw=()=>render(n=>n+1);return settings?<div className="app manager"><main className="content"><div className="list-heading"><div><h1>{document.documentElement.lang==='zh-CN'?'启动台':'Launcher'}</h1><p>{document.documentElement.lang==='zh-CN'?'启动方式、菜单与分类。':'Launch controls, menus and categories.'}</p></div></div><LauncherSettings config={config} refresh={async()=>redraw()}/></main></div>:<Launcher plugins={{...plugins}} safeMode={false} config={config} favorites={steamFavorites} localFavorites={saved} order={order} refresh={async()=>redraw()}/>;}
const category=(name:string)=>document.querySelector<HTMLButtonElement>(`.launcher-filters button[aria-label="${name}"]`)!;
const labels=()=>[...document.querySelectorAll('.launch-name')].map(n=>n.textContent);
const input=()=>document.querySelector<HTMLInputElement>('.launcher-search input')!;
@@ -25,7 +25,7 @@ const favorite=(name:string)=>document.querySelector<HTMLButtonElement>(`.launch
configureLanguage('en-US',[]);redraw();await until(()=>!document.querySelector('.launcher-initializing'));
if(category('Favorites').getAttribute('aria-pressed')!=='true'||labels().sort().join('|')!=='Half-Life: Alyx|Termix')throw Error('Default favorites / disabled filtering incorrect '+labels().join('|'));
const keys=[...document.querySelectorAll('.launcher-filters button')].map(n=>n.getAttribute('aria-label'));if(keys.join('|')!=='Favorites|All|Plugin|Steam|Lepton|Desktop apps')throw Error('Category order '+keys);
category('All').click();await until(()=>labels().length===7);const initialGrid=document.querySelector('.launcher-grid')!.getBoundingClientRect();const initialFilters=document.querySelector('.launcher-filters')!.getBoundingClientRect();
category('All').click();await until(()=>labels().length===7);for(const [label,page] of [['Plugin library','catalog'],['Settings','settings']]){document.querySelector<HTMLButtonElement>(`.launcher-shortcuts button[aria-label="${label}"]`)!.click();await until(()=>calls.some(c=>c.method==='host.manager.open'&&c.params.page===page));await until(()=>!document.querySelector('.launcher-shortcuts button:disabled'));}const initialGrid=document.querySelector('.launcher-grid')!.getBoundingClientRect();const initialFilters=document.querySelector('.launcher-filters')!.getBoundingClientRect();
const stable=()=>{const g=document.querySelector('.launcher-grid')!.getBoundingClientRect(),f=document.querySelector('.launcher-filters')!.getBoundingClientRect();if(g.x!==initialGrid.x||g.y!==initialGrid.y||g.height!==570||f.y!==initialFilters.y)throw Error('Search / category changed fixed layout');};
await wait(400);const searchBottom=document.querySelector('.launcher-search')!.getBoundingClientRect().bottom;const artworkTop=document.querySelector('.launch-disc')!.getBoundingClientRect().top;if(artworkTop-searchBottom<72)throw Error('Search is too close to the icon artwork');
// Every app kind keeps its position when favorited or unfavorited.
@@ -35,13 +35,13 @@ const favorite=(name:string)=>document.querySelector<HTMLButtonElement>(`.launch
for(const checked of [!was,was]){
favorite(name).click();await until(()=>favorite(name).getAttribute('aria-pressed')===String(checked));await until(()=>!favorite(name).disabled);
if(labels().join('|')!==initialOrder)throw Error('Favorite reordered '+name);
if(checked){await until(()=>getComputedStyle(favorite(name)).opacity==='1');if(!favorite(name).classList.contains('selected')||getComputedStyle(favorite(name)).opacity!=='1'||favorite(name).querySelector('svg')!.getAttribute('fill')!=='currentColor')throw Error('Favorite state is not visible '+name);}
if(checked){if(!favorite(name).classList.contains('selected')||favorite(name).querySelector('svg')!.getAttribute('fill')!=='currentColor')throw Error('Favorite state is not selected '+name);await wait(200);if(getComputedStyle(favorite(name)).opacity!=='0')throw Error('Favorite should stay hidden without hover '+name);}
}
}
// Explicit manual ordering survives favorite changes, refresh and reopening.
order=[...document.querySelectorAll<HTMLElement>('[data-launch-key]')].map(n=>n.dataset.launchKey!).reverse();redraw();await until(()=>labels().join('|')!==initialOrder);const manualOrder=labels().join('|');
favorite('Framely').click();await until(()=>saved.includes('framely'));await until(()=>!favorite('Framely').disabled);if(labels().join('|')!==manualOrder)throw Error('Manual order changed after favorite');
window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>category('Favorites').getAttribute('aria-pressed')==='true');category('All').click();await until(()=>labels().length===7);if(labels().join('|')!==manualOrder)throw Error('Reopening lost manual order');
window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category('All').getAttribute('aria-pressed')!=='true')throw Error('Reopening changed manual-order category');await until(()=>labels().length===7);if(labels().join('|')!==manualOrder)throw Error('Reopening lost manual order');
favorite('Framely').click();await until(()=>!saved.includes('framely'));await until(()=>!favorite('Framely').disabled);if(labels().join('|')!==manualOrder)throw Error('Manual order changed after removing favorite');order=[];redraw();await until(()=>labels().join('|')===initialOrder);
favorite('Framely').click();await until(()=>saved.includes('framely'));await until(()=>!document.querySelector('.launch-favorite:disabled'));favorite('Desktop terminal').click();await until(()=>saved.includes('desktop:terminal.desktop'));await until(()=>!document.querySelector('.launch-favorite:disabled'));favorite('さくら').click();await until(()=>saved.includes('lepton:default/sakura'));await until(()=>!document.querySelector('.launch-favorite:disabled'));
category('Favorites').click();await until(()=>labels().length===5);if(!labels().includes('Framely')||!labels().includes('Desktop terminal')||!labels().includes('さくら'))throw Error('New types absent from Favorites');
@@ -58,11 +58,13 @@ const favorite=(name:string)=>document.querySelector<HTMLButtonElement>(`.launch
category('Plugin').click();query('tsts');await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SEARCH_EN');configureLanguage('zh-CN',[]);redraw();await wait(450);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SEARCH_ZH');configureLanguage('en-US',[]);redraw();query('');
config={...config,allCategories:[]};redraw();category('All').click();await until(()=>labels().length===0);category('Favorites').click();await until(()=>labels().length===5);category('Plugin').click();await until(()=>labels().length===3);
config={...config,allCategories:['desktop']};redraw();category('All').click();await until(()=>labels().join('|')==='Desktop terminal');query('termix');await until(()=>labels().length===0);query('');stable();
window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>category('Favorites').getAttribute('aria-pressed')==='true');if(input().value!=='')throw Error('Opening did not clear query');
window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category('All').getAttribute('aria-pressed')!=='true')throw Error('Opening did not retain All');if(input().value!=='')throw Error('Opening did not clear query');
// Late refresh must not override an explicit category selection.
delay=350;window.dispatchEvent(new Event('framely.launcher.open'));await wait(30);category('Steam').click();await wait(450);if(category('Steam').getAttribute('aria-pressed')!=='true')throw Error('Refresh overwrote category selection');delay=80;
saved=[];steamFavorites=[];plugins['demo.alpha'].favorite=false;redraw();window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>category('All').getAttribute('aria-pressed')==='true');await until(()=>!document.querySelector('.launcher-initializing'));if(labels().join('|')!=='Desktop terminal')throw Error('No favorites fallback ignored All membership');
favorite('Desktop terminal').click();await until(()=>saved.length===1);window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>category('Favorites').getAttribute('aria-pressed')==='true');favorite('Desktop terminal').click();await until(()=>labels().length===0);if(category('Favorites').getAttribute('aria-pressed')!=='true'||!document.body.innerText.includes('No favorites yet'))throw Error('Removing final favorite navigated away');window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>category('All').getAttribute('aria-pressed')==='true');
saved=[];steamFavorites=[];plugins['demo.alpha'].favorite=false;redraw();window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category('Steam').getAttribute('aria-pressed')!=='true')throw Error('Favorites changed selected category');category('All').click();await until(()=>labels().join('|')==='Desktop terminal');
favorite('Desktop terminal').click();await until(()=>saved.length===1);window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category('All').getAttribute('aria-pressed')!=='true')throw Error('Adding favorite changed selected category');category('Favorites').click();await until(()=>labels().length===1);favorite('Desktop terminal').click();await until(()=>labels().length===0);window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category('Favorites').getAttribute('aria-pressed')!=='true'||!document.body.innerText.includes('No favorites yet'))throw Error('Empty favorites changed selected category');
for(const label of ['All','Plugin','Steam','Lepton','Desktop apps','Favorites']){category(label).click();window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category(label).getAttribute('aria-pressed')!=='true')throw Error('Did not retain '+label);}
window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-2}));await until(()=>category('All').getAttribute('aria-pressed')==='true');window.dispatchEvent(new Event('framely.launcher.open'));await wait(250);if(category('All').getAttribute('aria-pressed')!=='true')throw Error('Joystick category was not retained');
if(!matchesSearch('Termix','termix')||!matchesSearch('École','ecole')||matchesSearch('Alpha','beta')||matchesSearch('ガ','カ'))throw Error('Search normalization');
settings=true;redraw();await until(()=>document.querySelectorAll('[role=switch]').length===6);const switches=document.querySelectorAll<HTMLInputElement>('[role=switch]');switches[0].click();await until(()=>config.primaryTrigger);await until(()=>!document.querySelector('[role=switch]:disabled'));switches[2].click();await until(()=>(config.allCategories??[]).includes('plugin'));await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');configureLanguage('zh-CN',[]);redraw();await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e+' '+(e as Error).stack);}};
+11
View File
@@ -0,0 +1,11 @@
#include "page_scroll.h"
#include <cassert>
int main(){
auto t=std::chrono::steady_clock::now();PageScrollStick s;
assert(!s.update(0,.1f,true,t).y);assert(!s.update(0,1,false,t).y);
int sixty=0;for(int i=1;i<=60;i++)sixty+=s.update(0,1,true,t+std::chrono::microseconds(i*1000000/60)).y;
s.clear();int ninety=0;for(int i=1;i<=90;i++)ninety+=s.update(0,1,true,t+std::chrono::microseconds(i*1000000/90)).y;
assert(std::abs(sixty-ninety)<10);auto d=s.update(.8f,.9f,true,t+std::chrono::seconds(2));assert(d.x==0&&d.y>0&&d.y<=55);
d=s.update(1,.2f,true,t+std::chrono::milliseconds(2017));assert(d.x<0&&d.y==0);
assert(!s.update(NAN,1,true,t).y);assert(!s.update(0,0,true,t).y);s.clear();assert(s.last.time_since_epoch().count()==0);
}
+29
View File
@@ -0,0 +1,29 @@
import '../ui/src/style.css';
import {installScrollbars} from '../sdk/src/scrollbars';
installScrollbars();installScrollbars();
const wait=(ms=60)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<100;i++){if(f())return;await wait(30);}throw Error('Timeout');};
const root=document.getElementById('root')!;root.className='app';root.innerHTML='<div id="outer" style="position:absolute;left:100px;top:70px;width:700px;height:600px;overflow:auto"><div style="height:1800px;background:#1c2a36;color:white"><button id="click" style="margin:50px">Click</button><div id="inner" style="margin:30px;height:240px;overflow:auto;background:#314758"><div style="height:900px"><button id="nested" style="margin:50px">Nested</button></div></div><input id="input" value="Text"><div id="opt" data-framely-no-scroll-drag>Custom gesture</div></div></div>';
const outer=document.getElementById('outer')!,inner=document.getElementById('inner')!,button=document.getElementById('click')!;let clicks=0;button.addEventListener('click',()=>clicks++);
const event=(target:Element,type:string,x:number,y:number,buttons=1)=>target.dispatchEvent(new PointerEvent(type,{bubbles:true,cancelable:true,pointerId:1,button:0,buttons,clientX:x,clientY:y}));
const click=(target:Element)=>target.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));
(window as any).runInstallReviewChecks=async()=>{try{
event(button,'pointerdown',120,160);event(button,'pointermove',123,152);event(button,'pointerup',123,152,0);click(button);if(clicks!==1||outer.scrollTop!==0)throw Error('Jitter lost a normal click');
event(button,'pointerdown',120,160);event(button,'pointermove',120,100);event(button,'pointerup',120,80,0);click(button);if(clicks!==1||outer.scrollTop!==80)throw Error('Drag clicked or did not follow hand '+outer.scrollTop);outer.scrollTop=0;
const nested=document.getElementById('nested')!;event(nested,'pointerdown',160,340);event(nested,'pointermove',160,240);event(nested,'pointerup',160,240,0);if(inner.scrollTop!==100||outer.scrollTop!==0)throw Error('Inner drag scrolled wrong container');await wait(230);
inner.scrollTop=inner.scrollHeight-inner.clientHeight;event(nested,'pointerdown',160,340);event(nested,'pointermove',160,280);event(nested,'pointerup',160,280,0);if(outer.scrollTop!==60)throw Error('Nested boundary did not chain');outer.scrollTop=0;inner.scrollTop=0;
for(const id of ['input','opt']){const target=document.getElementById(id)!;event(target,'pointerdown',150,350);event(target,'pointermove',150,250);event(target,'pointerup',150,250,0);if(outer.scrollTop!==0)throw Error('Hijacked '+id);}
event(button,'pointerdown',120,160);event(button,'pointermove',200,170);event(button,'pointerup',200,170,0);if(outer.scrollTop!==0)throw Error('Horizontal gesture became vertical scroll');
event(button,'pointerdown',120,160);event(button,'pointermove',120,100);event(button,'pointercancel',120,100,0);const cancelled=outer.scrollTop;event(button,'pointermove',120,30);if(outer.scrollTop!==cancelled)throw Error('Cancelled drag remained active');outer.scrollTop=0;
// Real CEF mouse/laser input, including a scrollable element away from centre.
await wait(200);const r=nested.getBoundingClientRect();console.log(`FRAMELY_PAGE_WHEEL_${Math.round(r.x+15)}_${Math.round(r.y+15)}_0_-120`);await until(()=>inner.scrollTop>0);if(outer.scrollTop!==0)throw Error('Native wheel missed inner container');inner.scrollTop=0;
const b=button.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(b.x+20)}_${Math.round(b.y+15)}_0_-90_400`);await until(()=>outer.scrollTop>=80);await wait(500);if(clicks!==1)throw Error('Real drag activated button');outer.scrollTop=0;
// An already-installed plugin gets gestures from host bootstrap, independently
// of this document's listeners and without rebuilding its plugin bundle.
const frame=document.createElement('iframe');frame.style.cssText='position:absolute;left:850px;top:200px;width:300px;height:240px;flex:none';frame.srcdoc='<style>body{margin:0}#scroller{height:180px;overflow:auto}#inside{height:800px}</style><div id="scroller"><div id="inside"><button id="plugin-button" style="margin:40px">Plugin</button></div></div><script src="/bootstrap.js"></script>';root.append(frame);
await until(()=>!!frame.contentDocument?.getElementById('framely-scrollbars'));const doc=frame.contentDocument!,scroller=doc.getElementById('scroller')!,pb=doc.getElementById('plugin-button')!;let pluginClicks=0;pb.addEventListener('click',()=>pluginClicks++);
event(pb,'pointerdown',60,70);event(pb,'pointermove',60,20);event(pb,'pointerup',60,20,0);click(pb);if(scroller.scrollTop!==50||pluginClicks)throw Error('Old plugin bootstrap did not support drag');scroller.scrollTop=0;await wait(350);
const f=frame.getBoundingClientRect(),pr=pb.getBoundingClientRect();console.log(`FRAMELY_PAGE_WHEEL_${Math.round(f.x+pr.x+15)}_${Math.round(f.y+pr.y+15)}_0_-120`);await until(()=>scroller.scrollTop>0);if(outer.scrollTop!==0)throw Error('Iframe wheel scrolled the parent');frame.remove();
document.documentElement.classList.add('launcher-view');event(button,'pointerdown',120,160);event(button,'pointermove',120,100);event(button,'pointerup',120,100,0);if(outer.scrollTop!==0)throw Error('Launcher gesture intercepted');
console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e+' '+(e as Error).stack);}};
console.log('FRAMELY_VIEW_READY');
+6 -1
View File
@@ -2,7 +2,7 @@
// Run: bash tools/test-install-review.sh /path/to/cef tests/system_updates_fixture.tsx
import React,{useState} from 'react';
import {createRoot} from 'react-dom/client';
import {SystemUpdates} from '../ui/src/updates';
import {SystemUpdates,sameSystemVersion} from '../ui/src/updates';
import {configureLanguage} from '../ui/src/i18n';
import '../ui/src/style.css';
const root=createRoot(document.getElementById('root')!);
@@ -120,6 +120,11 @@ async function choose(label:string){(document.querySelector('.framely-select but
if(rollbacks!==0)throw Error('rollback bypassed confirmation');
button('Confirm rollback').click();await until(()=>rollbacks===1);
if(downloads!==4)throw Error('rollback downloaded a release');
if(!sameSystemVersion('0.4.3-preview.2-16320a685fb1','0.4.3-preview.2-3640daa86d54')||sameSystemVersion('0.4.3-preview.3-16320a685fb1','0.4.3-preview.2-3640daa86d54'))throw Error('Build hash changed version comparison');
status={...status,version:'0.4.3-preview.2',build:'0.4.3-preview.2-3640daa86d54'};
updateStatus({...status});remount();await wait();
button('Check for updates').click();await until(()=>!!document.body.textContent?.includes('No update is needed'));
if(document.querySelector('.update-release-dialog')||document.querySelector('.update-confirmation')||downloads!==4||attempts!==4)throw Error('Same version with different hash offered installation');
console.log('FRAMELY_BRIDGE_PASS');
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}
};
+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_pointer paint_buffer render_timing; do
for test in menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input 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
+3 -1
View File
@@ -480,6 +480,8 @@
"还没有收藏的应用": "No favorites yet",
"收藏分类": "Favorites",
"读音搜索暂不可用,仍可使用原文搜索。": "Phonetic search is unavailable. You can still search original names.",
"启动方式与菜单": "Launch controls and menus"
"启动方式与菜单": "Launch controls and menus",
"当前已是此版本,无需重复更新。": "This version is already installed. No update is needed.",
"快捷操作": "Quick actions"
}
}
+3 -1
View File
@@ -480,6 +480,8 @@
"还没有收藏的应用": "还没有收藏的应用",
"收藏分类": "收藏",
"读音搜索暂不可用,仍可使用原文搜索。": "读音搜索暂不可用,仍可使用原文搜索。",
"启动方式与菜单": "启动方式与菜单"
"启动方式与菜单": "启动方式与菜单",
"当前已是此版本,无需重复更新。": "当前已是此版本,无需重复更新。",
"快捷操作": "快捷操作"
}
}
+12 -9
View File
@@ -3,16 +3,16 @@
.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-filters 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)}
: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-filters button.active{background:transparent;box-shadow:none;color:var(--launch-blue)}
.launcher-filters button:hover:not(:disabled){background:#e3f1ff26;color:#f3f8fc}
: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}
.launcher-filters button:hover .launcher-category-label,.launcher-filters button:focus-visible .launcher-category-label{opacity:1}
.launcher-grid{position:absolute;left:50%;top:calc(50% + 17px);height:570px;width:calc(100% - 90px);transform:translate(-50%,-50%);isolation:isolate}
: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{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{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 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)}
@@ -22,9 +22,9 @@
.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:18px;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-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{opacity:1;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-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-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.align-right{left:auto;right:0;transform:none}.launch-menu.above{top:auto;bottom:100%}
@@ -44,7 +44,7 @@
@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%}.launcher-drag-edge.right{right:88px}}
@media(prefers-reduced-transparency:reduce){.launch-disc{background:#273644}.launcher-tools{background:#273644}.launch-menu{background:#20303d}}
@media(prefers-reduced-motion:reduce){.launch-disc,.launch-favorite,.launcher-category-label,.launcher-filters button,.launcher-category-active,.launcher-pages button span{transition:none}.launch-menu,.launch-item{animation:none}}
@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 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}
@@ -73,3 +73,6 @@
.launcher-initializing .launcher-grid,.launcher-initializing .launcher-category-active{visibility:hidden}
.launcher-settings>label{max-width:none}
.launcher-shortcuts{left:auto;right:0}
.launcher-shortcuts .launcher-category-label{left:auto;right:calc(100% + 20px)}
+15 -12
View File
@@ -1,15 +1,15 @@
import {SwitchRow} from './switch';
import React,{useEffect,useLayoutEffect,useRef,useState} from 'react';
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} from '@tabler/icons-react';
import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck,IconSearch,IconX,IconApps,IconSettings} from '@tabler/icons-react';
import {installedIcon} from './store';
import {PluginFrame} from './plugin-frame';
import type {Plugin} from './main';
import './launcher.css';
import {matchesSearch,type SearchIndex} from './launcher-search';
export type LauncherConfig={primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
const defaults:LauncherConfig={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
const defaults:LauncherConfig={primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
type SteamApp={id:number;name:string;icon?:string;iconFit?:string;kind?:string};
function LauncherArtwork({src,name,kind,fit}:{src?:string|null;name:string;kind:string;fit?:string}){
const [failed,setFailed]=useState(false);
@@ -30,27 +30,29 @@ type LaunchItem={key:string;kind:string;id:string;name:string;icon?:string|null;
const emptyOrder:string[]=[];
const categories=[{key:'favorites',label:'收藏分类',icon:IconStar},{key:'all',label:'全部',icon:IconLayoutGrid},{key:'plugin',label:'插件',icon:IconPuzzle},{key:'steam',label:'Steam',icon:IconBrandSteam},{key:'lepton',label:'Lepton',icon:IconBrandAndroid},{key:'desktop',label:'桌面程序',icon:IconDeviceDesktop}];
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[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),[busy,setBusy]=useState(false),[uninstall,setUninstall]=useState<(Plugin&{dependents:string[];fingerprint:string})|null>(null),[frontend,setFrontend]=useState<{plugin:string;action:string;context:any}|null>(null),[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}|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),busyRef=useRef(false),frontTimer=useRef<ReturnType<typeof setTimeout>|null>(null),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]);
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;}
function cancelDrag(){if(drag.current)setManual(drag.current.before);drag.current=null;setDragging(null);setGhost(null);stopEdge();}
useEffect(()=>{mounted.current=true;let live=true,sequence=0;const load=()=>{cancelHold();cancelDrag();cancelPan();setEditing(false);long.current=false;const current=++sequence;opening.current=true;setQuery('');setPage(0);setLoading(true);setMenu(null);setOutside(false);void Promise.allSettled([api<SteamApp[]>('steam.list'),api<LocalApp[]>('desktop.list')]).then(results=>{if(!live||current!==sequence)return;const [steamResult,localResult]=results;if(steamResult.status==='fulfilled')setSteam(steamResult.value);else setError(String(steamResult.reason));if(localResult.status==='fulfilled')setLocal(localResult.value);else setError(String(localResult.reason));if(opening.current){const known=props.current;const hasFavorite=Object.values(known.plugins).some(p=>p.enabled&&p.favorite)||(steamResult.status==='fulfilled'&&steamResult.value.some(a=>known.favorites.includes(a.id)))||known.localFavorites.includes('framely')||(localResult.status==='fulfilled'&&localResult.value.some(a=>known.localFavorites.includes(`${a.kind}:${a.id}`)));setFilter(hasFavorite?'favorites':'all');opening.current=false;}setLoading(false);});};load();const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;setMenu(null);setEditing(false);};window.addEventListener('framely.launcher.cancelInput',cancel);window.addEventListener('framely.launcher.open',load);return()=>{live=false;mounted.current=false;window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(frontTimer.current)clearTimeout(frontTimer.current);if(panTimer.current)clearTimeout(panTimer.current);};},[]);
useEffect(()=>{mounted.current=true;let live=true,sequence=0;const load=()=>{cancelHold();cancelDrag();cancelPan();setEditing(false);long.current=false;const current=++sequence;opening.current=remembered.current===null;setQuery('');setPage(0);setLoading(true);setMenu(null);setOutside(false);void Promise.allSettled([api<SteamApp[]>('steam.list'),api<LocalApp[]>('desktop.list')]).then(results=>{if(!live||current!==sequence)return;const [steamResult,localResult]=results;if(steamResult.status==='fulfilled')setSteam(steamResult.value);else setError(String(steamResult.reason));if(localResult.status==='fulfilled')setLocal(localResult.value);else setError(String(localResult.reason));if(opening.current){const known=props.current;const hasFavorite=Object.values(known.plugins).some(p=>p.enabled&&p.favorite)||(steamResult.status==='fulfilled'&&steamResult.value.some(a=>known.favorites.includes(a.id)))||known.localFavorites.includes('framely')||(localResult.status==='fulfilled'&&localResult.value.some(a=>known.localFavorites.includes(`${a.kind}:${a.id}`)));selectCategory(hasFavorite?'favorites':'all');}setLoading(false);});};load();const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;setMenu(null);setEditing(false);};window.addEventListener('framely.launcher.cancelInput',cancel);window.addEventListener('framely.launcher.open',load);return()=>{live=false;mounted.current=false;window.removeEventListener('framely.launcher.open',load);window.removeEventListener('framely.launcher.cancelInput',cancel);cancelHold();if(edgeTimer.current)clearTimeout(edgeTimer.current);if(frontTimer.current)clearTimeout(frontTimer.current);if(panTimer.current)clearTimeout(panTimer.current);};},[]);
useEffect(()=>{if(!drag.current)cancelHold();setMenu(null);setOutside(false);},[filter,page]);
useEffect(()=>{if(!menu||!outside||!config.menuAutoClose||busy)return;const timer=setTimeout(()=>setMenu(null),config.menuTimeoutSeconds*1000);return()=>clearTimeout(timer);},[menu,outside,busy,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 api('host.launcher.close');}
function settleSwipe(gesture:{dx?:number;active?:boolean}){if(!gesture.active)return;const s=state.current,width=grid.current?.clientWidth??1110,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);}
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||drag.current||busyRef.current||state.current.panning)return;const direction=(e as CustomEvent<number>).detail;const s=state.current;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);setFilter(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);};},[]);
const all: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]));all.sort((a,b)=>(rank.get(a.key)??Infinity)-(rank.get(b.key)??Infinity)||a.name.localeCompare(b.name)||a.key.localeCompare(b.key));
const indexKey=JSON.stringify(all.map(a=>a.name).sort());
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||drag.current||busyRef.current||state.current.panning)return;const direction=(e as CustomEvent<number>).detail;const s=state.current;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);};},[]);
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]);
useEffect(()=>{let live=true;const names=Array.from(new Set(all.map(a=>a.name)));if(names.length)void api<Record<string,SearchIndex>>('launcher.search.index',{names}).then(v=>{if(live)setIndexes(v);}).catch(()=>{if(live)setError(t('读音搜索暂不可用,仍可使用原文搜索。'));});return()=>{live=false;};},[indexKey]);
useEffect(()=>{const changed=(e:Event)=>{keyboard.current=!!(e as CustomEvent<boolean>).detail;if(keyboard.current){cancelPan();cancelHold();setMenu(null);}};window.addEventListener('framely.keyboard',changed);return()=>window.removeEventListener('framely.keyboard',changed);},[]);
const items=all.filter(a=>(filter==='favorites'?a.favorite:filter==='all'?(config.allCategories??defaults.allCategories!).includes(a.kind==='framely'?'plugin':a.kind):a.kind===filter||(filter==='plugin'&&a.kind==='framely'))&&matchesSearch(a.name,query,indexes[a.name]));
const items=useMemo(()=>all.filter(a=>(filter==='favorites'?a.favorite:filter==='all'?(config.allCategories??defaults.allCategories!).includes(a.kind==='framely'?'plugin':a.kind):a.kind===filter||(filter==='plugin'&&a.kind==='framely'))&&matchesSearch(a.name,query,indexes[a.name])),[all,filter,config.allCategories,query,indexes]);
const pageSize=13,columns=6,pages=Math.max(1,Math.ceil(items.length/pageSize)),current=Math.min(page,pages-1),selected=menu?all.find(a=>a.key===menu):null,selectedPlugin=selected?.kind==='plugin'?plugins[selected.id]:null,visible=items.slice(current*pageSize,current*pageSize+pageSize);
const counts=[Math.min(visible.length,4),Math.min(Math.max(visible.length-4,0),5),Math.max(visible.length-9,0)].filter(count=>count>0),rows=Math.max(1,counts.length);
const cells=counts.flatMap((count,row)=>Array.from({length:count},(_,col)=>({row,column:(columns-(rows===1?count:row===1?5:4))*2+1+col*4})));
@@ -67,7 +69,7 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
function reorder(target:string){const d=drag.current;if(!d||target===d.key||target===d.over)return;const from=d.order.indexOf(d.key),to=d.order.indexOf(target);if(from<0||to<0)return;const next=[...d.order];next.splice(from,1);next.splice(to,0,d.key);d.order=next;d.over=target;d.moved=true;setManual(next);}
function turnEdge(direction:number){const d=drag.current;if(!d||d.edge===direction)return;stopEdge();if(!direction)return;d.edge=direction;setEdge(direction);const tick=()=>{const s=state.current,next=s.current+direction;if(!drag.current||next<0||next>=s.pages){stopEdge();return;}const target=s.items[next*pageSize+(direction>0?0:Math.min(pageSize-1,s.items.length-next*pageSize-1))];if(target)reorder(target.key);setPage(next);edgeTimer.current=setTimeout(tick,900);};edgeTimer.current=setTimeout(tick,650);}
useEffect(()=>{const move=(e:PointerEvent)=>{const d=drag.current;if(d){setGhost(g=>g?{...g,x:e.clientX,y:e.clientY}:g);const moved=Math.hypot(e.clientX-point.current.x,e.clientY-point.current.y)>36;if(!d.moved&&!moved)return;d.moved=true;const box=grid.current?.getBoundingClientRect(),categories=shell.current?.querySelector('.launcher-filters')?.getBoundingClientRect();const left=box?Math.max(box.left,(categories?.right??box.left)+24):0;const inHeight=!!box&&e.clientY>=box.top&&e.clientY<=box.bottom;const direction=box&&inHeight?(e.clientX>=left&&e.clientX<left+50?-1:e.clientX<=box.right&&e.clientX>box.right-50?1:0):0;turnEdge(direction);if(direction)return;const target=document.elementFromPoint(e.clientX,e.clientY)?.closest<HTMLElement>('[data-launch-key]');if(target&&Math.hypot(e.clientX-d.lastX,e.clientY-d.lastY)>12){reorder(target.dataset.launchKey!);d.lastX=e.clientX;d.lastY=e.clientY;}else if(!target)d.over=null;
}else if(swipe.current){const gesture=swipe.current,dx=e.clientX-gesture.x,dy=e.clientY-gesture.y;if(gesture.active||(Math.abs(dx)>36&&Math.abs(dx)>Math.abs(dy)*1.25)){gesture.active=true;gesture.dx=dx;cancelHold();setMenu(null);long.current=true;pageAnimation.current?.cancel();const width=grid.current?.clientWidth??1110,direction=dx<0?1:-1,next=state.current.current+direction,valid=next>=0&&next<state.current.pages;setPanView({offset:valid?Math.max(-width,Math.min(width,dx)):dx*.22,direction,width,settling:false});}else if(holdTarget.current&&Math.hypot(dx,dy)>36){cancelHold();long.current=true;}}};
}else if(swipe.current){const gesture=swipe.current,dx=e.clientX-gesture.x,dy=e.clientY-gesture.y;if(gesture.active||(Math.abs(dx)>36&&Math.abs(dx)>Math.abs(dy)*1.25)){gesture.active=true;gesture.dx=dx;cancelHold();setMenu(null);long.current=true;pageAnimation.current?.cancel();const width=grid.current?.clientWidth??1300,direction=dx<0?1:-1,next=state.current.current+direction,valid=next>=0&&next<state.current.pages;setPanView({offset:valid?Math.max(-width,Math.min(width,dx)):dx*.22,direction,width,settling:false});}else if(holdTarget.current&&Math.hypot(dx,dy)>36){cancelHold();long.current=true;}}};
const up=(e:PointerEvent)=>{if(swipe.current){const gesture=swipe.current;swipe.current=null;settleSwipe(gesture);}cancelHold();stopEdge();const d=drag.current;drag.current=null;setDragging(null);setGhost(null);if(d){long.current=true;if(!d.moved&&d.startedInEdit)setEditing(false);if(d.moved)void run(async()=>{try{await api('launcher.order.save',{order:d.order});}catch(e){setManual(d.before);throw e;}});}};
const cancel=()=>{cancelPan();cancelHold();cancelDrag();long.current=true;};window.addEventListener('pointermove',move);window.addEventListener('pointerup',up);window.addEventListener('pointercancel',cancel);return()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',up);window.removeEventListener('pointercancel',cancel);};},[]);
function launch(a:LaunchItem){return a.kind==='framely'?api('host.manager.open'):a.kind==='plugin'?api('plugin.launch',{plugin:a.id,source:'launcher'}):a.key.startsWith('steam:')?api('steam.launch',{app:+a.id}):api('desktop.launch',{app:a.key});}
@@ -76,7 +78,8 @@ export function Launcher({plugins,safeMode,config=defaults,favorites,localFavori
function done(error?:string){if(frontTimer.current)clearTimeout(frontTimer.current);const p=pending.current;pending.current=null;setFrontend(null);if(error)p?.reject(new Error(error));else p?.resolve();}
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&&!editing&&!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'))close();}}>
<div className="launcher-search"><IconSearch size={22} stroke={1.65}/><input type="search" aria-label={t('搜索应用')} placeholder={t('搜索应用')} value={query} disabled={editing||busy} 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={busy||!!dragging||!!panView} className={filter===key?'active':''} onClick={()=>{if(editing){close();return;}opening.current=false;setFilter(key);setPage(0);}}><CategoryIcon size={26} stroke={1.65}/><span className="launcher-category-label">{t(label)}</span></button>)}</nav>
<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={busy||!!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:'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={busy||editing||!!dragging||!!panView} onClick={()=>{cancelHold();setMenu(null);void run(()=>api('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={busy||!!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);}}>
+2 -2
View File
@@ -26,7 +26,7 @@ const managerMode=location.pathname==='/manager';
const empty:Status={version:'0.4.0',database:{plugins:{},sources:[],safeMode:false},notifications:[],running:[]};
function App(){
const[catalogTarget,setCatalogTarget]=useState<{plugin:string;source?:string}|null>(null);const runtimeLabels:Record<string,string>={starting:t("初始化中"),running:t("运行中"),stopping:t("停止中"),stopped:t("已停止"),recovering:t("等待恢复"),failed:t("失败"),crashed:t("异常退出"),ready:t("已就绪"),installed:t("已安装"),'waiting-dependency':t("等待依赖")};const [status,setStatus]=useState<Status>(empty),[error,setError]=useState(''),[tab,setTab]=useState(managerMode?(location.hash==='#catalog'?'catalog':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':location.hash==='#launcher-settings'?'launcher-settings':'installed'):'favorites'),[page,setPage]=useState<string|null>(!managerMode&&location.hash.startsWith('#plugin:')?location.hash.slice(8):null),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[manage,setManage]=useState<Plugin|null>(null),[install,setInstall]=useState<any>(null),[confirmRunAs,setConfirmRunAs]=useState(false),[logs,setLogs]=useState<string|null>(null),[importing,setImporting]=useState(false),[transfer,setTransfer]=useState<Job|null>(null),[downloadRequest,setDownloadRequest]=useState<any>(null),[relationAction,setRelationAction]=useState<any>(null),[reviewAgreement,setReviewAgreement]=useState(false),[agreementDeclined,setAgreementDeclined]=useState(false),[languageFiles,setLanguageFiles]=useState<string[]>([]),[logExport,setLogExport]=useState(''),[exportingLogs,setExportingLogs]=useState(false);
const[catalogTarget,setCatalogTarget]=useState<{plugin:string;source?:string}|null>(null);const runtimeLabels:Record<string,string>={starting:t("初始化中"),running:t("运行中"),stopping:t("停止中"),stopped:t("已停止"),recovering:t("等待恢复"),failed:t("失败"),crashed:t("异常退出"),ready:t("已就绪"),installed:t("已安装"),'waiting-dependency':t("等待依赖")};const [status,setStatus]=useState<Status>(empty),[error,setError]=useState(''),[tab,setTab]=useState(managerMode?(location.hash==='#catalog'?'catalog':location.hash==='#settings'?'settings':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':location.hash==='#launcher-settings'?'launcher-settings':'installed'):'favorites'),[page,setPage]=useState<string|null>(!managerMode&&location.hash.startsWith('#plugin:')?location.hash.slice(8):null),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[manage,setManage]=useState<Plugin|null>(null),[install,setInstall]=useState<any>(null),[confirmRunAs,setConfirmRunAs]=useState(false),[logs,setLogs]=useState<string|null>(null),[importing,setImporting]=useState(false),[transfer,setTransfer]=useState<Job|null>(null),[downloadRequest,setDownloadRequest]=useState<any>(null),[relationAction,setRelationAction]=useState<any>(null),[reviewAgreement,setReviewAgreement]=useState(false),[agreementDeclined,setAgreementDeclined]=useState(false),[languageFiles,setLanguageFiles]=useState<string[]>([]),[logExport,setLogExport]=useState(''),[exportingLogs,setExportingLogs]=useState(false);
useEffect(()=>{const ticket=install?.ticket;return()=>{if(ticket)void api('job.cancel',{job:ticket}).catch(()=>{});};},[install?.ticket]);
const localePacks=useRef<LanguagePack[]|null>(null);const localePreference=useRef<string|null>(null);
const readError=useReadInbox(!managerMode&&tab==='notifications'&&!page&&!!status.agreement?.accepted,status.inbox??[],refresh);
@@ -61,7 +61,7 @@ function App(){
else{const plan=await api('plugin.disable.preview',{plugin:id});if(plan.dependents.length){setRelationAction({title:kind==='uninstall'?t("确认卸载及依赖停用"):t("确认停用计划"),enable:[],disable:plan.dependents,method:kind==='uninstall'?'plugin.uninstall':'plugin.enable',params:{plugin:id,enabled:false,purge:false,...extra,approveDependents:true,fingerprint:plan.fingerprint}});return;}await api(kind==='uninstall'?'plugin.uninstall':'plugin.enable',{plugin:id,enabled:false,purge:false,...extra});}
setManage(null);
}
useEffect(()=>{if(!managerMode)return;const navigate=()=>{if(['#catalog','#updates','#notification-settings','#launcher-settings'].includes(location.hash)){setPage(null);setCatalogTarget(null);setTab(location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':location.hash==='#launcher-settings'?'launcher-settings':'catalog');}};window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[]);
useEffect(()=>{if(!managerMode)return;const navigate=()=>{if(['#catalog','#settings','#updates','#notification-settings','#launcher-settings'].includes(location.hash)){setPage(null);setCatalogTarget(null);setTab(location.hash==='#settings'?'settings':location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-settings':location.hash==='#launcher-settings'?'launcher-settings':'catalog');}};window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[]);
useEffect(()=>{const navigate=()=>{if(location.hash.startsWith('#plugin:')){const id=decodeURIComponent(location.hash.slice(8));if(managerMode){setPage(null);setTab('installed');const plugin=status.database.plugins[id];if(plugin){setManage(plugin);history.replaceState(null,'',location.pathname+location.search);}}else{history.replaceState(null,'',location.pathname+location.search);setPage(id);}}};navigate();window.addEventListener('hashchange',navigate);return()=>window.removeEventListener('hashchange',navigate);},[status.database.plugins]);
async function launchPlugin(p:Plugin){const remote=managerMode&&!!document.querySelector('meta[name=framely-remote]');const result=await api('plugin.launch',{plugin:p.manifest.id,source:managerMode?'manager':'quickPanel',navigate:!remote});if((remote||!managerMode)&&result.target.type==='quickPage')setPage(p.manifest.id);else if(remote&&result.target.type==='window')location.assign(`/window/${p.manifest.id}/${result.target.window}`);}
async function loadCatalog(){setCatalogTarget(null);setTab('catalog');}
+9 -4
View File
@@ -6,6 +6,10 @@ import {runJob,Job,compareVersions} from './store';
import {Select} from './localized-select';
type UpdateChannel='stable'|'testing';
type UpdateSource={url:string};
export function sameSystemVersion(target:string,current:string){
const version=(value:string)=>value.replace(/-[a-f0-9]{12}$/i,'');
return compareVersions(version(target),version(current))===0;
}
export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<void>}){
const source:UpdateSource|null=status.database.updateSource??null;
const channel:UpdateChannel=status.database.updateChannel??'stable';
@@ -29,6 +33,7 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
useEffect(reset,[source?.url,channel]);
const progress=job?.kind==='system.download'?job:status.systemUpdate;
const older=release&&compareVersions(release.targetVersion??release.version,status.version)===-1;
const sameVersion=release&&sameSystemVersion(release.version,status.build??status.version);
async function switchChannel(value:string){
if(!source||busy||installing||value===channel)return;
setBusy(true);setError('');reset();
@@ -75,7 +80,7 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
<Select label={t('更新渠道')} value={channel} disabled={busy||installing||!source} onChange={value=>void switchChannel(value)} options={[{value:'stable',label:t('正式版')},{value:'testing',label:t('测试版')}]}/>
<p className="sub">{channel==='stable'?t('仅检查正式版。'):t('仅检查测试版(Preview、Beta、RC 等)。')}</p>
<div className="row">
<button className="primary" disabled={busy||installing||!source} onClick={()=>{reset();void run('system.check.start',{},v=>{setRelease(v.release?{...v.release,targetVersion:v.version}:null);setReady(false);setChecked(true);setShowRelease(!!v.release&&v.release.version!==(status.build??status.version));});}}>{busy?t('处理中…'):t('检查更新')}</button>
<button className="primary" disabled={busy||installing||!source} onClick={()=>{reset();void run('system.check.start',{},v=>{setRelease(v.release?{...v.release,targetVersion:v.version}:null);setReady(false);setChecked(true);setShowRelease(!!v.release&&!sameSystemVersion(v.release.version,status.build??status.version));});}}>{busy?t('处理中…'):t('检查更新')}</button>
<button disabled={busy||installing||!status.previousRelease} onClick={()=>setConfirm('rollback')}>{t('回滚上一版本')}</button>
</div>
<div className="automatic-update-settings">
@@ -91,17 +96,17 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
{progress?.phase==='verifying'&&<div className="download-progress"><progress aria-label={t('校验进度')} max={progress.total??1} value={progress.verified??0}/><p>{t('已校验 {0} / {1} MiB',{'0':((progress.verified??0)/1024/1024).toFixed(1),'1':((progress.total??0)/1024/1024).toFixed(1)})}</p></div>}
{progress?.phase==='failed'&&<p className="error">{t('上次更新失败:')}{progress.error}<small>{t('诊断日志:/var/lib/framely/logs/update.log')}</small></p>}
{progress?.phase==='done'&&<p className="banner">{t('上次更新操作已完成。')}</p>}
{checked&&release&&release.version===(status.build??status.version)&&<p className="banner" role="status">{t('当前已是此发行版本')}</p>}
{checked&&sameVersion&&<p className="banner" role="status">{t('当前已是此版本,无需重复更新。')}</p>}
{showRelease&&release&&<dialog ref={releaseDialog} className="modal update-release-dialog" aria-labelledby={releaseTitle} onCancel={e=>{e.preventDefault();setShowRelease(false);}} onKeyDown={e=>{if(e.key==='Escape'){e.stopPropagation();setShowRelease(false);}}}>
<h2 id={releaseTitle}>{t('发现可用发行版本')}</h2>
<div className="release-card">
<h3>{release.version}</h3>
<p>{release.version===(status.build??status.version)?t('当前已是此发行版本'):t('发行包大小:{0} MiB',{'0':(release.size/1024/1024).toFixed(1)})}</p>
<p>{sameVersion?t('当前已是此版本,无需重复更新。'):t('发行包大小:{0} MiB',{'0':(release.size/1024/1024).toFixed(1)})}</p>
{older&&<p className="banner">{t('所选版本早于当前版本,安装将切换到较旧版本。')}</p>}
<p className="prose">{release.changelog||t('发布者未提供更新说明。')}</p>
{error&&<p className="error" role="alert">{error}</p>}
<div className="row">
{release.version!==(status.build??status.version)&&<button className="primary" disabled={busy||installing} onClick={()=>setConfirm('apply')}>{ready?t('安装已校验版本'):t('下载并安装')}</button>}
{!sameVersion&&<button className="primary" disabled={busy||installing} onClick={()=>setConfirm('apply')}>{ready?t('安装已校验版本'):t('下载并安装')}</button>}
<button ref={dismissRelease} disabled={busy||installing} onClick={()=>setShowRelease(false)}>{t('稍后')}</button>
</div>
</div></dialog>}