Compare commits

...
Author SHA1 Message Date
toorux 148bb96ab6 Release 0.4.3-preview.2 with spatial launcher and diagnostic exports 2026-10-05 19:22:32 +08:00
72 changed files with 3497 additions and 144 deletions

No files matched your search

Generated
+1 -1
View File
@@ -291,7 +291,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.4.3-preview.1"
version = "0.4.3-preview.2"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.4.3-preview.1"
version = "0.4.3-preview.2"
edition = "2021"
license = "AGPL-3.0-only"
+3
View File
@@ -33,3 +33,6 @@ Framely manages plugins on Steam Frame through device services, a SteamVR interf
- [Framely releases and updates](releases.md)
- [Desktop installer](../installer/README.md)
- [Space launcher](launcher.md)
- [Launcher actions and host compatibility](developer-guide/launcher.md)
+4
View File
@@ -82,3 +82,7 @@ Inspect Framely status and systemd logs. Do not mix debug messages into backend
Default GitHub Releases use tag `v<version>` and asset `<id>-<version>.framely`; source may omit `downloadUrl`. Community registration derives it automatically. Prepare a temporary packaged Manifest containing the final URL as shown in [publishing](publishing.md), because current `framely pack` does not read Git remotes. Set an explicit URL for custom downloads. Publish the Release/assets with usage, runtime-user details, changes and actual test coverage, then host a catalog or register a fixed source commit. Database registration also checks expected whole-package hashes and field compatibility.
For updates: bump version → regenerate the release Manifest (update explicit custom URLs if used) → rebuild/verify → device test → new Release → update the source pin. Never replace old assets.
## Launcher actions and host compatibility
[Launcher actions and host compatibility](launcher.md)
+73
View File
@@ -0,0 +1,73 @@
# Launcher, actions and host compatibility
[简体中文](../zh-CN/developer-guide/launcher.md) · [Manifest](manifest.md) · [SDK](sdk.md)
Available from Framely **0.4.3-preview.2**. Existing plugins need no changes: a short press opens their quick page and a long press offers Quick panel, Manage plugin and Uninstall plugin. A missing quick page disables that menu item. Uninstall always asks for confirmation.
## Host version requirement
```json
"engines": {"framely": ">=0.4.3-preview.2 <0.5.0"}
```
`engines.framely` is a mandatory host compatibility check, separate from `apiVersion` (the protocol version). It is optional for existing plugins and required when using launcher declarations. A declared launcher feature must have an explicit version floor of `0.4.3-preview.2` or newer.
Ranges use the same parser as plugin dependencies: exact versions (`0.4.3` or `=0.4.3`), comparisons, whitespace/comma intersections, `^`, `~`, and wildcards. `||` and hyphen ranges are not supported. A normal range excludes preview versions; to include a preview, explicitly name its major/minor/patch and prerelease in a comparator. For example `>=0.4.3-preview.2 <0.5.0` matches later 0.4.3 previews and compatible stable releases, but does not automatically include 0.4.4 previews. Build metadata does not affect matching.
The host checks before installation side effects, updates, version rollback, and runtime startup. Incompatible versions remain visible in the library but cannot be installed; dependency resolution skips them. Downgrading Framely preserves incompatible plugins and their enabled preferences while blocking execution. Published older hosts reject the unknown fields rather than run a new-format package; their error messages cannot be retroactively upgraded.
## Entry behavior
```json
"ui": {
"quickPage": "page.js",
"windows": {"main": {"entry": "page.js", "title": "Example"}},
"launch": {
"launcher": {"type": "window", "window": "main"},
"quickPanel": {"type": "quickPage"},
"manager": {"type": "window", "window": "main"}
}
}
```
Omitted entry behavior opens the plugin's quick page. The manager's separate management button is unaffected. The built-in **Quick panel** menu action always opens the quick page regardless of entry configuration. Window references must name a declared window; a `quickPage` target requires a declared quick page.
`plugin.open` retains its existing backend-start meaning. It does not apply entry navigation.
## Long-press actions
Declare buttons in order; Framely renders them above its three built-in actions. At most 16 custom actions are allowed, with unique valid IDs and nonempty labels up to 120 bytes. Labels are developer-provided text; choose localized content in your published package as appropriate.
```json
"launcherActions": [
{"id": "open", "label": "Open window", "target": {"type": "window", "window": "main"}},
{"id": "connect", "label": "Connect", "target": {"type": "backend", "method": "connect", "params": {"profile": "default"}}},
{"id": "notify", "label": "Send notification", "target": {"type": "frontend", "entry": "actions.js"}}
]
```
A backend target requires a backend and receives `{params, context}`; reserved `framely.*` methods are prohibited. A frontend target names a hashed payload bundle. It runs in a dedicated sandbox only when invoked, without rendering the quick page, and uses the existing scoped SDK capabilities. Frontend actions have a 90-second host deadline; failures stay visible for retry. Actions do not gain plugin management or arbitrary host-command privileges.
Build a separate `actions.ts` bundle (the scaffold's build command does this automatically):
```ts
import {registerLauncherActions, framely} from '@framely/sdk';
registerLauncherActions({
notify: async context => {
await framely.notifications.send({
id: 'hello', title: 'Hello', body: `From ${context.source}`, inbox: true,
});
},
});
```
## Startup context
```ts
const initial = await framely.ui.launchContext.get();
const unsubscribe = framely.ui.launchContext.onChanged(context => {
console.log(context.source, context.trigger, context.actionId);
});
```
`source` is `launcher`, `quickPanel`, or `manager`; `trigger` is `shortPress` or `menuAction`; custom menu actions additionally include `actionId`. Initial context is available after loading, and subsequent activations of a reused page/window send `ui.launch`. Frontend action callbacks receive context directly; backend actions receive it with their parameters. A backend `onStart` lifecycle context also includes `launchContext` when startup was caused by an entry launch. Old calls without a known entry use the quick-panel default.
+4
View File
@@ -96,3 +96,7 @@ Dependencies use ranges or `{version, source}`, with source pointing to an HTTPS
Manifest JSON is limited to 256 KiB and payload paths to 512 bytes. Payload paths allow ASCII letters/digits and `/._-+` only. Absolute paths, empty segments, `.`/`..`, symlinks and undeclared archive files are forbidden. Successful validation is not a safety review; users still assess runtime privileges.
`backend.uiVisibilityEvents` (boolean, default false) subscribes to manager-only `framely.ui.visibility` RPCs. Reply promptly. Delivery does not depend on mounted pages and requires a host supporting visibility events.
## Launcher actions and host compatibility
[Launcher actions and host compatibility](launcher.md)
+4
View File
@@ -146,3 +146,7 @@ Declare `backend.uiVisibilityEvents: true` to receive the reserved JSON-line RPC
`framely.ui.close()` closes the current native surface: the quick panel or the current plugin window. It does not stop the backend or recording. Requires a host with this bridge method.
Standalone plugin windows display the plugin page without a host title bar. Plugins provide their own title, navigation and close button using `framely.ui.close()` or `framely.windows.close(id)`. Quick panels retain host navigation.
## Launcher actions and host compatibility
[Launcher actions and host compatibility](launcher.md)
+17
View File
@@ -0,0 +1,17 @@
# Space launcher
[简体中文](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. **Settings → Launcher → Open launcher with the primary trigger** swaps these shortcuts immediately. 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 follows the headset refresh rate within a 60–144 FPS budget, with a 90 FPS fallback when the rate is unavailable. 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 All, Plugins, Steam, Lepton or Desktop apps, the bottom dots to change pages, and the star on hover to favorite apps. 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 **Settings → Launcher**. Uninstall requires confirmation and keeps plugin data.
Keep holding for **800 ms** to enter icon arrangement. Drag any icon (including Framely itself, which opens the management panel) onto another position; icons shift and automatically fill the 4 / 5 / 4 slots without gaps. The left drag edge is to the right of the category selector; the selector itself does not trigger paging. Hold a dragged icon at either edge of the icon area for 650 ms to turn a page; continue holding to move across further pages. Release to save the order, or choose **Done** to finish arranging. Closing or hiding the Dock cancels an unfinished drag. A manually arranged order takes precedence over favorite sorting; new apps are appended.
Either controller's stick switches categories vertically and pages horizontally. A gesture uses only its dominant axis; return below the neutral threshold before the next navigation step. This prevents a diagonal or simultaneous stick input from performing both actions.
Desktop applications come from the session user's XDG application directories, including Flatpak exports; hidden entries and duplicate Steam game shortcuts are omitted. Steam-installed APK applications appear in the Lepton category and launch through Steam. Sideloaded Lepton apps are discovered from installed APKs in each context's package database; empty contexts and Android system packages are excluded. These launch in their existing user-owned context. Missing Android labels/artwork fall back to the package name and initials. Downloaded but uninstalled APK files are not included. Enumeration does not start containers.
Steam launch feedback confirms handoff to Steam rather than game readiness. Plugin-library entries can specify `engines.framely`; update Framely if your version does not satisfy the shown range.
+4
View File
@@ -84,3 +84,7 @@ Restart defaults to `on-failure`; `never` reports failure without retrying. Limi
States include starting/running/stopping/stopped/recovering/failed. Core management is serialized. Normal SIGTERM/SIGINT attempts to stop plugins; forced termination or power loss cannot guarantee hooks. Support safe recovery on next startup.
The log view includes backend and lifecycle logs; each retains a current 2 MiB file and a preceding copy. Failed pre-registration installs log to `/var/lib/framely/logs/<id>.lifecycle.log`. User data is under `/var/lib/framely/data/<id>/<identity>/`, relative to any custom core state root.
## Launch context
[Launch context](developer-guide/launcher.md)
+16
View File
@@ -40,3 +40,19 @@ The **Allow notifications** switch at the top of Notifications controls all floa
In the manager, **Notification settings** provides global **Floating notifications** and **Launcher badge** switches, plus independent **Badge** and **Popup** switches for Framely itself (including update reminders) and every installed plugin, including disabled plugins. Global switches take priority and preserve each app’s preferences. The Framely Dock launcher badge counts unread inbox messages from sources allowed to show a badge, displays `99+` for larger counts, and disappears when you view the inbox. Hidden headset panels do not mark messages as read. Reading keeps messages and their actions available; it does not remove them. Settings and read state survive restart.
Log commands are in the [installation guide](installation.md). A working manager does not establish that all plugins work. Enable plugins individually and verify their sources and runtime users.
## Export logs
Open **Settings → Recovery and diagnostics → Export logs**. In the headset, the ZIP is saved in `~/Downloads` on Frame and the panel shows its path. From the network panel, the ZIP downloads to your browser instead. Collection includes recent Framely/plugin logs, CEF renderer logs, GPU evidence, service status and version information. Missing sources or failed commands are recorded in `report.json`; they do not prevent exporting the remaining information. Each source and the total archive input are bounded.
If the panel cannot open, connect to Frame using the installer and choose **Export logs** in its footer. Select where to save the ZIP on your computer. It collects directly over SSH, includes the installer’s current operation log, and works when Framely services are stopped or its state file is damaged. No release package selection is required.
Plugin configuration, inbox contents and password files are excluded. Common credential patterns in logs are masked; review application logs before sharing the archive. Nothing is uploaded automatically.
For terminal recovery without a running daemon:
```bash
sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs.zip
```
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`.
+3
View File
@@ -35,3 +35,6 @@ Framely 是 Steam Frame 的插件管理器,包含设备端服务、SteamVR 界
- [Framely 发行与更新机制](releases.md)
- [电脑端安装器构建](../../installer/README.zh-CN.md)
- [空间启动台](launcher.md)
- [启动台操作与本体兼容](developer-guide/launcher.md)
+4
View File
@@ -82,3 +82,7 @@ sudo /var/lib/framely/current/bin/framely install ./yourname.my-plugin-0.1.0.fra
GitHub Release 默认使用标签 `v<version>` 和附件 `<id>-<version>.framely`,源码清单可以省略 `downloadUrl`。社区登记会自动拼接地址;打包前按[发布指南](publishing.md)生成包含最终地址的临时清单,因为当前 `framely pack` 不会自己读取仓库地址。自定义下载地址再显式填写 `downloadUrl`。随后公开 Release、上传包,提供使用说明、运行用户、更新记录和实际测试范围,再自行托管插件源或登记固定源码提交。社区数据库还需校验整包预期哈希和字段兼容性。
后续更新:提升版本号 → 重新生成发布清单(自定义地址时更新地址)→ 构建打包并校验 → 实机验证 → 新 Release → 更新来源登记。不要替换旧版本附件。
## 启动台操作与本体兼容
[启动台操作与本体兼容](launcher.md)
+71
View File
@@ -0,0 +1,71 @@
# 启动台、操作与本体兼容要求
[English](../../developer-guide/launcher.md) · [Manifest](manifest.md) · [SDK](sdk.md)
从 Framely **0.4.3-preview.2** 开始支持。旧插件无需修改:短按默认打开快捷页面,长按显示“快捷面板、管理插件、卸载插件”。没有快捷页面时对应按钮置灰,卸载始终需要确认。
## 本体版本要求
```json
"engines": {"framely": ">=0.4.3-preview.2 <0.5.0"}
```
`engines.framely` 是强制执行的本体兼容条件;`apiVersion` 表示协议版本,两者独立。旧插件可以省略;使用启动台新增声明时必须填写,并明确约束最低版本为 `0.4.3-preview.2` 或更新版本。
与插件依赖共用范围解析:精确版本(`0.4.3` 或 `=0.4.3`)、比较符、空格或逗号连接的交集、`^`、`~` 和通配符。不支持 `||` 和连字符区间。普通范围不匹配预览版;需要在比较条件中显式写出对应主、次、补丁版本及预览标识。例如 `>=0.4.3-preview.2 <0.5.0` 接受后续 0.4.3 预览版和兼容正式版,但不会自动接受 0.4.4 预览版。构建标识不影响匹配。
安装、更新、降级安装和启动均检查兼容性;不兼容安装在执行钩子或替换文件之前被拒绝。插件库仍展示不兼容版本及要求,依赖解析跳过这些版本。本体降级后保留插件文件及启用偏好,但阻止执行。已发布的旧本体会拒绝未知清单字段,无法追溯增加友好升级提示。
## 不同入口的行为
```json
"ui": {
"quickPage": "page.js",
"windows": {"main": {"entry": "page.js", "title": "Example"}},
"launch": {
"launcher": {"type": "window", "window": "main"},
"quickPanel": {"type": "quickPage"},
"manager": {"type": "window", "window": "main"}
}
}
```
未配置的入口默认进入对应插件快捷页面。管理面板独立管理按钮不受影响。长按菜单内置的“快捷面板”始终进入快捷页,不受入口配置影响。窗口目标必须引用已声明窗口,快捷页目标必须声明快捷页面。现有 `plugin.open` 仍仅启动后台,不执行入口导航。
## 长按操作
自定义按钮按声明顺序显示在三个内置操作上方;最多 16 个,ID 合法且不重复,名称非空且不超过 120 字节。名称由插件提供,发布时自行选择合适的语言。
```json
"launcherActions": [
{"id": "open", "label": "打开窗口", "target": {"type": "window", "window": "main"}},
{"id": "connect", "label": "连接", "target": {"type": "backend", "method": "connect", "params": {"profile": "default"}}},
{"id": "notify", "label": "发送通知", "target": {"type": "frontend", "entry": "actions.js"}}
]
```
后台目标必须有后台,接收 `{params, context}`,禁止使用保留的 `framely.*` 方法。前端目标必须指向包内带哈希的脚本;点击时在独立沙箱中加载,不渲染快捷页面,可使用现有 SDK 能力。前端操作最多等待 90 秒,失败保留菜单并支持重试。操作不会取得插件管理或任意系统命令权限。
新模板会自动把 `actions.ts` 构建为 `actions.js`:
```ts
import {registerLauncherActions, framely} from '@framely/sdk';
registerLauncherActions({
notify: async context => {
await framely.notifications.send({
id: 'hello', title: '你好', body: `来自 ${context.source}`, inbox: true,
});
},
});
```
## 启动上下文
```ts
const initial = await framely.ui.launchContext.get();
const unsubscribe = framely.ui.launchContext.onChanged(context => {
console.log(context.source, context.trigger, context.actionId);
});
```
`source` 为 `launcher`、`quickPanel` 或 `manager`;`trigger` 为 `shortPress` 或 `menuAction`;自定义操作还包含 `actionId`。首次加载即可查询,复用页面或窗口时接收 `ui.launch` 事件。前端操作直接接收上下文,后台操作随参数接收。入口导致后台首次启动时,`onStart` 生命周期参数也包含 `launchContext`。没有已知来源的旧调用默认标记为快捷面板。
+4
View File
@@ -96,3 +96,7 @@
清单 JSON 最大 256 KiB,载荷路径最多 512 字节。载荷路径仅允许 ASCII 字母、数字及 `/._-+`,禁止绝对路径、空段、`.`、`..`、软链接和未声明的附加包文件。清单通过校验后,运行权限仍需用户判断;文件哈希不代表插件安全审核。
`backend.uiVisibilityEvents`(boolean,默认 false):订阅宿主专用 `framely.ui.visibility` RPC。后端必须及时回复;通知无需页面挂载。该字段需要支持可见性事件的新版宿主。
## 启动台操作与本体兼容
[启动台操作与本体兼容](launcher.md)
+4
View File
@@ -153,3 +153,7 @@ const off = framely.ui.onVisibilityChanged(state => {
`framely.ui.close()` 关闭当前原生界面:快捷页关闭 Framely 面板,插件窗口关闭自身;不会停止插件后端或当前录制。需要包含此 bridge 方法的新版宿主。
独立插件窗口直接展示插件页面,宿主不添加标题栏。插件应自行提供标题、导航和关闭按钮,可使用 `framely.ui.close()` 或 `framely.windows.close(id)`。快捷面板仍保留宿主导航。
## 启动台操作与本体兼容
[启动台操作与本体兼容](launcher.md)
+17
View File
@@ -0,0 +1,17 @@
# 空间启动台
[English](../launcher.md)
激光指向 Framely Dock 图标:上扳机打开启动台,主扳机打开快捷面板。在 **设置 → 启动台 → 主扳机打开启动台** 开启开关,两个入口立即互换。其他手柄保留普通点击入口。
启动台是 Dock 内宽 3 米的原生面板,由 Steam 管理位置和曲面;打开时通过 Dock 的面板切换机制替代当前面板,Dock 隐藏时关闭。界面绘制上限跟随头显刷新率,在 60–144 FPS 范围内调整;读取不到刷新率时回退到 90 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 本机各库已安装应用,不包含仅在账号库或串流电脑中的应用。
按下后的激光落点允许在 36 个界面像素内抖动,点击区域大于图标本身;明显拖动会取消落点稳定处理。主扳机按住插件图标 400 毫秒展开操作菜单,普通抖动不会取消长按,包含开发者按钮和“快捷面板、管理插件、卸载插件”。按 **B** 或点击空白区域关闭启动台;处于图标整理状态时,第一次操作先退出整理,启动台继续显示。按住主扳机横向拖动时,当前页随手移动,相邻页同步滑入;松手后吸附到相邻页,拖动距离不足或到达边界则回到当前页。操作菜单默认在指针离开 10 秒后关闭,重新移入取消计时。在 **设置 → 启动台** 调整自动关闭开关与时长(1–300 秒)。卸载需要确认,并保留插件数据。
继续按住到 **800 毫秒**进入图标整理。拖动任意图标到其他位置,其他图标按顺序移动并自动补齐 4/5/4 槽位,不留空洞。Framely 本身也在启动台中,点击打开管理面板,并可一起排列。向左拖动的翻页边缘位于分类器右侧,分类器本身不会触发翻页。拖到图标区域左右边缘停留 650 毫秒自动翻页,继续停留可跨更多页面。放手保存排列,点击“完成”退出整理。关闭启动台或隐藏 Dock 会取消尚未完成的拖动。手动排列优先于收藏排序,新应用追加在后面。
任一手柄的摇杆上下切换分类、左右翻页。每次拨动只取幅度较大的一个方向,回到中位范围后才允许再次导航,避免斜向或两个摇杆同时触发分类与翻页。
桌面程序从会话用户的 XDG 应用目录和 Flatpak 导出入口读取,隐藏入口及重复的 Steam 游戏快捷方式不会显示。Steam 安装的 APK 应用出现在 Lepton 分类,通过 Steam 启动;侧载应用从 Lepton 各容器的已安装 APK 与包数据库读取,排除空容器和 Android 系统组件,并在原有用户容器中启动。缺少 Android 名称、图片时使用包名和首字母。仅下载但未安装的 APK 不会加入启动台,读取应用列表也不会启动容器。
Steam 启动成功表示请求已交给 Steam,并不代表游戏加载完成。插件库版本可声明 `engines.framely`;当前本体不满足显示的范围时,请先升级本体。
+4
View File
@@ -93,3 +93,7 @@ restart 默认为 on-failure;never 只记录失败,不自动重启。restart
状态包含 starting、running、stopping、stopped、recovering、failed 等阶段,并发管理操作沿核心服务的串行调度执行。管理器正常 SIGTERM/SIGINT 会尝试逐个停止插件;强制终止、断电不能保证任何钩子执行。清理应同时可在下次启动时安全恢复。
日志页面合并展示后端日志与 lifecycle 日志;每种日志保留当前 2 MiB 与上一份。安装失败且插件尚未登记时,可从 `/var/lib/framely/logs/<id>.lifecycle.log` 查看。运行身份的数据目录为 `/var/lib/framely/data/<id>/<身份>/`(自定义核心数据目录时相应变化)。
## 启动上下文
[启动上下文](developer-guide/launcher.md)
+16
View File
@@ -44,3 +44,19 @@
本体默认在启动约 30 秒后检查更新(须已接受用户协议),之后每 6 小时检查一次;面板关闭时也会继续检查。可在管理窗口“关于”的更新页关闭自动检查或修改间隔。自动检查遵循所选正式版/测试版渠道及代理配置,只检查并提醒,不会自动下载或安装。
发现较新版本后会将提醒保存在收件箱,通知设置允许时同时显示浮窗。“忽略”关闭并移除此版本提醒,同一版本在重启后也不会重复提醒;“打开更新”直接打开管理窗口的更新页面。更改更新源或渠道会清除旧提醒。
## 导出日志
打开 **设置 → 恢复与诊断 → 导出日志**。在头显内,ZIP 保存到 Frame 的 `~/Downloads`,面板显示保存路径;通过网络面板访问时,ZIP 下载到浏览器。日志包包括本体与插件的近期日志、CEF 渲染日志、GPU 信息、服务状态和安装版本。缺失来源或命令失败会记录在 `report.json` 中,不影响导出其余信息;单个来源和总收集量均有大小限制。
管理面板无法打开时,使用安装器连接 Frame,点击底部的 **导出日志**,选择电脑上的保存位置。安装器直接通过 SSH 收集,也会加入当前安装器的操作日志;本体服务停止或状态文件损坏时仍可使用,无需选择发行版本或安装包。
日志包不包含插件配置、收件箱内容或密码文件,会过滤日志中的常见凭据格式;分享前仍应检查应用日志。不会自动上传。
无需运行本体服务,也可以通过终端导出:
```bash
sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs.zip
```
输出路径必须是新文件。本体程序无法运行时,解压发行包中的 `tools/export-diagnostics.py` 也可独立使用:`sudo python3 tools/export-diagnostics.py --output /tmp/framely-logs.zip`。
+1 -1
View File
@@ -1985,7 +1985,7 @@ dependencies = [
[[package]]
name = "framely-installer"
version = "0.4.1-preview.8"
version = "0.4.1-preview.9"
dependencies = [
"anyhow",
"base64 0.22.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely-installer"
version = "0.4.1-preview.8"
version = "0.4.1-preview.9"
edition = "2024"
license = "AGPL-3.0-only"
+7 -1
View File
@@ -62,7 +62,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
Preview requires an available GPU or software Vulkan renderer. Default release builds do not include preview support.
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.8`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.9`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
Actions builds Linux x64/ARM64, Windows x64, and macOS Intel/Apple Silicon. macOS produces an `.app` ZIP, Windows an EXE ZIP, and Linux a tar.gz. Apple notarization and Windows code signing are not configured; verify first-launch behavior on each system before publishing.
@@ -77,3 +77,9 @@ Discovery queries `frame.local` mDNS address records directly. It does not requi
```bash
cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --example scan -- 192.168.5.0/24
```
## Export diagnostic logs
After connecting to Frame, click **Export logs** in the footer and choose a ZIP destination on your computer. You do not need to select a release or installation package. Collection runs directly over SSH with administrator access, independently of Framely’s web panel and daemon. It also works with stopped services or damaged state, and includes the installer’s current operation log. Export does not install, restart or change Framely.
The archive includes bounded recent service/plugin/CEF logs, GPU evidence, installation versions and a plugin status summary. Missing sources are listed in `report.json`. Plugin settings, inbox contents and password files are excluded; common credential patterns are masked. Review logs before sharing. Cancelling the save dialog leaves the installation untouched.
+7 -1
View File
@@ -62,7 +62,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
预览依赖可用的 GPU 或软件 Vulkan 渲染器;默认发行构建不包含此预览功能。
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.8`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.9`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
Actions 构建 Linux x64/ARM64、Windows x64、macOS Intel/Apple Silicon。macOS 输出 `.app` ZIP,Windows 输出 EXE ZIP,Linux 输出 tar.gz。当前未配置 Apple 公证或 Windows 代码签名,发布前应分别验证系统的首次启动体验。
@@ -77,3 +77,9 @@ Actions 构建 Linux x64/ARM64、Windows x64、macOS Intel/Apple Silicon。macOS
```bash
cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --example scan -- 192.168.5.0/24
```
## 导出诊断日志
连接 Frame 后,点击底部的 **导出日志**,选择电脑上的 ZIP 保存位置,无需选择发行版本或安装包。安装器通过 SSH 和管理员权限独立收集,不依赖网络面板或本体服务;服务停止、状态文件损坏时仍能导出,并包含安装器当前的操作日志。导出不会安装、重启或修改 Framely。
日志包包含有大小限制的近期服务、插件和 CEF 日志、GPU 信息、安装版本及插件状态摘要。缺失来源记录在 `report.json` 中。不包含插件设置、收件箱内容和密码文件,并过滤常见凭据格式;分享前仍应检查日志。取消保存弹窗不会改变安装状态。
+48
View File
@@ -42,6 +42,7 @@ enum Event {
Connected(Result<Connection, String>),
File(FileKind, Option<PathBuf>),
Log(String),
LogsExported(Result<Option<PathBuf>, String>),
Progress(Progress),
Done(Result<(), String>),
}
@@ -320,6 +321,20 @@ impl Installer {
self.status = progress.detail.clone();
self.progress = Some(progress);
}
Event::LogsExported(result) => {
self.busy = false;
match result {
Ok(Some(path)) => {
self.status = "日志已导出。".into();
self.notice("日志导出完成", format!("已保存到:{}", path.display()));
}
Ok(None) => self.status = "已取消导出日志。".into(),
Err(error) => {
self.status = "日志导出失败。".into();
self.notice("日志导出失败", error);
}
}
}
Event::Done(result) => {
self.busy = false;
self.error = None;
@@ -347,6 +362,39 @@ impl Installer {
}
self.present_prompt(window, cx);
}
fn export_logs(&mut self, cx: &mut Context<Self>) {
if self.busy {
return;
}
let Some(connection) = self.connection.clone() else {
return;
};
let password = Zeroizing::new(self.password.read(cx).value().to_string());
let sender = self.sender.clone();
self.busy = true;
self.status = "正在导出日志…".into();
let logs = self.logs.clone();
std::thread::spawn(move || {
let result = (|| -> anyhow::Result<Option<PathBuf>> {
let Some(path) = rfd::FileDialog::new()
.set_title("保存 Framely 日志包")
.set_file_name("framely-logs.zip")
.add_filter("ZIP 日志包", &["zip"])
.save_file()
else {
return Ok(None);
};
let guard = connection
.lock()
.map_err(|_| anyhow::anyhow!("设备连接不可用"))?;
remote::export_logs(&guard, &password, &path, &logs)?;
Ok(Some(path))
})()
.map_err(|e| format!("{e:#}"));
let _ = sender.send(Event::LogsExported(result));
});
cx.notify();
}
fn scan(&mut self, cx: &mut Context<Self>) {
self.cancelled.store(true, Ordering::Relaxed);
self.cancelled = Arc::new(AtomicBool::new(false));
+65
View File
@@ -507,3 +507,68 @@ mod tests {
eprintln!("stages={stages:?}; error={error}");
}
}
/// Collect directly over SSH; neither the web panel nor the daemon is required.
pub fn export_logs(
connection: &Connection,
password: &str,
destination: &Path,
logs: &[String],
) -> Result<()> {
use base64::engine::general_purpose::STANDARD;
ensure!(
!password.is_empty() && !password.contains(['\n', '\r']),
"请输入有效的设备登录密码"
);
connection.session.set_timeout(60_000);
let script = include_str!("../../tools/export-diagnostics.py");
// Keep archive output private and binary-safe. The ordinary text logger
// masks password substrings, which could corrupt a base64 archive.
let mut channel = connection.session.channel_session()?;
let local_log = logs.join("\n").replace(password, "[密码已隐藏]");
let recent: String = local_log
.chars()
.rev()
.take(64 * 1024)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
channel.exec(&format!(
"sudo -S -p '' -- python3 -c {} --installer-log-base64 {}",
quote(script),
quote(&STANDARD.encode(recent))
))?;
channel.write_all(password.as_bytes())?;
channel.write_all(b"\n")?;
channel.flush()?;
channel.send_eof()?;
let mut output = String::new();
Read::by_ref(&mut channel)
.take(12 * 1024 * 1024)
.read_to_string(&mut output)?;
let mut error = String::new();
channel.stderr().take(4096).read_to_string(&mut error)?;
channel.wait_close()?;
ensure!(
channel.exit_status()? == 0,
"日志收集失败:{}",
error.replace(password, "[密码已隐藏]")
);
let archive: serde_json::Value =
serde_json::from_str(output.trim()).context("日志包响应无效")?;
let bytes = STANDARD.decode(archive["data"].as_str().context("日志包缺少数据")?)?;
ensure!(
bytes.len() <= 9 * 1024 * 1024 && bytes.starts_with(b"PK\x03\x04"),
"日志包格式无效"
);
let parent = destination
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let mut file = tempfile::NamedTempFile::new_in(parent).context("无法创建日志包")?;
file.write_all(&bytes).context("无法保存日志包")?;
file.as_file().sync_all()?;
file.persist(destination).context("无法保存日志包")?;
Ok(())
}
+6
View File
@@ -1111,6 +1111,12 @@ impl Render for Installer {
},
)),
);
footer = footer.child(
Button::new("export-diagnostic-logs")
.label("导出日志")
.disabled(self.busy || self.connection.is_none())
.on_click(cx.listener(|view, _, _, cx| view.export_logs(cx))),
);
if self.page < 2 {
let label = if self.page == 0 && self.connection.is_none() {
"先连接设备"
+159 -22
View File
@@ -8,8 +8,13 @@
#include "dock_uv_geometry.h"
#include "menu_geometry.h"
#include "menu_input.h"
#include "launcher_input.h"
#include "launcher_pointer.h"
#include <filesystem>
#include "overlay_placement.h"
#include "render_resolution.h"
#include "render_timing.h"
#include "paint_buffer.h"
#include "notification_geometry.h"
#include "notification_badge.h"
#include "keyboard_state.h"
@@ -43,6 +48,20 @@ static Display* xdisplay=nullptr;
static GLXContext glcontext=nullptr;
static GLXPbuffer glsurface=0;
static std::atomic<unsigned> notification_badge_count{0};
static bool launcher_open=false;
static LauncherDismissGuard launcher_dismiss;
static EntryTrigger launcher_close_button;
static LauncherStick launcher_stick;
static std::chrono::steady_clock::time_point launcher_outside_at{};
static bool launcher_selected=false;
static std::atomic<bool> launcher_primary{false};
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;
static vr::VRInputValueHandle_t hands[2]{};
static EntryTrigger entry_buttons[2][2];
static EntryAnalogTrigger entry_analog[2];
static EntryPointer entry_pointer;
static bool menu_open=false,keyboard_open=false,keyboard_close_pending=false;
static float notification_curve_radius=1.3f;
static bool keyboard_toolbar=false,keyboard_chars=false,keyboard_done=false;
@@ -63,9 +82,9 @@ 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;std::chrono::steady_clock::time_point haptic_at{};
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_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]{};std::vector<uint8_t> pixels;
vr::VROverlayHandle_t overlay=0,thumbnail=0;GLuint textures[2]{};int texture_slot=0;bool texture_ready[2]{};PaintDamage texture_damage[2];std::vector<uint8_t> pixels;
};
static std::map<std::string,std::unique_ptr<View>> views;
static View* find_browser(cef_browser_t* b){int id=b->get_identifier(b);for(auto& [key,v]:views)if(v->browser_id==id)return v.get();return nullptr;}
@@ -183,23 +202,24 @@ static void anchor_notification(View& v){
v.placement.curve(overlays,v.overlay,toast.width,toast.radius);
}
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)overlays->ShowDashboard(key.c_str());else if(key!="notifications")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"?4:(key=="menu"||key=="notifications")?2:1;
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;
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);
// Keep the runtime's full dashboard controls, including its keyboard and close buttons.
if(dock){
auto result=overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MinimalControlBar,false);
if(key=="launcher")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_NoDashboardTab,true);
auto result=overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MinimalControlBar,key=="launcher");
if(result)std::cerr<<"Dashboard controls unavailable "<<key<<" "<<result<<"\n";
for(auto flag:{vr::VROverlayFlags_EnableControlBarKeyboard,vr::VROverlayFlags_EnableControlBarClose}){
result=overlays->SetOverlayFlag(v->overlay,flag,true);
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:1.2f));}else if(key!="menu"&&key!="notifications"){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=30;bs.background_color=0;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)overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications")show(*ptr,true);return *ptr;
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);}
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(vr_system->GetFloatTrackedDeviceProperty(vr::k_unTrackedDeviceIndex_Hmd,vr::Prop_DisplayFrequency_Float));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;
}
static void plugin_thumbnail(View& view,const std::string& encoded){
if(!view.thumbnail||encoded.empty()||encoded.size()>1400000)return;
@@ -215,9 +235,105 @@ static void plugin_thumbnail(View& view,const std::string& encoded){
for(int y=0;y<h;y++)for(int x=0;x<w;x++){int sx=std::min(width-1,int(x/scale)),sy=std::min(height-1,int(y/scale));std::copy_n(source.data()+(sy*width+sx)*4,4,pixels.data()+((y+(128-h)/2)*128+x+(128-w)/2)*4);}
overlays->SetOverlayRaw(view.thumbnail,pixels.data(),128,128,4);
}
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=="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"){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:""),true,1440,810);if(page=="catalog"||page=="updates"||page=="notification-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;}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"){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;}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");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 navigate(View& view,const std::string& hash){auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"location.hash="+json(hash).dump()+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}
static void hide_launcher(){
launcher_open=false;launcher_selected=false;
if(views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay))
overlays->ShowDashboard("valve.steam.gamepadui.main");
}
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing||(v.key=="notifications"&&!v.shown))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=="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!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){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!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove: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=="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")close_view(v);break;default:break;}}
static void launcher_back(){
if(!launcher_open||!views.count("launcher"))return;
auto& view=*views.at("launcher");auto* frame=view.browser->get_main_frame(view.browser);
execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);
}
static bool launcher_accepts(const vr::VREvent_t& e){
bool held=false;for(auto& hand:entry_buttons)for(auto& button:hand)held|=button.held;
return launcher_dismiss.allows(std::chrono::steady_clock::now(),held,e.eventAgeSeconds);
}
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);
vr::VREvent_t stale{};while(overlays->PollNextOverlayEvent(view.overlay,&stale,sizeof(stale))){}
launcher_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={};
overlays->ShowDashboard("launcher");
auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.open'))");frame->base.release(&frame->base);
}else{hide_launcher();menu_open=!menu_open;if(menu_open)menu_button.opened(std::chrono::steady_clock::now());}
}
static void setup_entry_actions(){
char exe[4096]{};auto n=readlink("/proc/self/exe",exe,sizeof(exe)-1);if(n<=0)return;
auto base=std::filesystem::path(exe).parent_path();auto manifest=base/"../../share/input/actions.json";
if(!std::filesystem::exists(manifest))manifest=base/"../../native/input/actions.json";
auto* input=vr::VRInput();if(!input||!std::filesystem::exists(manifest))return;
auto error=input->SetActionManifestPath(std::filesystem::absolute(manifest).lexically_normal().c_str());if(error!=vr::VRInputError_None){std::cerr<<"Action manifest failed: "<<error<<std::endl;return;}
entry_actions_ready=input->GetActionSetHandle("/actions/entry",&entry_set)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/primary",&primary_action)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/upper",&upper_action)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/aim",&aim_action)==vr::VRInputError_None;
input->GetActionHandle("/actions/entry/in/close",&close_action);input->GetActionHandle("/actions/entry/in/navigate",&navigate_action);
input->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 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;
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;
if(available&&!pose_ok&&device!=vr::k_unTrackedDeviceIndexInvalid){
vr::TrackedDevicePose_t tracked[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,tracked,vr::k_unMaxTrackedDeviceCount);
char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));
vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t tip{};
if(tracked[device].bDeviceIsConnected&&tracked[device].bPoseIsValid&&vr::VRRenderModels()->GetComponentStateForDevicePath(model,"tip",hands[hand],&mode,&tip)){
pose.pose.mDeviceToAbsoluteTracking=entry_tip_pose(tracked[device].mDeviceToAbsoluteTracking,tip.mTrackingToComponentLocal);pose_ok=true;
}
}
if(pose_ok){auto& m=pose.pose.mDeviceToAbsoluteTracking;vr::VROverlayIntersectionParams_t params{};params.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){params.vSource.v[r]=m.m[r][3];params.vDirection.v[r]=-m.m[r][2];}vr::VROverlayIntersectionResults_t result{};hit=hit||(overlays->IsHoverTargetOverlay(button)&&overlays->ComputeOverlayIntersection(button,&params,&result)&&result.vUVs.v[0]>=float(dock_icon_left)/dock_canvas_width&&result.vUVs.v[0]<=float(dock_icon_left+128)/dock_canvas_width);}
for(int kind=0;kind<2;kind++){vr::InputDigitalActionData_t data{};bool active=input->GetDigitalActionData(kind?upper_action:primary_action,&data,sizeof(data),hands[hand])==vr::VRInputError_None&&data.bActive;
bool down=data.bState;if(kind==0&&active)entry_analog[hand].held=down;
// Dashboard focus suppresses action data. Frame's render-model components
// read the separate physical input paths, without changing Steam bindings
// or the user's global input-override setting.
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){
char type[256]{},model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_ControllerType_String,type,sizeof(type));
if(std::string(type)=="frame_controller"){
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,kind?"bumper":"trigger",hands[hand],&mode,&state);
down=active&&(state.uProperties&vr::VRComponentProperty_IsPressed);
if(active&&kind==0){
// Frame's primary trigger is analog: its animated component has no
// IsPressed flag. Calibrate its travel from the model's legacy axis 1,
// then match Steam's 50% press / 45% release thresholds.
vr::VRControllerState_t rest_input{},full_input{};full_input.rAxis[1].x=1;
vr::RenderModel_ComponentState_t rest{},full{};float pull=0;
if(vr::VRRenderModels()->GetComponentState(model,"trigger",&rest_input,&mode,&rest)&&vr::VRRenderModels()->GetComponentState(model,"trigger",&full_input,&mode,&full)&&entry_trigger_pull(rest,full,state,pull))down=entry_analog[hand].update(pull)||down;
}
}
}
if(kind==0&&!active)entry_analog[hand].held=false;
bool was_held=entry_buttons[hand][kind].held;
if(entry_buttons[hand][kind].update(active,down,hit,now))toggle_entry(kind==0?launcher_primary.load():!launcher_primary.load());
else if(kind==0&&active&&down&&!was_held&&launcher_open&&!hit&&launcher_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(!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(launcher_open){int direction=launcher_stick.update(stick_x,stick_y,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={};}
if(launcher_open&&launcher_outside_at.time_since_epoch().count()&&now>=launcher_outside_at){launcher_outside_at={};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.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"){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");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_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){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;}}
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];}
@@ -231,21 +347,21 @@ int main(int argc,char** argv){
vr::VR_Shutdown();
// Frame's compositor requires the Overlay application type for visible UI.
// The session agent and service gate this renderer on all native services.
vr_system=vr::VR_Init(&vr_error,vr::VRApplication_Overlay);if(vr_error){std::cerr<<"SteamVR renderer unavailable "<<vr_error<<"\n";return 3;}overlays=vr::VROverlay();
vr_system=vr::VR_Init(&vr_error,vr::VRApplication_Overlay);if(vr_error){std::cerr<<"SteamVR renderer unavailable "<<vr_error<<"\n";return 3;}overlays=vr::VROverlay();setup_entry_actions();
xdisplay=XOpenDisplay(nullptr);if(!xdisplay){vr::VR_Shutdown();return 4;}int n=0;int attrs[]={GLX_DRAWABLE_TYPE,GLX_PBUFFER_BIT,GLX_RENDER_TYPE,GLX_RGBA_BIT,None};auto* configs=glXChooseFBConfig(xdisplay,DefaultScreen(xdisplay),attrs,&n);if(!configs||!n){vr::VR_Shutdown();return 4;}int pba[]={GLX_PBUFFER_WIDTH,16,GLX_PBUFFER_HEIGHT,16,None};glsurface=glXCreatePbuffer(xdisplay,configs[0],pba);glcontext=glXCreateNewContext(xdisplay,configs[0],GLX_RGBA_TYPE,nullptr,True);XFree(configs);if(!glcontext||!glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext)){vr::VR_Shutdown();return 4;}
cef_settings_t settings{};settings.size=sizeof(settings);settings.windowless_rendering_enabled=1;std::string locale;for(const char* key:{"LC_ALL","LC_MESSAGES","LANG"}){const char* value=std::getenv(key);if(value&&*value){locale=value;break;}}auto suffix=locale.find_first_of(".@");if(suffix!=std::string::npos)locale.erase(suffix);std::replace(locale.begin(),locale.end(),'_','-');if(locale=="C"||locale=="POSIX"||locale.empty())locale="en-US";str(settings.accept_language_list,locale+",en-US,en");str(settings.root_cache_path,runtime+"/cef-cache");str(settings.log_file,runtime+"/cef.log");if(!cef_initialize(&args,&settings,nullptr,nullptr)){vr::VR_Shutdown();return 5;}
render_handler.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t* b,cef_rect_t* r){auto* v=find_browser(b);*r={0,0,v?v->width:600,v?v->height:840};};
render_handler.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t* b,cef_screen_info_t* info){auto* v=find_browser(b);return render_screen_info(info,v?v->width:600,v?v->height:840,v?v->render_scale:1);};
render_handler.api.on_paint=[](cef_render_handler_t*,cef_browser_t* b,cef_paint_element_type_t type,size_t,const cef_rect_t*,const void* data,int w,int h){auto* v=find_browser(b);if(!v||v->closing||type!=PET_VIEW||w!=v->width*v->render_scale||h!=v->height*v->render_scale)return;v->pixels.resize(w*h*4);auto* source=static_cast<const uint8_t*>(data);for(int y=0;y<h;y++)std::copy_n(source+y*w*4,w*4,v->pixels.data()+(h-1-y)*w*4);v->dirty=true;};
render_handler.api.on_paint=[](cef_render_handler_t*,cef_browser_t* b,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){auto* v=find_browser(b);if(!v||v->closing||type!=PET_VIEW||w!=v->width*v->render_scale||h!=v->height*v->render_scale)return;merge_paint(v->pixels,data,w,h,dirty_count,dirty,true);for(auto& damage:v->texture_damage)damage.add(w,h,dirty_count,dirty);v->dirty=true;};
life_handler.api.on_before_popup=[](cef_life_span_handler_t*,cef_browser_t*,cef_frame_t*,int,const cef_string_t*,const cef_string_t*,cef_window_open_disposition_t,int,const cef_popup_features_t*,cef_window_info_t*,cef_client_t**,cef_browser_settings_t*,cef_dictionary_value_t**,int*){return 1;};
life_handler.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t* b){auto* v=find_browser(b);if(v){v->closed=true;v->host->base.release(&v->host->base);v->host=nullptr;v->browser->base.release(&v->browser->base);v->browser=nullptr;}};
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
request_handler.api.on_before_browse=[](cef_request_handler_t*,cef_browser_t* b,cef_frame_t* f,cef_request_t* r,int,int){auto* uri=r->get_url(r);auto s=text(uri);cef_string_userfree_utf16_free(uri);auto* v=find_browser(b);return int(!allow_plugin_navigation(s,origin,!f||f->is_main(f),v&&!v->plugin.empty()));};
request_handler.api.on_render_process_terminated=[](cef_request_handler_t*,cef_browser_t* b,cef_termination_status_t,int,const cef_string_t*){auto* v=find_browser(b);if(v){std::cerr<<"Renderer crashed: "<<v->key<<"\n";close_view(*v);if(v->key=="menu")running=0;}};
request_handler.api.on_render_process_terminated=[](cef_request_handler_t*,cef_browser_t* b,cef_termination_status_t,int,const cef_string_t*){auto* v=find_browser(b);if(v){std::cerr<<"Renderer crashed: "<<v->key<<"\n";close_view(*v);if(v->key=="menu")running=0;if(v->key=="launcher")launcher_open=false;}};
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));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",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));}});
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;
@@ -263,33 +379,54 @@ int main(int argc,char** argv){
if(!main_overlay)main_overlay=active_main_window();
if(main_overlay&&!overlays->GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose;
if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);}
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications")close_view(*v);}was_visible=visible;
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;launcher_open=false;for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher")close_view(*v);}was_visible=visible;
// The runtime owns dashboard placement, curvature, visibility and switching.
// Never move the launcher into standing space or hide another app's overlay.
if(views.count("launcher")){
bool selected=overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay);
if(launcher_open&&selected)launcher_selected=true;
if(launcher_open&&!selected&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(1)))launcher_open=false;
}
// Toggle once for a matched left click; never activate on a stale release.
vr::VREvent_t event{};while(overlays->PollNextOverlayEvent(button,&event,sizeof(event))){
laser_cursor(button,event);
if(event.eventType==vr::VREvent_MouseMove||event.eventType==vr::VREvent_MouseButtonDown||event.eventType==vr::VREvent_MouseButtonUp)entry_pointer.move(event.trackedDeviceIndex,event.data.mouse.x,event.data.mouse.y);
if(event.eventType==vr::VREvent_FocusLeave)entry_pointer.leave();
if(event.eventType==vr::VREvent_MouseButtonDown||event.eventType==vr::VREvent_MouseButtonUp){
if(menu_button.event(event.eventType==vr::VREvent_MouseButtonDown,event.data.mouse.button==vr::VRMouseButton_Left,anchor_ok,now)){
menu_open=!menu_open;if(menu_open){menu_button.opened(now);if(views.count("menu"))views.at("menu")->host->set_focus(views.at("menu")->host,1);}
if(!entry_actions_ready&&menu_button.event(event.eventType==vr::VREvent_MouseButtonDown,event.data.mouse.button==vr::VRMouseButton_Left,anchor_ok,now)){
toggle_entry(launcher_primary.load());if(menu_open){menu_button.opened(now);if(views.count("menu"))views.at("menu")->host->set_focus(views.at("menu")->host,1);}
std::cout<<"Menu "<<(menu_open?"opened":"closed")<<std::endl;
}
}
}
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){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;for(auto&[other,t]:views)if(!t->dashboard&&other!="menu"&&other!="notifications"&&!keyboard_open)close_view(*t);}}
if(!anchor_ok)entry_pointer.leave();
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);}}
while(overlays->PollNextOverlayEvent(v->overlay,&event,sizeof(event)))overlay_input(*v,event);
if(v->dirty&&!v->closing){
glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);
// Upload to the other texture; never redefine storage submitted this frame.
const int slot=1-v->texture_slot;const GLuint id=v->textures[slot];
auto& damage=v->texture_damage[slot];const int pixel_width=v->width*v->render_scale,pixel_height=v->height*v->render_scale;
static auto generate_mipmap=reinterpret_cast<PFNGLGENERATEMIPMAPPROC>(glXGetProcAddressARB(reinterpret_cast<const GLubyte*>("glGenerateMipmap")));
bool mipmapped=v->key=="launcher"&&generate_mipmap;
glBindTexture(GL_TEXTURE_2D,id);
glPixelStorei(GL_UNPACK_ALIGNMENT,4);glPixelStorei(GL_UNPACK_ROW_LENGTH,0);
glPixelStorei(GL_UNPACK_SKIP_PIXELS,0);glPixelStorei(GL_UNPACK_SKIP_ROWS,0);
if(!v->texture_ready[slot]){
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,mipmapped?GL_LINEAR_MIPMAP_LINEAR:GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,v->width*v->render_scale,v->height*v->render_scale,0,GL_BGRA,GL_UNSIGNED_BYTE,nullptr);
v->texture_ready[slot]=true;
damage.include(0,0,pixel_width,pixel_height);
}
if(!damage.empty()){
glPixelStorei(GL_UNPACK_ROW_LENGTH,pixel_width);
glTexSubImage2D(GL_TEXTURE_2D,0,damage.x,damage.y,damage.width,damage.height,GL_BGRA,GL_UNSIGNED_BYTE,v->pixels.data()+(size_t(damage.y)*pixel_width+damage.x)*4);
glPixelStorei(GL_UNPACK_ROW_LENGTH,0);damage.clear();
if(mipmapped)generate_mipmap(GL_TEXTURE_2D);
}
glTexSubImage2D(GL_TEXTURE_2D,0,0,0,v->width*v->render_scale,v->height*v->render_scale,GL_BGRA,GL_UNSIGNED_BYTE,v->pixels.data());
// glFlush queues the upload; glFinish makes the full image ready for sharing.
glFinish();vr::Texture_t texture{reinterpret_cast<void*>(uintptr_t(id)),vr::TextureType_OpenGL,vr::ColorSpace_Auto};
auto e=overlays->SetOverlayTexture(v->overlay,&texture);glFlush();
@@ -322,7 +459,7 @@ int main(int argc,char** argv){
show(*v,v->notification_content&&v->placement.valid);
}
}
{json snapshot=json::object();for(auto&[key,v]:views){if(key=="menu"||key=="framely.manager"||key.rfind("framely.window.",0)==0)snapshot[key]=capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay));}std::lock_guard<std::mutex> lock(visibility_mutex);visible_views=std::move(snapshot);}
{json snapshot=json::object();for(auto&[key,v]:views){if(key=="menu"||key=="launcher"||key=="framely.manager"||key.rfind("framely.window.",0)==0)snapshot[key]=capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay));}std::lock_guard<std::mutex> lock(visibility_mutex);visible_views=std::move(snapshot);}
if(keyboard_close_pending){
// A non-minimal runtime keyboard may buffer text until Done. Drain overlay
// character events first so the global Done event cannot duplicate input.
@@ -330,7 +467,7 @@ int main(int argc,char** argv){
close_keyboard(false);
}
for(auto i=views.begin();i!=views.end();){auto& v=*i->second;if(v.closed){overlays->ClearOverlayTexture(v.overlay);overlays->DestroyOverlay(v.overlay);if(v.thumbnail)overlays->DestroyOverlay(v.thumbnail);glDeleteTextures(2,v.textures);i=views.erase(i);}else ++i;}
bool hovered=anchor_ok&&overlays->IsHoverTargetOverlay(button);if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);const auto badge=notification_badge_count.load();if(icon_active!=menu_open||icon_hover!=hovered||icon_badge!=badge){icon_badge=badge;icon_active=menu_open;pixels=dock_icon(hovered,menu_open);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);}icon_hover=hovered;
bool hovered=anchor_ok&&overlays->IsHoverTargetOverlay(button);if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);const auto badge=notification_badge_count.load();if(icon_active!=(menu_open||launcher_open)||icon_hover!=hovered||icon_badge!=badge){icon_badge=badge;icon_active=menu_open||launcher_open;pixels=dock_icon(hovered,icon_active);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);}icon_hover=hovered;
auto sync=overlays->WaitFrameSync(8);
// A timeout already waited: never append another sleep to it. Bound retries
// only for immediate API failures, so a broken sync call cannot busy-spin.
+47
View File
@@ -0,0 +1,47 @@
{
"action_sets": [
{
"name": "/actions/entry",
"usage": "leftright"
}
],
"actions": [
{
"name": "/actions/entry/in/primary",
"type": "boolean"
},
{
"name": "/actions/entry/in/upper",
"type": "boolean"
},
{
"name": "/actions/entry/in/aim",
"type": "pose"
},
{
"name": "/actions/entry/in/close",
"type": "boolean"
},
{
"name": "/actions/entry/in/navigate",
"type": "vector2"
}
],
"default_bindings": [
{
"controller_type": "frame_controller",
"binding_url": "frame-controller.json"
}
],
"localization": [
{
"language_tag": "en_US",
"/actions/entry": "Framely launcher",
"/actions/entry/in/primary": "Primary trigger",
"/actions/entry/in/upper": "Upper trigger",
"/actions/entry/in/aim": "Pointer",
"/actions/entry/in/close": "Close launcher (B)",
"/actions/entry/in/navigate": "Launcher navigation"
}
]
}
+99
View File
@@ -0,0 +1,99 @@
{
"controller_type": "frame_controller",
"name": "Framely entry controls",
"bindings": {
"/actions/entry": {
"poses": [
{
"path": "/user/hand/left/pose/tip",
"output": "/actions/entry/in/aim"
},
{
"path": "/user/hand/right/pose/tip",
"output": "/actions/entry/in/aim"
}
],
"sources": [
{
"path": "/user/hand/left/input/trigger",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/entry/in/primary"
}
},
"parameters": {
"haptic_amplitude": "0",
"click_activate_threshold": "0.5",
"click_deactivate_threshold": "0.45"
}
},
{
"path": "/user/hand/left/input/bumper",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/entry/in/upper"
}
},
"parameters": {
"haptic_amplitude": "0"
}
},
{
"path": "/user/hand/right/input/trigger",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/entry/in/primary"
}
},
"parameters": {
"haptic_amplitude": "0",
"click_activate_threshold": "0.5",
"click_deactivate_threshold": "0.45"
}
},
{
"path": "/user/hand/right/input/bumper",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/entry/in/upper"
}
},
"parameters": {
"haptic_amplitude": "0"
}
},
{
"path": "/user/hand/right/input/b",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/entry/in/close"
}
}
},
{
"path": "/user/hand/left/input/thumbstick",
"mode": "joystick",
"inputs": {
"position": {
"output": "/actions/entry/in/navigate"
}
}
},
{
"path": "/user/hand/right/input/thumbstick",
"mode": "joystick",
"inputs": {
"position": {
"output": "/actions/entry/in/navigate"
}
}
}
]
}
}
}
+84
View File
@@ -0,0 +1,84 @@
#pragma once
#include <chrono>
#include <algorithm>
#include <cmath>
#include "openvr.h"
struct EntryTrigger {
bool held=false,ready=false;
std::chrono::steady_clock::time_point last{};
bool update(bool active,bool down,bool hit,std::chrono::steady_clock::time_point now){
if(!active){held=false;ready=false;return false;}
if(!ready){held=down;ready=true;return false;}
bool rising=down&&!held;held=down;
if(!rising||!hit||now-last<std::chrono::milliseconds(180))return false;
last=now;return true;
}
};
// Steam's laser supplies the actual tip hit and originating controller. Keep
// its point while the trigger locks the pointer, until an explicit focus leave.
struct EntryPointer {
unsigned device=~0u;bool inside=false;
void move(unsigned source,float x,float y){device=source;inside=x>=256&&x<=384&&y>=0&&y<=128;}
void leave(){device=~0u;inside=false;}
bool hit(unsigned source)const{return inside&&source!=~0u&&source==device;}
};
inline vr::HmdMatrix34_t entry_tip_pose(const vr::HmdMatrix34_t& tracked,const vr::HmdMatrix34_t& tip){
vr::HmdMatrix34_t out{};
for(int r=0;r<3;r++)for(int c=0;c<4;c++){
for(int k=0;k<3;k++)out.m[r][c]+=tracked.m[r][k]*tip.m[k][c];
if(c==3)out.m[r][c]+=tracked.m[r][3];
}
return out;
}
inline float entry_rotation_angle(const vr::HmdMatrix34_t& from,const vr::HmdMatrix34_t& to){
float trace=0;for(int r=0;r<3;r++)for(int c=0;c<3;c++)trace+=from.m[r][c]*to.m[r][c];
return std::acos(std::clamp((trace-1.f)*.5f,-1.f,1.f));
}
inline bool entry_trigger_pull(const vr::RenderModel_ComponentState_t& rest,const vr::RenderModel_ComponentState_t& full,const vr::RenderModel_ComponentState_t& current,float& pull){
float travel=entry_rotation_angle(rest.mTrackingToComponentRenderModel,full.mTrackingToComponentRenderModel);
if(!std::isfinite(travel)||travel<.001f)return false;
pull=std::clamp(entry_rotation_angle(rest.mTrackingToComponentRenderModel,current.mTrackingToComponentRenderModel)/travel,0.f,1.f);return std::isfinite(pull);
}
struct EntryAnalogTrigger {
bool held=false;
bool update(float pull){held=std::isfinite(pull)&&pull>=(held?.45f:.5f);return held;}
};
// The press that opens a modal surface must not dismiss it as an outside click.
struct LauncherDismissGuard {
std::chrono::steady_clock::time_point opened_at{};bool released=false;
void opened(std::chrono::steady_clock::time_point now){opened_at=now;released=false;}
void observe(bool held){if(!held)released=true;}
bool allows(std::chrono::steady_clock::time_point now,bool held,float event_age=0){
observe(held);
return released&&now-opened_at>=std::chrono::milliseconds(300)&&
event_age>=0&&std::chrono::duration<float>(now-opened_at).count()>=event_age;
}
};
// One axis per gesture, with a dead zone and neutral re-arm. A diagonal never
// flips a category and a page together, even if its dominant axis changes.
struct LauncherStick {
bool ready=false,latched=false;
int update(float x,float y,bool active){
if(!active||!std::isfinite(x)||!std::isfinite(y)){ready=false;latched=false;return 0;}
float strength=std::max(std::abs(x),std::abs(y));
if(strength<.3f){ready=true;latched=false;return 0;}
if(!ready||latched||strength<.65f)return 0;
latched=true;
return std::abs(x)>=std::abs(y)?(x>0?1:-1):(y>0?2:-2);
}
};
inline void launcher_rotation_vector(const vr::HmdMatrix34_t& rest,const vr::HmdMatrix34_t& value,float out[3]){
float m[3][3]{};for(int r=0;r<3;r++)for(int c=0;c<3;c++)for(int k=0;k<3;k++)m[r][c]+=rest.m[k][r]*value.m[k][c];
out[0]=m[2][1]-m[1][2];out[1]=m[0][2]-m[2][0];out[2]=m[1][0]-m[0][1];
}
inline bool launcher_stick_axes(const vr::RenderModel_ComponentState_t& rest,const vr::RenderModel_ComponentState_t& full_x,const vr::RenderModel_ComponentState_t& full_y,const vr::RenderModel_ComponentState_t& current,float& x,float& y){
float a[3],b[3],v[3];launcher_rotation_vector(rest.mTrackingToComponentRenderModel,full_x.mTrackingToComponentRenderModel,a);launcher_rotation_vector(rest.mTrackingToComponentRenderModel,full_y.mTrackingToComponentRenderModel,b);launcher_rotation_vector(rest.mTrackingToComponentRenderModel,current.mTrackingToComponentRenderModel,v);
float aa=0,ab=0,bb=0,av=0,bv=0;for(int i=0;i<3;i++){aa+=a[i]*a[i];ab+=a[i]*b[i];bb+=b[i]*b[i];av+=a[i]*v[i];bv+=b[i]*v[i];}
float det=aa*bb-ab*ab;if(det<.00001f)return false;
x=std::clamp((av*bb-bv*ab)/det,-1.f,1.f);y=std::clamp((bv*aa-av*ab)/det,-1.f,1.f);return std::isfinite(x)&&std::isfinite(y);
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <chrono>
#include <cmath>
// Keep the initial hit during ordinary laser jitter, including a long press.
// A deliberate move or an abandoned hold releases the lock.
struct LauncherPointer {
using Time=std::chrono::steady_clock::time_point;
bool active=false,cancelled=false;int x=0,y=0;Time pressed{};
void begin(int px,int py,Time now){active=true;cancelled=false;x=px;y=py;pressed=now;}
bool holding(Time now)const{return active&&!cancelled&&now-pressed<=std::chrono::milliseconds(2500);}
bool constrain(int& px,int& py,Time now){
if(!active)return false;
if(!holding(now)||std::hypot(float(px-x),float(py-y))>36.f)cancelled=true;
if(cancelled)return false;
px=x;py=y;return true;
}
void clear(){active=false;cancelled=false;}
};
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <vector>
// Each alternating GPU texture retains its own pending damage until uploaded.
// Coordinates match the vertically flipped CPU buffer, not CEF's top-left view.
struct PaintDamage {
int x=0,y=0,width=0,height=0;
bool empty() const{return width<=0||height<=0;}
void clear(){*this={};}
void include(int left,int top,int right,int bottom){
if(right<=left||bottom<=top)return;
if(!empty()){right=std::max(right,x+width);bottom=std::max(bottom,y+height);left=std::min(left,x);top=std::min(top,y);}
x=left;y=top;width=right-left;height=bottom-top;
}
template<class Rect> void add(int w,int h,size_t count,const Rect* rects){
if(!count||!rects){include(0,0,w,h);return;}
for(size_t i=0;i<count;i++){
const auto& r=rects[i];const int left=std::clamp(r.x,0,w),top=std::clamp(r.y,0,h);
const int right=int(std::clamp<long long>(static_cast<long long>(r.x)+r.width,0,w)),bottom=int(std::clamp<long long>(static_cast<long long>(r.y)+r.height,0,h));
if(right>left&&bottom>top)include(left,h-bottom,right,h-top);
}
}
};
// CEF dirty rectangles use physical pixels. Preserve unchanged pixels between
// paints, and flip only the copied rows for OpenVR's bottom-left texture origin.
template<class Rect> inline void merge_paint(std::vector<uint8_t>& pixels,const void* buffer,int width,int height,size_t count,const Rect* dirty,bool flip=false){
const size_t bytes=size_t(width)*height*4;const auto* source=static_cast<const uint8_t*>(buffer);
auto copy=[&](int left,int top,int right,int bottom){for(int y=top;y<bottom;y++)std::copy_n(source+(size_t(y)*width+left)*4,size_t(right-left)*4,pixels.data()+(size_t(flip?height-1-y:y)*width+left)*4);};
if(pixels.size()!=bytes||!count||!dirty){pixels.resize(bytes);copy(0,0,width,height);return;}
for(size_t i=0;i<count;i++){const auto& r=dirty[i];int left=std::clamp(r.x,0,width),top=std::clamp(r.y,0,height),right=int(std::clamp<long long>(static_cast<long long>(r.x)+r.width,0,width)),bottom=int(std::clamp<long long>(static_cast<long long>(r.y)+r.height,0,height));if(right>left&&bottom>top)copy(left,top,right,bottom);}
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <algorithm>
#include <cmath>
inline int browser_frame_rate(float refresh){
// Preserve smooth UI on an unknown display, with a bounded update budget.
return std::isfinite(refresh)&&refresh>0?std::clamp(int(std::round(std::clamp(refresh,60.f,144.f))),60,144):90;
}
+3 -1
View File
@@ -1,3 +1,5 @@
export * from './launcher';
import {launchContext} from './launcher';
export * from './visibility';
import {createVisibilityApi, type UiVisibility} from './visibility';
export * from './lifecycle';
@@ -9,7 +11,7 @@ export interface Notification {id:string;title:string;body:string;image?:string;
type Bridge={request:(op:string,p?:unknown)=>Promise<any>;subscribe:(f:(e:unknown)=>void)=>()=>void};
function bridge():Bridge{const b=(window as any).__framelyBridge;if(!b)throw new Error('This page requires the Framely plugin host');return b;}
export const framely={
ui:{...createVisibilityApi(bridge),close:():Promise<unknown>=>bridge().request('ui.close')},
ui:{launchContext,...createVisibilityApi(bridge),close:():Promise<unknown>=>bridge().request('ui.close')},
language:{get:():Promise<{preference:string;language:string}>=>bridge().request('language.get')},
call:<T=unknown>(method:string,params:unknown={}):Promise<T>=>bridge().request('call',{method,params}),
windows:{open:(window:string)=>bridge().request('window.open',{window}),close:(window:string)=>bridge().request('window.close',{window})},
+9
View File
@@ -0,0 +1,9 @@
export interface LaunchContext {source:'launcher'|'quickPanel'|'manager';trigger:'shortPress'|'menuAction';actionId?:string}
type Bridge={request:(op:string,p?:unknown)=>Promise<any>;subscribe:(f:(e:any)=>void)=>()=>void};
function bridge():Bridge{const b=(window as any).__framelyBridge;if(!b)throw new Error('This script requires the Framely plugin host');return b;}
export const launchContext={get:():Promise<LaunchContext>=>bridge().request('ui.launchContext'),onChanged:(callback:(context:LaunchContext)=>void)=>bridge().subscribe(e=>{if(e.type==='ui.launch')callback(e.data);})};
/** Register callbacks in a separate bundle named by a frontend launcher action. */
export function registerLauncherActions(actions:Record<string,(context:LaunchContext)=>unknown|Promise<unknown>>){
let busy=false;const off=bridge().subscribe(async e=>{if(e.type!=='launcher.action'||busy)return;busy=true;try{const context=e.data as LaunchContext;const action=actions[context.actionId??''];if(!action)throw new Error('Launcher action handler not registered');await action(context);await bridge().request('launcher.done');}catch(error){await bridge().request('launcher.done',{error:String(error)});}finally{busy=false;}});
void bridge().request('launcher.ready').catch(console.error);window.addEventListener('pagehide',off,{once:true});return off;
}
+476
View File
@@ -0,0 +1,476 @@
//! User-session applications: XDG desktop entries and installed Lepton packages.
use anyhow::{ensure, Context, Result};
use serde::Serialize;
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
process::Stdio,
};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct App {
pub id: String,
pub name: String,
pub kind: &'static str,
pub icon: Option<String>,
#[serde(skip)]
target: Target,
}
#[derive(Clone, Debug)]
enum Target {
Desktop(PathBuf),
Lepton {
runner: PathBuf,
context: String,
package: String,
},
}
fn entries(input: &str) -> BTreeMap<String, String> {
let mut active = false;
let mut out = BTreeMap::new();
for line in input.lines() {
let line = line.trim();
if line.starts_with('[') {
active = line == "[Desktop Entry]";
continue;
}
if active && !line.starts_with('#') {
if let Some((k, v)) = line.split_once('=') {
out.insert(k.trim().into(), v.trim().into());
}
}
}
out
}
fn data_home(home: &Path) -> PathBuf {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.unwrap_or_else(|| home.join(".local/share"))
}
fn data_dirs(home: &Path) -> Vec<PathBuf> {
let mut dirs = vec![data_home(home)];
dirs.extend(
std::env::var("XDG_DATA_DIRS")
.unwrap_or_else(|_| "/usr/local/share:/usr/share".into())
.split(':')
.map(PathBuf::from)
.filter(|p| p.is_absolute()),
);
for p in [
home.join(".local/share/flatpak/exports/share"),
PathBuf::from("/var/lib/flatpak/exports/share"),
] {
if !dirs.contains(&p) {
dirs.push(p);
}
}
dirs
}
fn icon_file(path: &Path) -> Option<String> {
let bytes = fs::read(path).ok()?;
if bytes.len() > 1024 * 1024 {
return None;
}
let mime = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
"image/jpeg"
} else if path.extension().is_some_and(|e| e == "svg")
&& std::str::from_utf8(&bytes).ok()?.contains("<svg")
{
"image/svg+xml"
} else {
return None;
};
use base64::Engine;
Some(format!(
"data:{mime};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
))
}
fn icon(home: &Path, name: &str, dirs: &[PathBuf]) -> Option<String> {
let path = Path::new(name);
if path.is_absolute() {
return icon_file(path);
}
if name.contains('/') || name.is_empty() {
return None;
}
for base in std::iter::once(home.join(".icons")).chain(dirs.iter().map(|d| d.join("icons"))) {
for theme in ["hicolor", "breeze", "breeze-dark", "Adwaita"] {
for size in ["256x256", "128x128", "64x64", "48x48", "scalable", "32x32"] {
for ext in ["png", "svg"] {
if let Some(icon) = icon_file(
&base
.join(theme)
.join(size)
.join("apps")
.join(format!("{name}.{ext}")),
) {
return Some(icon);
}
}
}
}
}
dirs.iter().find_map(|d| {
["png", "svg", "xpm"]
.iter()
.find_map(|ext| icon_file(&d.join("pixmaps").join(format!("{name}.{ext}"))))
})
}
fn files(base: &Path, dir: &Path, depth: usize, out: &mut Vec<(String, PathBuf)>) {
if depth > 8 {
return;
}
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let p = entry.path();
if entry.file_type().is_ok_and(|t| t.is_dir()) {
files(base, &p, depth + 1, out);
} else if p.extension().is_some_and(|e| e == "desktop") {
if let Ok(relative) = p.strip_prefix(base) {
out.push((relative.to_string_lossy().replace('/', "-"), p));
}
}
}
}
}
fn desktop_apps(home: &Path, dirs: &[PathBuf], steam: &BTreeSet<u32>) -> Vec<App> {
let mut seen = BTreeSet::new();
let mut apps = Vec::new();
let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
for dir in dirs {
let base = dir.join("applications");
let mut candidates = Vec::new();
files(&base, &base, 0, &mut candidates);
candidates.sort();
for (id, path) in candidates {
if !seen.insert(id.clone()) {
continue;
}
let Ok(content) = fs::read_to_string(&path) else {
continue;
};
if content.len() > 128 * 1024 {
continue;
}
let v = entries(&content);
let get = |k: &str| v.get(k).map(String::as_str).unwrap_or("");
if get("Type") != "Application"
|| get("Hidden") == "true"
|| get("NoDisplay") == "true"
|| get("Exec").is_empty()
{
continue;
}
let matches = |list: &str| {
list.split(';')
.any(|item| !item.is_empty() && desktops.split(':').any(|d| d == item))
};
if !get("OnlyShowIn").is_empty() && !matches(get("OnlyShowIn"))
|| matches(get("NotShowIn"))
{
continue;
}
if !get("TryExec").is_empty() {
let t = Path::new(get("TryExec"));
let exists = if t.is_absolute() {
t.is_file()
} else {
std::env::var_os("PATH").is_some_and(|paths| {
std::env::split_paths(&paths).any(|d| d.join(t).is_file())
})
};
if !exists {
continue;
}
}
// Steam already owns its game shortcuts and helper runtimes.
if get("Exec").split_whitespace().any(|word| {
word.trim_matches('"')
.strip_prefix("steam://rungameid/")
.and_then(|s| s.parse::<u32>().ok())
.is_some_and(|id| steam.contains(&id))
}) || id == "valve-steamvr.desktop"
|| id == "Lepton Development.desktop"
{
continue;
}
let locale = std::env::var("LC_MESSAGES")
.or_else(|_| std::env::var("LANG"))
.unwrap_or_default();
let locale = locale.split('.').next().unwrap_or("");
let language = locale.split('_').next().unwrap_or("");
let name = v
.get(&format!("Name[{locale}]"))
.or_else(|| v.get(&format!("Name[{language}]")))
.or_else(|| v.get("Name"))
.filter(|n| !n.is_empty());
let Some(name) = name else {
continue;
};
apps.push(App {
id,
name: name.clone(),
kind: "desktop",
icon: icon(home, get("Icon"), dirs),
target: Target::Desktop(path),
});
}
}
apps
}
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
let rest = tag.split_once(&format!("{name}=\""))?.1;
Some(rest.split_once('"')?.0)
}
fn safe_component(s: &str) -> bool {
!s.is_empty()
&& !s.starts_with('-')
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
}
fn lepton_apps(home: &Path) -> Vec<App> {
let runner = [
home.join(".local/share/Steam/steamapps/common/Lepton/lepton"),
home.join(".steam/steam/steamapps/common/Lepton/lepton"),
]
.into_iter()
.find(|p| p.is_file());
let Some(runner) = runner else {
return Vec::new();
};
let mut apps = Vec::new();
let root = data_home(home).join("lepton/contexts");
let Ok(contexts) = fs::read_dir(root) else {
return apps;
};
for context in contexts.flatten() {
let name = context.file_name().to_string_lossy().into_owned();
if !safe_component(&name) || name.starts_with("steamlaunch-") {
continue;
}
let data = context.path().join("baked/data_overlay");
let Ok(xml) = fs::read_to_string(data.join("system/packages.xml")) else {
continue;
};
if xml.len() > 8 * 1024 * 1024 {
continue;
}
for tag in xml.split('<').filter(|t| t.starts_with("package ")) {
let Some(package) = attr(tag, "name").filter(|s| safe_component(s)) else {
continue;
};
let Some(code) = attr(tag, "codePath").and_then(|s| s.strip_prefix("/data/")) else {
continue;
};
// Android system packages are not launcher apps. A removed package's stale
// metadata is ignored; validate that its installed APK is still present.
let path = data.join(code);
if code.split('/').any(|s| s == "..") || !path.join("base.apk").is_file() {
continue;
}
let icon = icon_file(
&data
.join("system_ce/0/shortcut_service/bitmaps")
.join(format!("{package}.png")),
);
apps.push(App {
id: format!("{name}/{package}"),
name: package.rsplit('.').next().unwrap_or(package).into(),
kind: "lepton",
icon,
target: Target::Lepton {
runner: runner.clone(),
context: name.clone(),
package: package.into(),
},
});
}
}
apps
}
pub fn discover(home: &Path) -> Vec<App> {
let ids = crate::steam::discover(home)
.into_iter()
.map(|a| a.id)
.collect();
let mut apps = desktop_apps(home, &data_dirs(home), &ids);
apps.extend(lepton_apps(home));
apps.sort_by(|a, b| a.name.cmp(&b.name));
apps
}
pub fn launch(home: &Path, id: &str) -> Result<()> {
let app = discover(home)
.into_iter()
.find(|a| format!("{}:{}", a.kind, a.id) == id)
.context("Application is no longer installed")?;
launch_target(app.target)
}
fn launch_target(target: Target) -> Result<()> {
match target {
Target::Desktop(path) => {
let output = crate::process::tool("gio")
.arg("launch")
.arg(path)
.stdin(Stdio::null())
.output()
.context("Cannot launch desktop application")?;
ensure!(
output.status.success(),
"Desktop launch failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Target::Lepton {
runner,
context,
package,
} => {
// Use the same session-owned context and Android launcher intent; never
// reinstall the APK or accept an arbitrary shell command from the UI.
let running = crate::process::tool("podman")
.args([
"inspect",
"--format",
"{{.State.Running}}",
&format!("lepton-{context}"),
])
.stdin(Stdio::null())
.output()
.is_ok_and(|o| {
o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true"
});
let child = if running {
None
} else {
Some(
std::process::Command::new(&runner)
.args(["start", &context])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("Cannot start Lepton context")?,
)
};
let start = std::time::Instant::now();
let mut ready = false;
while start.elapsed() < std::time::Duration::from_secs(35) {
let output = crate::process::tool("podman")
.args([
"exec",
&format!("lepton-{context}"),
"monkey",
"-p",
&package,
"-c",
"android.intent.category.LAUNCHER",
"1",
])
.stdin(Stdio::null())
.output();
if output.is_ok_and(|o| o.status.success()) {
ready = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if let Some(mut child) = child {
std::thread::spawn(move || {
let _ = child.wait();
});
}
ensure!(
ready,
"Lepton application did not become ready; retry after the context starts"
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xdg_priority_hidden_overrides_and_steam_duplicates() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("first");
let b = dir.path().join("second");
for p in [&a, &b] {
fs::create_dir_all(p.join("applications")).unwrap();
}
fs::write(
a.join("applications/hidden.desktop"),
"[Desktop Entry]\nHidden=true",
)
.unwrap();
fs::write(
b.join("applications/hidden.desktop"),
"[Desktop Entry]\nType=Application\nName=Hidden\nExec=test",
)
.unwrap();
fs::write(
a.join("applications/game.desktop"),
"[Desktop Entry]\nType=Application\nName=Game\nExec=steam steam://rungameid/42",
)
.unwrap();
fs::write(a.join("applications/terminal.desktop"),"[Other]\nName=Wrong\n[Desktop Entry]\nType=Application\nName=Terminal\nExec=terminal %U\nTerminal=true").unwrap();
let apps = desktop_apps(dir.path(), &[a, b], &BTreeSet::from([42]));
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].id, "terminal.desktop");
}
#[test]
fn lepton_only_installed_third_party_packages() {
let dir = tempfile::tempdir().unwrap();
let runner = dir
.path()
.join(".local/share/Steam/steamapps/common/Lepton");
fs::create_dir_all(&runner).unwrap();
fs::write(runner.join("lepton"), "").unwrap();
let data = dir
.path()
.join(".local/share/lepton/contexts/test/baked/data_overlay");
fs::create_dir_all(data.join("system")).unwrap();
fs::create_dir_all(data.join("app/example")).unwrap();
fs::write(data.join("app/example/base.apk"), "apk").unwrap();
fs::write(data.join("system/packages.xml"),"<packages><package name=\"com.example.app\" codePath=\"/data/app/example\"/><package name=\"com.android.system\" codePath=\"/system/app/system\"/><package name=\"com.removed.app\" codePath=\"/data/app/missing\"/></packages>").unwrap();
let apps = lepton_apps(dir.path());
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].id, "test/com.example.app");
}
#[test]
fn launch_handoffs_use_literal_arguments_and_reuse_live_lepton_context() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let tools = dir.path().join("tools");
fs::create_dir(&tools).unwrap();
for name in ["gio", "podman"] {
fs::write(tools.join(name),format!("#!/bin/sh\nif [ \"$1\" = inspect ]; then echo true; exit 0; fi\nprintf '%s\\n' \"$@\" > '{}'/{}-args\n",dir.path().display(),name)).unwrap();
fs::set_permissions(tools.join(name), fs::Permissions::from_mode(0o755)).unwrap();
}
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = Some(tools));
let path = dir.path().join("app; literal.desktop");
launch_target(Target::Desktop(path.clone())).unwrap();
assert_eq!(
fs::read_to_string(dir.path().join("gio-args")).unwrap(),
format!("launch\n{}\n", path.display())
);
launch_target(Target::Lepton {
runner: dir.path().join("must-not-start"),
context: "existing".into(),
package: "com.example.app".into(),
})
.unwrap();
assert_eq!(fs::read_to_string(dir.path().join("podman-args")).unwrap(),"exec\nlepton-existing\nmonkey\n-p\ncom.example.app\n-c\nandroid.intent.category.LAUNCHER\n1\n");
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = None);
}
#[test]
fn unknown_id_never_launches() {
let dir = tempfile::tempdir().unwrap();
assert!(launch(dir.path(), "desktop:../../arbitrary").is_err());
}
}
+57
View File
@@ -0,0 +1,57 @@
use anyhow::{ensure, Context, Result};
use serde_json::Value;
use std::{path::Path, process::Command};
pub fn collect(state: &Path) -> Result<Value> {
let output = Command::new("python3")
.args([
"-c",
include_str!("../tools/export-diagnostics.py"),
"--state",
])
.arg(state)
.output()
.context("Could not collect diagnostics")?;
ensure!(
output.status.success(),
"Diagnostic export failed: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).context("Invalid diagnostic archive")
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{engine::general_purpose::STANDARD, Engine};
use std::io::{Cursor, Read};
#[test]
fn exports_without_a_running_daemon_and_with_corrupt_state() {
let state = tempfile::tempdir().unwrap();
std::fs::create_dir(state.path().join("logs")).unwrap();
std::fs::write(state.path().join("state.json"), "corrupt").unwrap();
std::fs::write(
state.path().join("logs/demo.log"),
"useful error\npassword=not-public",
)
.unwrap();
let result = collect(state.path()).unwrap();
let bytes = STANDARD.decode(result["data"].as_str().unwrap()).unwrap();
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut log = String::new();
archive
.by_name("logs/demo.log")
.unwrap()
.read_to_string(&mut log)
.unwrap();
assert!(log.contains("useful error"));
assert!(!log.contains("not-public"));
assert!(archive.by_name("report.json").is_ok());
assert!(archive.by_name("renderer/gpu.json").is_ok());
assert_eq!(
std::fs::read_to_string(state.path().join("state.json")).unwrap(),
"corrupt"
);
}
}
+28
View File
@@ -19,6 +19,12 @@ pub fn write(stream: &mut impl Write, v: &Value) -> Result<()> {
stream.flush()?;
Ok(())
}
pub(crate) fn response(result: Result<Value>) -> Value {
match result {
Ok(v) => json!({"result":v}),
Err(e) => json!({"error":format!("{e:#}")}),
}
}
pub fn call(path: &Path, method: &str, params: Value) -> Result<Value> {
call_timeout(path, method, params, Duration::from_secs(90))
}
@@ -38,6 +44,28 @@ pub fn call_timeout(path: &Path, method: &str, params: Value, timeout: Duration)
mod tests {
use super::*;
#[test]
fn service_errors_keep_the_cause_across_ipc() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("service.sock");
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
let worker = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let request = read(&mut stream).unwrap();
assert_eq!(request["method"], "install.batch");
let result = Err(anyhow::anyhow!("Permission denied (os error 13)"))
.context("安装插件 test.plugin 失败")
.context("批量安装失败,已恢复原版本和启用状态");
write(&mut stream, &response(result)).unwrap();
});
let error = call(&socket, "install.batch", json!({})).unwrap_err();
let message = error.to_string();
assert!(message.contains("批量安装失败,已恢复原版本和启用状态"));
assert!(message.contains("test.plugin"));
assert!(message.contains("Permission denied (os error 13)"));
worker.join().unwrap();
}
#[test]
fn package_messages_can_exceed_previous_transport_limit() {
let request = json!({"method":"install","params":{"package":"A".repeat(97 * 1024 * 1024)}});
+26
View File
@@ -1,4 +1,6 @@
mod auth;
mod desktop;
mod diagnostics;
mod http;
mod ipc;
mod jobs;
@@ -11,6 +13,7 @@ mod recovery;
mod relations;
mod service;
mod session;
mod steam;
mod subscriptions;
#[cfg(test)]
mod tests;
@@ -31,6 +34,13 @@ struct Cli {
}
#[derive(Subcommand)]
enum Cmd {
/// Export logs even when the manager service is unavailable.
ExportLogs {
#[arg(long, default_value = "/var/lib/framely")]
state: PathBuf,
#[arg(long)]
output: PathBuf,
},
/// Disable and uninstall every plugin before removing the manager.
PrepareUninstall {
#[arg(long)]
@@ -129,6 +139,20 @@ enum Cmd {
}
fn run() -> Result<()> {
match Cli::parse().command {
Cmd::ExportLogs { state, output } => {
let archive = diagnostics::collect(&state)?;
let bytes = STANDARD.decode(archive["data"].as_str().context("Missing archive")?)?;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&output)?;
file.write_all(&bytes)?;
println!("{}", output.display());
Ok(())
}
Cmd::PrepareUninstall { approve, socket } => {
anyhow::ensure!(unsafe { libc::geteuid() } == 0, "Uninstall requires root");
let result = ipc::call_timeout(
@@ -286,6 +310,8 @@ fn run() -> Result<()> {
documentation_url: v.manifest.documentation_url,
homepage: v.manifest.homepage,
api_version: v.manifest.api_version,
engines: v.manifest.engines,
compatible: None,
url: v.manifest.download_url.unwrap_or(format!(
"{}/{}",
base_url.trim_end_matches('/'),
+190 -1
View File
@@ -1,4 +1,4 @@
use anyhow::{bail, ensure, Result};
use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Component, Path};
@@ -116,9 +116,105 @@ fn window_width_meters() -> Option<f32> {
Some(3.0)
}
pub const LAUNCHER_VERSION: &str = "0.4.3-preview.2";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Engines {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub framely: Option<String>,
}
impl Engines {
pub fn validate(&self) -> Result<()> {
if let Some(r) = &self.framely {
crate::relations::requirement(r)?;
}
Ok(())
}
pub fn matches(&self, version: &str) -> bool {
self.framely.as_ref().is_none_or(|r| {
crate::relations::requirement(r)
.ok()
.zip(semver::Version::parse(version).ok())
.is_some_and(|(r, v)| r.matches(&v))
})
}
pub fn check(&self) -> Result<()> {
ensure!(
self.matches(env!("CARGO_PKG_VERSION")),
"Plugin requires Framely {}; current version is {}",
self.framely.as_deref().unwrap_or("*"),
env!("CARGO_PKG_VERSION")
);
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
pub enum LaunchTarget {
QuickPage,
Window { window: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
pub enum LauncherActionTarget {
Window {
window: String,
},
Backend {
method: String,
#[serde(default)]
params: serde_json::Value,
},
Frontend {
entry: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LauncherAction {
pub id: String,
pub label: String,
pub target: LauncherActionTarget,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LauncherSettings {
#[serde(default)]
pub primary_trigger: bool,
#[serde(default = "yes")]
pub menu_auto_close: bool,
#[serde(default = "menu_timeout")]
pub menu_timeout_seconds: u32,
}
fn menu_timeout() -> u32 {
10
}
impl Default for LauncherSettings {
fn default() -> Self {
Self {
primary_trigger: false,
menu_auto_close: true,
menu_timeout_seconds: 10,
}
}
}
impl LauncherSettings {
pub fn validate(&self) -> Result<()> {
ensure!(
(1..=300).contains(&self.menu_timeout_seconds),
"Menu timeout must be between 1 and 300 seconds"
);
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Ui {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub launch: BTreeMap<String, LaunchTarget>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub launcher_actions: Vec<LauncherAction>,
pub quick_page: Option<String>,
#[serde(default)]
pub windows: BTreeMap<String, Window>,
@@ -138,6 +234,8 @@ pub struct Publish {
pub struct Manifest {
pub schema_version: u32,
pub api_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engines: Option<Engines>,
pub id: String,
pub name: String,
pub version: String,
@@ -197,6 +295,9 @@ impl Manifest {
self.schema_version == 1 && self.api_version == API_VERSION,
"Unsupported manifest/API version"
);
if let Some(e) = &self.engines {
e.validate()?;
}
valid_id(&self.id)?;
self.relations().validate(&self.id, &self.version)?;
for link in self
@@ -318,6 +419,81 @@ impl Manifest {
if let Some(p) = &self.ui.quick_page {
check(p)?;
}
if !self.ui.launch.is_empty() || !self.ui.launcher_actions.is_empty() {
let range = self
.engines
.as_ref()
.and_then(|e| e.framely.as_ref())
.context("Launcher features require engines.framely")?;
let requirement = crate::relations::requirement(range)?;
// Require an explicit floor so older hosts cannot be included accidentally.
let floor = semver::Version::parse(LAUNCHER_VERSION)?;
ensure!(
requirement.comparators.iter().any(|c| {
matches!(
c.op,
semver::Op::Greater
| semver::Op::GreaterEq
| semver::Op::Exact
| semver::Op::Caret
| semver::Op::Tilde
) && c.minor.is_some()
&& c.patch.is_some()
&& semver::Version {
major: c.major,
minor: c.minor.unwrap(),
patch: c.patch.unwrap(),
pre: c.pre.clone(),
build: semver::BuildMetadata::EMPTY,
} >= floor
}),
"Launcher features require an explicit version floor of {} or newer",
LAUNCHER_VERSION
);
}
for (source, target) in &self.ui.launch {
ensure!(
["launcher", "quickPanel", "manager"].contains(&source.as_str()),
"Unknown launch source"
);
match target {
LaunchTarget::QuickPage => {
ensure!(self.ui.quick_page.is_some(), "No quick page declared")
}
LaunchTarget::Window { window } => ensure!(
self.ui.windows.contains_key(window),
"Launch window not declared"
),
}
}
ensure!(
self.ui.launcher_actions.len() <= 16,
"Too many launcher actions"
);
let mut action_ids = std::collections::BTreeSet::new();
for action in &self.ui.launcher_actions {
valid_id(&action.id)?;
ensure!(
action_ids.insert(&action.id)
&& !action.label.is_empty()
&& action.label.len() <= 120,
"Invalid launcher action"
);
match &action.target {
LauncherActionTarget::Window { window } => ensure!(
self.ui.windows.contains_key(window),
"Action window not declared"
),
LauncherActionTarget::Frontend { entry } => check(entry)?,
LauncherActionTarget::Backend { method, .. } => ensure!(
self.backend.is_some()
&& !method.is_empty()
&& method.len() <= 120
&& !method.starts_with("framely."),
"Invalid backend action method"
),
}
}
ensure!(self.ui.windows.len() <= 8, "Too many declared windows");
for (key, w) in &self.ui.windows {
valid_id(key)?;
@@ -591,6 +767,12 @@ pub struct Database {
pub notification_inbox: BTreeMap<String, NotificationEntry>,
#[serde(default)]
pub notification_settings: NotificationSettings,
#[serde(default)]
pub launcher: LauncherSettings,
#[serde(default)]
pub steam_favorites: std::collections::BTreeSet<u32>,
#[serde(default)]
pub launcher_order: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -773,7 +955,11 @@ pub struct CatalogEntry {
pub documentation_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compatible: Option<bool>,
pub api_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engines: Option<Engines>,
pub url: String,
pub sha256: String,
#[serde(default)]
@@ -820,6 +1006,9 @@ impl CatalogEntry {
}
pub fn validate(&self, allow_http: bool) -> Result<()> {
if let Some(e) = &self.engines {
e.validate()?;
}
valid_id(&self.id)?;
self.relations().validate(&self.id, &self.version)?;
for link in self
+27 -9
View File
@@ -66,10 +66,13 @@ impl Resolver<'_> {
fn matching_catalog(&mut self, source: &Source, id: &str, dep: &Dependency) -> Result<Catalog> {
let mut catalog = self.catalog(source)?;
if catalog.plugins.iter().any(|p| p.id == id)
&& !catalog
.plugins
.iter()
.any(|p| p.id == id && dep.matches(&p.version).unwrap_or(false))
&& !catalog.plugins.iter().any(|p| {
p.id == id
&& p.engines
.as_ref()
.is_none_or(|e| e.matches(env!("CARGO_PKG_VERSION")))
&& dep.matches(&p.version).unwrap_or(false)
})
&& !self.histories.contains(&(source.id.clone(), id.to_owned()))
{
let history = crate::session::fetch_plugin_versions_cancel(
@@ -141,10 +144,13 @@ impl Resolver<'_> {
for source in sources.into_iter().filter(|s| s.enabled) {
match self.matching_catalog(&source, id, dep) {
Ok(c) => {
if c.plugins
.iter()
.any(|p| p.id == id && dep.matches(&p.version).unwrap_or(false))
{
if c.plugins.iter().any(|p| {
p.id == id
&& p.engines
.as_ref()
.is_none_or(|e| e.matches(env!("CARGO_PKG_VERSION")))
&& dep.matches(&p.version).unwrap_or(false)
}) {
let preferred =
selected.is_none() && Some(source.id.as_str()) == origin;
candidates.push(source);
@@ -184,7 +190,13 @@ impl Resolver<'_> {
let entry = catalog
.plugins
.iter()
.find(|p| p.id == id && dep.matches(&p.version).unwrap_or(false))
.find(|p| {
p.id == id
&& p.engines
.as_ref()
.is_none_or(|e| e.matches(env!("CARGO_PKG_VERSION")))
&& dep.matches(&p.version).unwrap_or(false)
})
.context("指定源中不存在依赖")?;
ensure!(
dep.matches(&entry.version)?,
@@ -197,6 +209,9 @@ impl Resolver<'_> {
|_, _| self.cancel.check(),
)?;
let manifest = bytes.manifest()?;
if let Some(e) = &manifest.engines {
e.check()?;
}
ensure!(
manifest.id == entry.id && manifest.version == entry.version,
"依赖包 ID 或版本与目录不一致"
@@ -299,6 +314,9 @@ pub fn prepare_staged(
) -> Result<Prepared> {
cancel.check()?;
let manifest = bytes.manifest()?;
if let Some(e) = &manifest.engines {
e.check()?;
}
let root = manifest.id.clone();
if let Some(source) = &source {
ensure!(db.sources.iter().any(|s| &s.id == source), "未知安装来源");
+1 -1
View File
@@ -29,7 +29,7 @@ pub struct ExitInfo {
}
#[cfg(test)]
thread_local! { pub static TEST_TOOLS: std::cell::RefCell<Option<std::path::PathBuf>> = const { std::cell::RefCell::new(None) }; }
fn tool(name: &str) -> Command {
pub(crate) fn tool(name: &str) -> Command {
#[cfg(test)]
if let Some(root) = TEST_TOOLS.with(|p| p.borrow().clone()) {
return Command::new(root.join(name));
+198 -9
View File
@@ -45,6 +45,7 @@ pub struct Service {
batching: bool,
visibility: crate::visibility::Tracker,
visibility_pending: BTreeSet<String>,
launch_contexts: BTreeMap<String, Value>,
}
impl Service {
pub fn prepare_uninstall(&mut self) -> Result<Value> {
@@ -95,6 +96,20 @@ impl Service {
Database::default()
};
db.update_check.validate()?;
db.launcher.validate()?;
for p in db.plugins.values_mut() {
if let Some(e) = &p.manifest.engines {
if let Err(error) = e.check() {
p.error = Some(error.to_string());
} else if p
.error
.as_deref()
.is_some_and(|v| v.starts_with("Plugin requires Framely "))
{
p.error = None;
}
}
}
if db.update_source.is_none() {
db.update_source = fs::read(root.join("update-source.json"))
.ok()
@@ -185,6 +200,7 @@ impl Service {
batching: false,
visibility: Default::default(),
visibility_pending: BTreeSet::new(),
launch_contexts: BTreeMap::new(),
};
if initialized_defaults {
service.save()?;
@@ -207,6 +223,9 @@ impl Service {
fn active(&self, id: &str) -> Result<&Installed> {
ensure!(self.agreement_accepted(), "请先同意用户协议和隐私声明");
let p = self.plugin(id)?;
if let Some(e) = &p.manifest.engines {
e.check()?;
}
ensure!(
p.enabled && !self.db.safe_mode,
"Plugin disabled or safe mode enabled"
@@ -264,7 +283,7 @@ impl Service {
previous: Option<&str>,
extra: Value,
) -> Value {
json!({"pluginId":m.id,"phase":phase,"reason":reason,"version":m.version,"previousVersion":previous,"dataDir":self.root.join("data").join(&m.id).join(match m.run_as(){RunAs::Steamos=>"steamos",RunAs::Root=>"root"}),"exit":extra})
json!({"pluginId":m.id,"phase":phase,"reason":reason,"version":m.version,"previousVersion":previous,"dataDir":self.root.join("data").join(&m.id).join(match m.run_as(){RunAs::Steamos=>"steamos",RunAs::Root=>"root"}),"exit":extra,"launchContext":self.launch_contexts.get(&format!("{}/quick",m.id))})
}
fn command_hook(
&mut self,
@@ -852,8 +871,17 @@ impl Service {
)
} else {
let staged = package::stage_request_proxy(&p, &self.db.proxy, |_, _| Ok(()))?;
(staged.manifest()?, staged.hash.clone())
{
let m = staged.manifest()?;
if let Some(e) = &m.engines {
e.check()?;
}
(m, staged.hash.clone())
}
};
if let Some(e) = &manifest.engines {
e.check()?;
}
let old = self.db.plugins.get(&manifest.id);
let changed = old.is_some_and(|o| o.manifest.run_as() != manifest.run_as());
Ok(
@@ -884,6 +912,11 @@ impl Service {
&& p["fingerprint"].as_str() == Some(plan.fingerprint.as_str())),
"请先确认依赖启用与冲突停用计划"
);
for target in &plan.enable {
if let Some(e) = &self.plugin(target)?.manifest.engines {
e.check()?;
}
}
for target in &plan.disable {
self.stop_reason(target, "conflict");
self.db.plugins.get_mut(target).unwrap().enabled = false;
@@ -979,6 +1012,144 @@ impl Service {
self.runtime.remove(&id);
Ok(json!(true))
}
"launcher.settings.save" => {
let settings: LauncherSettings = serde_json::from_value(p)?;
settings.validate()?;
self.db.launcher = settings;
self.save()?;
Ok(json!(true))
}
"launcher.order.save" => {
let order: Vec<String> = serde_json::from_value(p["order"].clone())?;
ensure!(order.len() <= 4096, "Too many launcher entries");
let mut seen = std::collections::BTreeSet::new();
for key in &order {
ensure!(
key.len() <= 512
&& !key.chars().any(char::is_control)
&& (key == "framely"
|| ["plugin:", "steam:", "desktop:", "lepton:"]
.iter()
.any(|prefix| key.starts_with(prefix)
&& key.len() > prefix.len()))
&& seen.insert(key),
"Invalid or duplicate launcher entry"
);
}
self.db.launcher_order = order;
self.save()?;
Ok(json!(true))
}
"steam.favorite" => {
let app = p["app"]
.as_u64()
.filter(|n| *n > 0 && *n <= u32::MAX as u64)
.context("Invalid Steam ID")? as u32;
if p["favorite"].as_bool().context("Missing favorite")? {
self.db.steam_favorites.insert(app);
} else {
self.db.steam_favorites.remove(&app);
}
self.save()?;
Ok(json!(true))
}
"plugin.launch.context" => {
self.active(&id)?;
let entry = p["entry"].as_str().unwrap_or("quick");
Ok(self
.launch_contexts
.get(&format!("{id}/{entry}"))
.cloned()
.unwrap_or(json!({"source":"quickPanel","trigger":"shortPress"})))
}
"plugin.launch" => {
let source = p["source"].as_str().context("Missing launch source")?;
ensure!(
["launcher", "quickPanel", "manager"].contains(&source),
"Invalid launch source"
);
let m = self.active(&id)?.manifest.clone();
let target = if p["quickPage"] == true {
LaunchTarget::QuickPage
} else {
m.ui.launch
.get(source)
.cloned()
.unwrap_or(LaunchTarget::QuickPage)
};
match &target {
LaunchTarget::QuickPage => {
ensure!(m.ui.quick_page.is_some(), "Plugin has no quick page")
}
LaunchTarget::Window { window } => {
ensure!(m.ui.windows.contains_key(window), "Window not declared")
}
}
let context = json!({"source":source,"trigger":if p["quickPage"]==true {"menuAction"} else {"shortPress"}});
self.launch_contexts
.insert(format!("{id}/quick"), context.clone());
self.start(&id)?;
let entry = match &target {
LaunchTarget::QuickPage => "quick",
LaunchTarget::Window { window } => window.as_str(),
};
self.launch_contexts
.insert(format!("{id}/{entry}"), context.clone());
process::event(
&self.events,
json!({"kind":"plugin.launch.context","plugin":id,"entry":entry,"context":context}),
);
let response = json!({"target":target,"context":context});
if p["navigate"] != false {
match target {
LaunchTarget::QuickPage => process::event(
&self.events,
json!({"kind":"menu.open","plugin":id,"context":context}),
),
LaunchTarget::Window { window } => process::event(
&self.events,
json!({"kind":"window.open","plugin":id,"window":window,"spec":m.ui.windows[&window],"context":context}),
),
}
}
Ok(response)
}
"plugin.launcher.action" => {
let m = self.active(&id)?.manifest.clone();
let action =
m.ui.launcher_actions
.iter()
.find(|a| Some(a.id.as_str()) == p["action"].as_str())
.context("Unknown launcher action")?
.clone();
let context =
json!({"source":"launcher","trigger":"menuAction","actionId":action.id});
self.launch_contexts
.insert(format!("{id}/quick"), context.clone());
self.start(&id)?;
match action.target {
LauncherActionTarget::Window { window } => {
self.launch_contexts
.insert(format!("{id}/{window}"), context.clone());
process::event(
&self.events,
json!({"kind":"window.open","plugin":id,"window":window,"spec":m.ui.windows[&window],"context":context}),
);
Ok(json!({"opened":true}))
}
LauncherActionTarget::Backend { method, params } => {
let value = self
.running
.get_mut(&id)
.context("No backend")?
.call(&method, json!({"params":params,"context":context}))?;
Ok(json!({"result":value}))
}
LauncherActionTarget::Frontend { entry } => {
Ok(json!({"frontend":entry,"context":context}))
}
}
}
"plugin.open" => {
self.start(&id)?;
Ok(json!(true))
@@ -1004,7 +1175,7 @@ impl Service {
r
}
"window.open" => {
let plugin = self.active(&id)?;
let plugin = self.active(&id)?.clone();
let key = p["window"].as_str().context("Missing window")?;
let window = plugin
.manifest
@@ -1013,6 +1184,15 @@ impl Service {
.get(key)
.context("Window not declared")?;
let mut v = json!({"kind":"window.open","plugin":id,"window":key,"spec":window});
if let Some(context) = p.get("launchContext") {
if ["launcher", "quickPanel", "manager"]
.contains(&context["source"].as_str().unwrap_or(""))
{
self.launch_contexts
.insert(format!("{id}/{key}"), context.clone());
v["context"] = context.clone();
}
}
if let Some(icon) = &plugin.manifest.icon {
v["spec"]["iconPath"] = json!(self.payload(&id)?.join(icon));
}
@@ -1372,6 +1552,7 @@ impl Service {
}
Ok(json!(true))
}
"diagnostics.export" => crate::diagnostics::collect(&self.root),
"logs" => {
if !id.is_empty() {
self.plugin(&id)?;
@@ -1682,6 +1863,9 @@ impl Service {
);
let bytes = package::request_bytes(&p, &self.db.proxy, self.manager)?;
let v = package::verify(&bytes)?;
if let Some(e) = &v.manifest.engines {
e.check()?;
}
if let Some(hash) = p["inspectionHash"].as_str() {
ensure!(
package::digest(&bytes) == hash,
@@ -1915,6 +2099,9 @@ impl Service {
for request in requests {
let bytes = package::request_bytes(request, &self.db.proxy, self.manager)?;
let v = package::verify(&bytes)?;
if let Some(e) = &v.manifest.engines {
e.check()?;
}
let source = request["source"].as_str().map(str::to_owned);
let item = plan
.items
@@ -2027,7 +2214,8 @@ impl Service {
let mut request = request.clone();
request["approve"] = json!(true);
request["approveRunAs"] = p["approveRunAs"].clone();
self.install(request)?;
self.install(request)
.with_context(|| format!("安装插件 {} 失败", item.manifest.id))?;
}
}
for (id, plugin) in &proposed.plugins {
@@ -2046,7 +2234,8 @@ impl Service {
.cloned()
.collect();
for id in roots {
self.start_reason(&id, "batch-install")?;
self.start_reason(&id, "batch-install")
.with_context(|| format!("启动插件 {id} 失败"))?;
}
self.save()?;
Ok(json!({"installed":targets,"disabled":disable}))
@@ -2223,10 +2412,10 @@ pub fn serve(root: &Path, socket: &Path, manager: u32) -> Result<()> {
);
dispatch(&core, method, request["params"].clone())
})();
let response = match result {
Ok(v) => json!({"result":v}),
Err(e) => json!({"error":e.to_string()}),
};
if let Err(error) = &result {
eprintln!("Framely request failed: {error:#}");
}
let response = ipc::response(result);
let _ = ipc::write(&mut stream, &response);
});
}
+137 -9
View File
@@ -361,7 +361,10 @@ impl Agent {
let badge = self
.core("host.notification.badge", json!({}))
.unwrap_or(json!(0));
return send_json(r, json!({"commands":commands,"notificationBadge":badge}));
return send_json(
r,
json!({"commands":commands,"notificationBadge":badge,"launcher":self.core("status",json!({})).ok().map(|v|v["database"]["launcher"].clone()).unwrap_or(json!({}))}),
);
}
if let Some(rel) = path.strip_prefix("/plugin-assets/") {
let (id, file) = rel.split_once('/').context("Invalid asset path")?;
@@ -384,7 +387,10 @@ impl Agent {
.join(&m.version);
return send_file(r, &root.join(file), true);
}
if let Some(rel) = path.strip_prefix("/plugin-frame/") {
if let Some(rel) = path
.strip_prefix("/plugin-frame/")
.or_else(|| path.strip_prefix("/plugin-action-frame/"))
{
let parts: Vec<_> = rel.split('/').collect();
ensure!(parts.len() == 2, "Invalid plugin frame");
let id = parts[0];
@@ -400,7 +406,23 @@ impl Agent {
"Plugin disabled"
);
let m: Manifest = serde_json::from_value(p["manifest"].clone())?;
let entry = if parts[1] == "quick" {
if let Some(e) = &m.engines {
e.check()?;
}
let entry = if path.starts_with("/plugin-action-frame/") {
let action = parts[1];
match &m
.ui
.launcher_actions
.iter()
.find(|a| a.id == action)
.context("Unknown action")?
.target
{
LauncherActionTarget::Frontend { entry } => entry.clone(),
_ => anyhow::bail!("Not a frontend action"),
}
} else if parts[1] == "quick" {
m.ui.quick_page.clone().context("No quick page")?
} else {
m.ui.windows
@@ -527,6 +549,9 @@ impl Agent {
let mut bytes = Vec::new();
r.as_reader().read_to_end(&mut bytes)?;
let mut request: Value = serde_json::from_slice(&bytes)?;
if request["method"] == "diagnostics.export" {
request["params"] = json!({"remote":remote});
}
if remote && request["method"] == "ui.visibility.get" {
request["params"] = json!({"view":""});
}
@@ -544,9 +569,21 @@ impl Agent {
if path == "/"
|| path == "/manager"
|| path == "/notifications"
|| path == "/launcher"
|| path.starts_with("/window/")
{
let data = fs::read(self.assets.join("index.html"))?;
let data = if remote {
String::from_utf8(data)?
.replacen(
"<head>",
"<head><meta name=\"framely-remote\" content=\"true\">",
1,
)
.into_bytes()
} else {
data
};
let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
r.respond(response)?;
return Ok(());
@@ -580,10 +617,41 @@ impl Agent {
);
}
match method {
"desktop.list" => Ok(json!(crate::desktop::discover(&crate::steam::home()?))),
"desktop.launch" => {
let id = p["app"].as_str().context("Missing desktop app")?;
crate::desktop::launch(&crate::steam::home()?, id)?;
push(&self.commands, json!({"kind":"launcher.close"}));
Ok(json!(true))
}
"steam.list" => Ok(json!(crate::steam::discover(&crate::steam::home()?))),
"steam.launch" => {
let id = p["app"]
.as_u64()
.filter(|v| *v > 0 && *v <= u32::MAX as u64)
.context("Invalid Steam app")? as u32;
crate::steam::launch(id)?;
push(&self.commands, json!({"kind":"launcher.close"}));
Ok(json!(true))
}
"host.launcher.close" => {
push(&self.commands, json!({"kind":"launcher.close"}));
Ok(json!(true))
}
"host.plugin.manage" => {
let id = p["plugin"].as_str().context("Missing plugin")?;
valid_id(id)?;
push(
&self.commands,
json!({"kind":"manager.open","page":format!("plugin:{id}")}),
);
Ok(json!(true))
}
"agreement.decline" => {
let view = p["view"].as_str().context("Missing view")?;
ensure!(
view == "menu"
|| view == "launcher"
|| view == "framely.manager"
|| view.starts_with("framely.window."),
"Invalid view"
@@ -746,12 +814,50 @@ impl Agent {
Ok(json!(true))
})
}
"diagnostics.export" => {
use base64::Engine;
let mut result = self.core("diagnostics.export", json!({}))?;
if p["remote"] != true {
let name = result["name"].as_str().context("Missing archive name")?;
ensure!(
name.starts_with("framely-logs-")
&& name.ends_with(".zip")
&& !name.contains('/')
&& !name.contains('\\'),
"Invalid archive name"
);
let bytes = base64::engine::general_purpose::STANDARD
.decode(result["data"].as_str().context("Missing archive data")?)?;
let dir =
PathBuf::from(std::env::var_os("HOME").context("Missing home directory")?)
.join("Downloads");
fs::create_dir_all(&dir)?;
let file = dir.join(format!(
"{}-{}.zip",
name.trim_end_matches(".zip"),
random_key()
));
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&file)?
.write_all(&bytes)?;
result["path"] = json!(file);
result.as_object_mut().unwrap().remove("data");
}
Ok(result)
}
"host.haptic" => {
let view = p["view"].as_str().context("Missing haptic view")?;
ensure!(
view.len() <= 160
&& (matches!(view, "menu" | "framely.manager" | "notifications")
|| view.starts_with("framely.window.")),
&& (matches!(
view,
"menu" | "launcher" | "framely.manager" | "notifications"
) || view.starts_with("framely.window.")),
"Invalid haptic view"
);
let mut commands = self.commands.lock().unwrap();
@@ -792,6 +898,12 @@ impl Agent {
| "plugin.uninstall"
| "plugin.open"
| "plugin.call"
| "plugin.launch.context"
| "plugin.launch"
| "plugin.launcher.action"
| "launcher.settings.save"
| "launcher.order.save"
| "steam.favorite"
| "window.open"
| "window.close"
| "notification.send"
@@ -856,14 +968,20 @@ pub(crate) fn fetch_catalog_proxy_cancel(
.context("插件源连接失败")?;
let bytes = crate::http::read_cancel(response, 2 * 1024 * 1024, cancel)?;
ensure!(bytes.len() <= 2 * 1024 * 1024, "插件目录超过 2 MiB");
let catalog: Catalog = serde_json::from_slice(&bytes).context("目录格式无效")?;
let mut catalog: Catalog = serde_json::from_slice(&bytes).context("目录格式无效")?;
ensure!(
catalog.schema_version == 1 && catalog.plugins.len() <= 1000,
"不支持的插件目录"
);
let mut ids = std::collections::BTreeSet::new();
for p in &catalog.plugins {
for p in &mut catalog.plugins {
p.validate(source.allow_http)?;
p.compatible = Some(
p.api_version == API_VERSION
&& p.engines
.as_ref()
.is_none_or(|e| e.matches(env!("CARGO_PKG_VERSION"))),
);
ensure!(ids.insert(&p.id), "主目录中有重复插件 ID");
}
Ok(catalog)
@@ -886,14 +1004,22 @@ pub(crate) fn fetch_plugin_versions_cancel(
cancel,
)?;
let bytes = crate::http::read_cancel(response, 2 * 1024 * 1024, cancel)?;
let document: PluginVersions = serde_json::from_slice(&bytes).context("历史版本格式无效")?;
let mut document: PluginVersions =
serde_json::from_slice(&bytes).context("历史版本格式无效")?;
ensure!(
document.schema_version == 1 && document.id == plugin_id && document.versions.len() <= 1000,
"不支持的插件历史版本目录"
);
let mut versions = std::collections::BTreeSet::new();
for entry in &document.versions {
for entry in &mut document.versions {
entry.validate(source.allow_http)?;
entry.compatible = Some(
entry.api_version == API_VERSION
&& entry
.engines
.as_ref()
.is_none_or(|e| e.matches(env!("CARGO_PKG_VERSION"))),
);
ensure!(
entry.id == plugin_id && versions.insert(&entry.version),
"历史版本 ID 不匹配或版本重复"
@@ -1128,6 +1254,7 @@ pub fn serve(
"plugin.disabled",
"notification.changed",
"manager.open",
"menu.open",
]
.contains(&e["kind"].as_str().unwrap_or(""))
{
@@ -1894,6 +2021,7 @@ mod tests {
for expected in [
"host.ui.visibility",
"host.notification.badge",
"status",
"agreement.status",
] {
let (mut stream, _) = listener.accept().unwrap();
+418
View File
@@ -0,0 +1,418 @@
//! Local Steam library discovery. No account API or network access is needed.
use anyhow::{ensure, Context, Result};
use serde::Serialize;
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
process::Stdio,
};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct App {
pub id: u32,
pub name: String,
pub kind: &'static str,
pub icon: Option<String>,
pub icon_fit: &'static str,
}
#[derive(Debug)]
enum Value {
Text(String),
Object(BTreeMap<String, Value>),
}
fn tokens(input: &str) -> Result<Vec<String>> {
let mut chars = input.chars().peekable();
let mut out = Vec::new();
while let Some(c) = chars.next() {
match c {
c if c.is_whitespace() => {}
'/' if chars.peek() == Some(&'/') => {
for c in chars.by_ref() {
if c == '\n' {
break;
}
}
}
'{' | '}' => out.push(c.to_string()),
'"' => {
let mut s = String::new();
let mut closed = false;
while let Some(c) = chars.next() {
if c == '"' {
closed = true;
break;
}
if c == '\\' {
let e = chars.next().context("Truncated escape")?;
s.push(match e {
'n' => '\n',
't' => '\t',
other => other,
});
} else {
s.push(c);
}
}
ensure!(closed, "Unclosed VDF string");
out.push(s);
}
_ => {
let mut s = c.to_string();
while chars
.peek()
.is_some_and(|c| !c.is_whitespace() && *c != '{' && *c != '}')
{
s.push(chars.next().unwrap());
}
out.push(s);
}
}
}
Ok(out)
}
fn object(
tokens: &[String],
i: &mut usize,
depth: usize,
nested: bool,
) -> Result<BTreeMap<String, Value>> {
ensure!(depth < 32, "VDF too deep");
let mut out = BTreeMap::new();
while *i < tokens.len() {
if tokens[*i] == "}" {
ensure!(nested, "Unexpected brace");
*i += 1;
return Ok(out);
}
let key = tokens[*i].clone();
*i += 1;
let v = tokens.get(*i).context("Missing VDF value")?;
*i += 1;
let value = if v == "{" {
Value::Object(object(tokens, i, depth + 1, true)?)
} else {
ensure!(v != "}", "Missing VDF value");
Value::Text(v.clone())
};
out.insert(key, value);
}
ensure!(!nested, "Unclosed VDF object");
Ok(out)
}
fn read(path: &Path) -> Result<BTreeMap<String, Value>> {
let bytes = fs::read(path)?;
ensure!(bytes.len() <= 8 * 1024 * 1024, "VDF too large");
let t = tokens(std::str::from_utf8(&bytes)?)?;
object(&t, &mut 0, 0, false)
}
fn text<'a>(v: &'a BTreeMap<String, Value>, key: &str) -> Option<&'a str> {
if let Some(Value::Text(s)) = v.get(key) {
Some(s)
} else {
None
}
}
fn root<'a>(v: &'a BTreeMap<String, Value>, key: &str) -> Option<&'a BTreeMap<String, Value>> {
if let Some(Value::Object(v)) = v.get(key) {
Some(v)
} else {
None
}
}
pub fn discover(home: &Path) -> Vec<App> {
let steam = home.join(".local/share/Steam");
let steam = if steam.exists() {
steam
} else {
home.join(".steam/steam")
};
let mut libraries = BTreeSet::from([steam.clone()]);
if let Ok(v) = read(&steam.join("steamapps/libraryfolders.vdf")) {
if let Some(v) = root(&v, "libraryfolders") {
for (key, value) in v {
if key.parse::<u32>().is_err() {
continue;
}
let path = match value {
Value::Text(s) => Some(s.as_str()),
Value::Object(v) => text(v, "path"),
};
if let Some(p) = path {
let p = PathBuf::from(p);
if p.is_absolute() {
libraries.insert(p);
}
}
}
}
}
let mut apps = BTreeMap::new();
for library in libraries {
let Ok(entries) = fs::read_dir(library.join("steamapps")) else {
continue;
};
for entry in entries.flatten() {
let p = entry.path();
let Some(file) = p.file_name().and_then(|s| s.to_str()) else {
continue;
};
if !file.starts_with("appmanifest_") || !file.ends_with(".acf") {
continue;
}
let Ok(v) = read(&p) else { continue };
let Some(v) = root(&v, "AppState") else {
continue;
};
let Some(id) = text(v, "appid")
.and_then(|s| s.parse::<u32>().ok())
.filter(|v| *v > 0)
else {
continue;
};
let Some(name) = text(v, "name").filter(|n| !n.is_empty()) else {
continue;
};
let flags = text(v, "StateFlags")
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(0);
if flags & 4 == 0 {
continue;
}
let lower = name.to_lowercase();
if [
"steamvr",
"lepton",
"lepton development",
"steam linux runtime",
"proton",
"steamworks common redistributables",
]
.iter()
.any(|s| lower == *s || lower.starts_with(&format!("{s} ")))
{
continue;
}
let dir = steam.join("appcache/librarycache").join(id.to_string());
let mut candidates: Vec<_> = fs::read_dir(dir)
.ok()
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.collect();
// Newer Steam clients keep named artwork one level below a content hash.
let nested: Vec<_> = candidates
.iter()
.filter(|p| p.is_dir())
.take(16)
.cloned()
.collect();
for path in nested {
candidates.extend(
fs::read_dir(path)
.ok()
.into_iter()
.flatten()
.flatten()
.take(32)
.map(|e| e.path()),
);
}
for suffix in ["icon.jpg", "icon.png", "library_600x900.jpg", "header.jpg"] {
candidates.push(
steam
.join("appcache/librarycache")
.join(format!("{id}_{suffix}")),
);
}
let artwork_fit = |p: &Path| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
if name.contains("600x900") || name == "library_capsule.jpg" {
(0, "cover")
} else if name.contains("header") {
(1, "cover")
} else if name == "logo.png" {
(2, "contain")
} else {
(3, "contain")
}
};
candidates.sort_by_key(|p| (artwork_fit(p).0, p.clone()));
let icon = candidates.into_iter().take(32).find_map(|p| {
if fs::metadata(&p).ok()?.len() > 1024 * 1024 {
return None;
}
let b = fs::read(&p).ok()?;
let mime = if b.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if b.starts_with(&[0xff, 0xd8, 0xff]) {
"image/jpeg"
} else {
return None;
};
use base64::Engine;
Some((
format!(
"data:{mime};base64,{}",
base64::engine::general_purpose::STANDARD.encode(b)
),
artwork_fit(&p).1,
))
});
apps.insert(
id,
App {
id,
name: name.into(),
kind: if text(v, "installdir").is_some_and(|dir| {
fs::read_dir(library.join("steamapps/common").join(dir)).is_ok_and(
|entries| {
entries
.flatten()
.any(|e| e.path().extension().is_some_and(|ext| ext == "apk"))
},
)
}) {
"lepton"
} else {
"steam"
},
icon_fit: icon.as_ref().map(|v| v.1).unwrap_or("contain"),
icon: icon.map(|v| v.0),
},
);
}
}
apps.into_values().collect()
}
pub fn home() -> Result<PathBuf> {
ensure!(
unsafe { libc::geteuid() } != 0,
"Steam launcher must run in the user session"
);
Ok(crate::process::user_home(unsafe { libc::geteuid() })?.into())
}
pub fn launch(id: u32) -> Result<()> {
launch_installed(&home()?, id)
}
fn launch_installed(home: &Path, id: u32) -> Result<()> {
let apps = discover(home);
ensure!(
apps.iter().any(|a| a.id == id),
"Steam app is no longer installed"
);
let output = crate::process::tool("steam")
.arg(format!("steam://rungameid/{id}"))
.stdin(Stdio::null())
.output()
.context("Cannot contact Steam")?;
ensure!(
output.status.success(),
"Steam launch failed: {}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nested_escaped_and_invalid() {
let t = tokens("\"libraryfolders\" { \"0\" { \"path\" \"/a\\\\b\" } }").unwrap();
let v = object(&t, &mut 0, 0, false).unwrap();
assert!(root(&v, "libraryfolders").is_some());
assert!(tokens("\"oops").is_err());
assert!(object(&tokens("a { b c").unwrap(), &mut 0, 0, false).is_err());
}
#[test]
fn artwork_prefers_nested_cover_then_logo() {
let dir = tempfile::tempdir().unwrap();
let steam = dir.path().join(".local/share/Steam");
fs::create_dir_all(steam.join("steamapps")).unwrap();
fs::write(
steam.join("steamapps/appmanifest_1.acf"),
"AppState { appid 1 name Game StateFlags 4 }",
)
.unwrap();
let cache = steam.join("appcache/librarycache/1");
fs::create_dir_all(cache.join("content-hash")).unwrap();
let png = include_bytes!("../assets/branding/icons/32.png");
fs::write(cache.join("logo.png"), png).unwrap();
let cover = cache.join("content-hash/library_capsule.jpg");
fs::write(&cover, png).unwrap();
let app = discover(dir.path()).remove(0);
assert_eq!(app.icon_fit, "cover");
assert!(app.icon.unwrap().starts_with("data:image/png;base64,"));
fs::write(&cover, b"corrupt").unwrap();
assert_eq!(discover(dir.path()).remove(0).icon_fit, "contain");
}
#[test]
fn multiple_libraries_and_tools() {
let dir = tempfile::tempdir().unwrap();
let steam = dir.path().join(".local/share/Steam/steamapps");
fs::create_dir_all(&steam).unwrap();
fs::write(
steam.join("appmanifest_1.acf"),
"AppState { appid 1 name Game StateFlags 4 }",
)
.unwrap();
fs::write(
steam.join("appmanifest_2.acf"),
"AppState { appid 2 name Proton StateFlags 4 }",
)
.unwrap();
fs::write(steam.join("appmanifest_3.acf"), "broken {").unwrap();
let second = dir.path().join("second-library");
fs::create_dir_all(second.join("steamapps")).unwrap();
fs::write(
second.join("steamapps/appmanifest_4.acf"),
"AppState { appid 4 name AnotherGame StateFlags 4 }",
)
.unwrap();
fs::write(
second.join("steamapps/appmanifest_1.acf"),
"AppState { appid 1 name Duplicate StateFlags 4 }",
)
.unwrap();
fs::write(steam.join("libraryfolders.vdf"), format!("libraryfolders {{ 0 {{ path \"{}\" }} 1 {{ path \"{}\" }} 2 {{ path \"/missing-library\" }} }}", steam.parent().unwrap().display(), second.display())).unwrap();
assert_eq!(
discover(dir.path())
.iter()
.map(|a| a.id)
.collect::<Vec<_>>(),
vec![1, 4]
);
}
}
#[cfg(test)]
mod launch_tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn only_installed_ids_reach_mock_steam() {
let dir = tempfile::tempdir().unwrap();
let library = dir.path().join(".local/share/Steam/steamapps");
fs::create_dir_all(&library).unwrap();
fs::write(
library.join("appmanifest_42.acf"),
"AppState { appid 42 name Test StateFlags 4 }",
)
.unwrap();
let tools = dir.path().join("tools");
fs::create_dir(&tools).unwrap();
let log = dir.path().join("args");
fs::write(
tools.join("steam"),
format!("#!/bin/sh\nprintf '%s' \"$1\" > '{}'\n", log.display()),
)
.unwrap();
fs::set_permissions(tools.join("steam"), fs::Permissions::from_mode(0o755)).unwrap();
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = Some(tools));
assert!(launch_installed(dir.path(), 999).is_err());
assert!(!log.exists());
launch_installed(dir.path(), 42).unwrap();
assert_eq!(fs::read_to_string(log).unwrap(), "steam://rungameid/42");
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = None);
}
}
+226 -5
View File
@@ -1082,17 +1082,21 @@ fn batch_hook_failure_and_interrupted_transaction_restore_state() {
disable: vec![],
affected: vec!["test.first".into(), "test.bad".into()],
};
assert!(apply_prepared(
let error = apply_prepared(
&mut core,
crate::planner::Prepared {
plan,
packages: vec![
(package::Staged::from_bytes(&first).unwrap(), None),
(package::Staged::from_bytes(&bad).unwrap(), None)
]
}
(package::Staged::from_bytes(&bad).unwrap(), None),
],
},
)
.is_err());
.unwrap_err();
let message = format!("{error:#}");
assert!(message.contains("批量安装失败,已恢复原版本和启用状态"));
assert!(message.contains("安装插件 test.bad 失败"));
assert!(message.contains("Lifecycle"));
assert_eq!(
serde_json::to_value(&core.db).unwrap(),
serde_json::to_value(&original).unwrap()
@@ -1748,3 +1752,220 @@ for line in sys.stdin:
assert_eq!(read(&mut core, "test.legacy"), json!([]));
core.shutdown();
}
#[test]
fn launcher_manifest_validation_and_old_package_compatibility() {
let root = tempfile::tempdir().unwrap();
let bytes = fixture(root.path(), "launch.test", "1.0.0", 1, None);
let old = package::verify(&bytes).unwrap().manifest;
assert!(
old.engines.is_none() && old.ui.launch.is_empty() && old.ui.launcher_actions.is_empty()
);
let mut value = serde_json::to_value(&old).unwrap();
value["engines"] = json!({"framely":">=0.4.3-preview.2 <0.5.0"});
value["ui"]["launch"] = json!({"launcher":{"type":"quickPage"}});
let m: Manifest = serde_json::from_value(value.clone()).unwrap();
m.validate().unwrap();
value["ui"]["launch"]["manager"] = json!({"type":"window","window":"missing"});
assert!(serde_json::from_value::<Manifest>(value.clone())
.unwrap()
.validate()
.is_err());
value["ui"]["launch"] = json!({"launcher":{"type":"quickPage"}});
value["engines"] = json!({"framely":"*"});
assert!(serde_json::from_value::<Manifest>(value)
.unwrap()
.validate()
.is_err());
}
#[test]
fn engines_ranges_and_incompatible_install_are_non_mutating() {
let e = |r: &str| Engines {
framely: Some(r.into()),
};
assert!(e(">=0.4.3 <0.5.0").matches("0.4.4+build"));
assert!(!e(">=0.4.3 <0.5.0").matches("0.4.4-preview.1"));
assert!(e(">=0.4.3-preview.2 <0.5.0").matches("0.4.3-preview.3"));
assert!(e(">=0.4.3-preview.2 <0.5.0").matches("0.4.3"));
assert!(!e(">=0.4.3-preview.2 <0.5.0").matches("0.4.3-preview.1"));
assert!(e("=0.4.3").matches("0.4.3+abc"));
assert!(e("^0.4.3").matches("0.4.9"));
assert!(!e("^0.4.3").matches("0.5.0"));
assert!(e("~0.4.3").matches("0.4.4"));
assert!(e("0.4.*").matches("0.4.4"));
assert!(e("nonsense").validate().is_err());
assert!(e("0.4.3 || 0.5.0").validate().is_err());
let root = tempfile::tempdir().unwrap();
let mut core =
accepted_service(&root.path().join("state"), unsafe { libc::geteuid() }).unwrap();
install(
&mut core,
&fixture(root.path(), "launch.compat", "1.0.0", 1, None),
json!({}),
)
.unwrap();
let old = serde_json::to_value(&core.db).unwrap();
let future = rewrite(
&fixture(root.path(), "launch.compat", "2.0.0", 2, None),
|name, data| {
if name == "manifest.json" {
let mut m: Value = serde_json::from_slice(data).unwrap();
m["engines"] = json!({"framely":">=999.0.0"});
*data = serde_json::to_vec(&m).unwrap();
}
},
);
assert!(install(&mut core, &future, json!({}))
.unwrap_err()
.to_string()
.contains("requires Framely"));
assert_eq!(serde_json::to_value(&core.db).unwrap(), old);
assert!(!root
.path()
.join("state/plugins/launch.compat/versions/2.0.0")
.exists());
core.db
.plugins
.get_mut("launch.compat")
.unwrap()
.manifest
.engines = Some(e(">=999.0.0"));
assert!(core
.handle("plugin.open", json!({"plugin":"launch.compat"}))
.is_err());
}
#[test]
fn launcher_dispatch_preserves_old_open_semantics_and_window_context() {
let root = tempfile::tempdir().unwrap();
let mut core =
accepted_service(&root.path().join("state"), unsafe { libc::geteuid() }).unwrap();
install(
&mut core,
&fixture(root.path(), "launch.routes", "1.0.0", 1, None),
json!({}),
)
.unwrap();
core.events.lock().unwrap().clear();
core.handle("plugin.open", json!({"plugin":"launch.routes"}))
.unwrap();
assert!(!core
.handle("events", json!({}))
.unwrap()
.as_array()
.unwrap()
.iter()
.any(|v| v["kind"] == "menu.open"));
for source in ["launcher", "quickPanel", "manager"] {
core.handle(
"plugin.launch",
json!({"plugin":"launch.routes","source":source}),
)
.unwrap();
let events = core.handle("events", json!({})).unwrap();
assert!(events
.as_array()
.unwrap()
.iter()
.any(|v| v["kind"] == "menu.open" && v["context"]["source"] == source));
assert_eq!(
core.handle(
"plugin.launch.context",
json!({"plugin":"launch.routes","entry":"quick"})
)
.unwrap()["source"],
source
);
}
assert!(core
.handle(
"plugin.launch",
json!({"plugin":"launch.routes","source":"unknown"})
)
.is_err());
let plugin = core.db.plugins.get_mut("launch.routes").unwrap();
plugin.manifest.ui.windows.insert(
"main".into(),
serde_json::from_value(json!({"entry":"page.js","title":"Main"})).unwrap(),
);
plugin.manifest.ui.launch.insert(
"launcher".into(),
LaunchTarget::Window {
window: "main".into(),
},
);
plugin.manifest.ui.launcher_actions = serde_json::from_value(json!([
{"id":"window","label":"Open","target":{"type":"window","window":"main"}},
{"id":"front","label":"Front","target":{"type":"frontend","entry":"page.js"}}
]))
.unwrap();
core.handle(
"plugin.launch",
json!({"plugin":"launch.routes","source":"launcher"}),
)
.unwrap();
assert_eq!(
core.handle(
"plugin.launch.context",
json!({"plugin":"launch.routes","entry":"main"})
)
.unwrap()["source"],
"launcher"
);
core.handle(
"plugin.launcher.action",
json!({"plugin":"launch.routes","action":"window"}),
)
.unwrap();
assert_eq!(
core.handle(
"plugin.launch.context",
json!({"plugin":"launch.routes","entry":"main"})
)
.unwrap()["actionId"],
"window"
);
let frontend = core
.handle(
"plugin.launcher.action",
json!({"plugin":"launch.routes","action":"front"}),
)
.unwrap();
assert_eq!(frontend["frontend"], "page.js");
assert_eq!(frontend["context"]["actionId"], "front");
assert!(core
.handle(
"plugin.launcher.action",
json!({"plugin":"launch.routes","action":"unknown"})
)
.is_err());
core.handle(
"launcher.settings.save",
json!({"primaryTrigger":true,"menuAutoClose":false,"menuTimeoutSeconds":25}),
)
.unwrap();
let loaded = Service::load(&root.path().join("state"), unsafe { libc::geteuid() }).unwrap();
assert!(loaded.db.launcher.primary_trigger);
assert!(!loaded.db.launcher.menu_auto_close);
assert_eq!(loaded.db.launcher.menu_timeout_seconds, 25);
let order = vec![
"framely",
"plugin:launch.routes",
"desktop:terminal.desktop",
"lepton:test/com.example.app",
];
core.handle("launcher.order.save", json!({"order":order}))
.unwrap();
for invalid in [
json!(["framely", "framely"]),
json!(["unknown:app"]),
json!(["plugin:"]),
json!(["desktop:bad\nentry"]),
] {
assert!(core
.handle("launcher.order.save", json!({"order":invalid}))
.is_err());
}
let loaded = Service::load(&root.path().join("state"), unsafe { libc::geteuid() }).unwrap();
assert_eq!(loaded.db.launcher_order, order);
}
+4 -1
View File
@@ -33,7 +33,10 @@ impl Tracker {
ensure!(
views.len() <= 64
&& views.keys().all(|k| k.len() <= 256
&& (k == "menu" || k == "framely.manager" || k.starts_with("framely.window."))),
&& (k == "menu"
|| k == "launcher"
|| k == "framely.manager"
|| k.starts_with("framely.window."))),
"Invalid native visibility views"
);
self.seen = Some(now);
+4
View File
@@ -33,3 +33,7 @@ To edit this template inside the Framely repository, run `npm install`, `npm run
The template and bundled SDK use `AGPL-3.0-only`. See Framely's `docs/developer-guide/README.md` for the full development and publishing workflow. The template lifecycle callbacks preserve user data.
For community GitHub registration, source may omit `downloadUrl`; default assets use `<id>-<version>.framely`. Release packages need a temporary Manifest with the derived URL. See Framely's `docs/developer-guide/publishing.md` for commands, Release digests, custom URLs and database window-dimension validation. The direct packaging command above is for core testing and does not establish community registration acceptance.
Launcher support requires Framely 0.4.3-preview.2+. `actions.ts` is built separately; edit `engines.framely`, `ui.launch` and `ui.launcherActions` in the manifest. See the bilingual launcher developer guide.
启动台功能要求 Framely 0.4.3-preview.2 或更新版本。`actions.ts` 单独构建;在清单中调整 `engines.framely`、`ui.launch` 和 `ui.launcherActions`,详见中英双语启动台开发文档。
+12
View File
@@ -0,0 +1,12 @@
import {registerLauncherActions, framely} from '@framely/sdk';
// This bundle runs in a sandbox without mounting QuickPage.
// 此入口运行在沙箱内,不会挂载 QuickPage。
registerLauncherActions({
notify: async context => {
await framely.notifications.send({
id: 'launcher-action', title: 'Launcher action / 启动台操作',
body: `Opened from ${context.source} / 来自 ${context.source}`, inbox: true,
});
},
});
+63 -10
View File
@@ -7,23 +7,76 @@
"author": "Your name",
"description": "一个可保存文字、打开窗口和发送通知的插件。",
"details": "使用 React 编写界面,使用 Python 后端保存插件数据。",
"tags": ["工具"],
"tags": [
"工具"
],
"changelog": "首次发布。",
"backend": {
"entry": "backend.py", "runAs": "steamos", "autostart": false,
"restart": "on-failure", "restartLimit": 3
"entry": "backend.py",
"runAs": "steamos",
"autostart": false,
"restart": "on-failure",
"restartLimit": 3
},
"lifecycle": {
"onInstall": {"entry": "backend.py"},
"onUpdate": {"entry": "backend.py"},
"onStart": true, "onStop": true,
"onUninstall": {"entry": "backend.py"},
"onInstall": {
"entry": "backend.py"
},
"onUpdate": {
"entry": "backend.py"
},
"onStart": true,
"onStop": true,
"onUninstall": {
"entry": "backend.py"
},
"timeoutSeconds": 10
},
"ui": {
"quickPage": "page.js",
"windows": {"main": {"entry": "page.js", "title": "我的插件", "dockIcon": true}}
"windows": {
"main": {
"entry": "page.js",
"title": "我的插件",
"dockIcon": true
}
},
"launch": {
"launcher": {
"type": "quickPage"
},
"quickPanel": {
"type": "quickPage"
},
"manager": {
"type": "quickPage"
}
},
"launcherActions": [
{
"id": "open-window",
"label": "Open window",
"target": {
"type": "window",
"window": "main"
}
},
{
"id": "notify",
"label": "Send a notification",
"target": {
"type": "frontend",
"entry": "actions.js"
}
}
]
},
"dependencies": {}, "optionalDependencies": {}, "conflicts": {},
"exclusiveResources": [], "files": {}
"dependencies": {},
"optionalDependencies": {},
"conflicts": {},
"exclusiveResources": [],
"files": {},
"engines": {
"framely": ">=0.4.3-preview.2 <0.5.0"
}
}
+10 -5
View File
@@ -1,3 +1,5 @@
#include "../native/paint_buffer.h"
#include "../native/launcher_pointer.h"
// Integration test only: render the real manager and exercise its iframe bridge.
#include "include/capi/cef_app_capi.h"
#include "include/capi/cef_client_capi.h"
@@ -23,7 +25,9 @@ static Handler<cef_life_span_handler_t> life;
static std::vector<unsigned char> pixels;static int pixel_width=0,pixel_height=0;
static void snapshot(const std::string& name){const char* dir=std::getenv("FRAMELY_PREVIEW_DIR");if(!dir||pixels.empty())return;std::ofstream out(std::string(dir)+"/"+name+".ppm",std::ios::binary);out<<"P6\n"<<pixel_width<<" "<<pixel_height<<"\n255\n";for(size_t i=0;i<pixels.size();i+=4){unsigned char rgb[]={pixels[i+2],pixels[i+1],pixels[i]};out.write(reinterpret_cast<char*>(rgb),3);}}
static bool install_review_mode=std::getenv("FRAMELY_INSTALL_REVIEW_TEST")!=nullptr;
static bool launcher_mode=std::getenv("FRAMELY_LAUNCHER_TEST")!=nullptr;
static bool notification_mode=std::getenv("FRAMELY_NOTIFICATION_TEST")!=nullptr;
static LauncherPointer test_pointer;static bool press_pending=false;static int press_dx=0,press_dy=0,press_duration=0;
static bool ready=false,passed=false,failed=false,closed=false;static int paints=0;static bool scaled_paint=false,dpi_click=false,rounded_paint=false;
static void str(cef_string_t& s,const std::string& v){cef_string_utf8_to_utf16(v.data(),v.size(),&s);}
static std::string text(const cef_string_t* s){cef_string_utf8_t o{};cef_string_utf16_to_utf8(s->str,s->length,&o);std::string r(o.str,o.length);cef_string_utf8_clear(&o);return r;}
@@ -32,12 +36,13 @@ 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=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){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,size_t,const cef_rect_t*,const void* data,int w,int h){++paints;if(w==(std::getenv("FRAMELY_STORE_TEST")?5760:1200)&&h==(std::getenv("FRAMELY_STORE_TEST")?3240:(notification_mode?720:1680)))scaled_paint=true;pixel_width=w;pixel_height=h;pixels.assign(static_cast<const unsigned char*>(data),static_cast<const unsigned char*>(data)+w*h*4);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_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;};
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";}}}
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=30;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);
auto end=std::chrono::steady_clock::now()+std::chrono::seconds(20);bool injected=false;
while(std::chrono::steady_clock::now()<end&&!passed&&!failed){cef_do_message_loop_work();if(ready&&!injected){auto* f=browser->get_main_frame(browser);cef_string_t code{},source{};str(code,install_review_mode?"window.runInstallReviewChecks()":notification_mode?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));for(let i=0;i<100&&!document.querySelector('.toast');i++)await wait(30);if(innerWidth!==600||innerHeight!==360||devicePixelRatio!==2)throw Error('notification dimensions');const toast=document.querySelector('.toast');if(!toast?.textContent.includes('Toast integration'))throw Error('notification missing');if(toast.getBoundingClientRect().height>240)throw Error('notification too large');if(toast.querySelectorAll('.toast-actions button').length!==3)throw Error('actions missing');toast.querySelector('[aria-label="确认"]').click();await wait(150);document.querySelector('[aria-label="关闭通知"]').click();await wait(150);const status=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method:'status',params:{}})}).then(r=>r.json());if(status.result.notifications.length)throw Error('notification not withdrawn');console.log('FRAMELY_BRIDGE_PASS');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_REMOTE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));await wait(300);const button=document.createElement('button');button.style.cssText='position:fixed;left:20px;top:20px;width:100px;height:50px;z-index:9999';button.onclick=()=>console.log('FRAMELY_DPI_CLICK_PASS');document.body.append(button);console.log('FRAMELY_DPI_CLICK_REQUEST');await wait(200);button.remove();for(let i=0;i<100;i++){const plugin=document.querySelector('.plugin-main');if(plugin){plugin.click();return;}await wait(50);}throw Error('remote plugin list timeout');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_STORE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));const until=async find=>{for(let i=0;i<160;i++){const value=find();if(value)return value;await wait(50);}throw Error("store timeout "+document.body.innerText);};await wait(300);if(innerWidth!==1440||innerHeight!==810||devicePixelRatio!==4)throw Error("manager layout or DPI mismatch");[...document.querySelectorAll("nav button")].find(b=>b.textContent==="插件源").click();await wait(200);if(!document.body.textContent.includes("Subscription fixture"))throw Error("subscription settings missing");const autoRefresh=[...document.querySelectorAll("button")].find(b=>b.textContent==="关闭自动刷新");if(!autoRefresh)throw Error("subscription automatic refresh missing");autoRefresh.click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="开启自动刷新"));[...document.querySelectorAll("nav button")].find(b=>b.textContent==="关于").click();await wait(150);const updates=document.querySelector(".system-updates");if(!updates||updates.querySelector("input:not([type=checkbox]),textarea"))throw Error("release configuration leaked into user settings");if(!updates.querySelector(".automatic-update-settings input[type=checkbox]")?.disabled)throw Error("automatic check controls enabled without source");if(!updates.textContent.includes("尚未配置更新服务"))throw Error("missing unconfigured update service message");const check=[...updates.querySelectorAll("button")].find(b=>b.textContent==="检查更新");if(!check?.disabled)throw Error("unconfigured check enabled");[...document.querySelectorAll("button")].find(b=>b.textContent==="查看协议与隐私声明").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议"));document.querySelector('.language-picker button').click();const english=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='English'));english.click();await until(()=>document.querySelector('h1')?.textContent==='Before using Framely');document.querySelector('.language-picker button').click();const chinese=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='简体中文'));chinese.click();await until(()=>[...document.querySelectorAll('button')].find(b=>b.textContent==='拒绝协议'));[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议").click();await until(()=>document.body.textContent.includes("拒绝协议将停用全部插件"));[...document.querySelectorAll("button")].find(b=>b.textContent==="取消").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="返回关于"));[...document.querySelectorAll("button")].find(b=>b.textContent==="返回关于").click();await until(()=>document.querySelector(".system-updates"));[...document.querySelectorAll("nav button")].find(b=>b.textContent.includes("插件库")).click();const card=await until(()=>document.querySelector(".store-card"));if(!card.textContenLine truncated
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=90;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);
auto end=std::chrono::steady_clock::now()+std::chrono::seconds(45);bool injected=false;
while(std::chrono::steady_clock::now()<end&&!passed&&!failed){cef_do_message_loop_work();if(press_pending){auto now=std::chrono::steady_clock::now();cef_mouse_event_t mouse{};mouse.x=test_pointer.x+press_dx;mouse.y=test_pointer.y+press_dy;test_pointer.constrain(mouse.x,mouse.y,now);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;host->send_mouse_move_event(host,&mouse,0);if(now-test_pointer.pressed>=std::chrono::milliseconds(press_duration)){mouse.modifiers=0;host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;}}if(ready&&!injected){auto* f=browser->get_main_frame(browser);cef_string_t code{},source{};str(code,install_review_mode?"window.runInstallReviewChecks()":notification_mode?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));for(let i=0;i<100&&!document.querySelector('.toast');i++)await wait(30);if(innerWidth!==600||innerHeight!==360||devicePixelRatio!==2)throw Error('notification dimensions');const toast=document.querySelector('.toast');if(!toast?.textContent.includes('Toast integration'))throw Error('notification missing');if(toast.getBoundingClientRect().height>240)throw Error('notification too large');if(toast.querySelectorAll('.toast-actions button').length!==3)throw Error('actions missing');toast.querySelector('[aria-label="确认"]').click();await wait(150);document.querySelector('[aria-label="关闭通知"]').click();await wait(150);const status=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method:'status',params:{}})}).then(r=>r.json());if(status.result.notifications.length)throw Error('notification not withdrawn');console.log('FRAMELY_BRIDGE_PASS');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_REMOTE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));await wait(300);const button=document.createElement('button');button.style.cssText='position:fixed;left:20px;top:20px;width:100px;height:50px;z-index:9999';button.onclick=()=>console.log('FRAMELY_DPI_CLICK_PASS');document.body.append(button);console.log('FRAMELY_DPI_CLICK_REQUEST');await wait(200);button.remove();for(let i=0;i<100;i++){const plugin=document.querySelector('.plugin-main');if(plugin){plugin.click();return;}await wait(50);}throw Error('remote plugin list timeout');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_STORE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));const until=async find=>{for(let i=0;i<160;i++){const value=find();if(value)return value;await wait(50);}throw Error("store timeout "+document.body.innerText);};await wait(300);if(innerWidth!==1440||innerHeight!==810||devicePixelRatio!==4)throw Error("manager layout or DPI mismatch");[...document.querySelectorAll("nav button")].find(b=>b.textContent==="插件源").click();await wait(200);if(!document.body.textContent.includes("Subscription fixture"))throw Error("subscription settings missing");const autoRefresh=[...document.querySelectorAll("button")].find(b=>b.textContent==="关闭自动刷新");if(!autoRefresh)throw Error("subscription automatic refresh missing");autoRefresh.click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="开启自动刷新"));[...document.querySelectorAll("nav button")].find(b=>b.textContent==="关于").click();await wait(150);const updates=document.querySelector(".system-updates");if(!updates||updates.querySelector("input:not([type=checkbox]),textarea"))throw Error("release configuration leaked into user settings");if(!updates.querySelector(".automatic-update-settings input[type=checkbox]")?.disabled)throw Error("automatic check controls enabled without source");if(!updates.textContent.includes("尚未配置更新服务"))throw Error("missing unconfigured update service message");const check=[...updates.querySelectorAll("button")].find(b=>b.textContent==="检查更新");if(!check?.disabled)throw Error("unconfigured check enabled");[...document.querySelectorAll("button")].find(b=>b.textContent==="查看协议与隐私声明").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议"));document.querySelector('.language-picker button').click();const english=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='English'));english.click();await until(()=>document.querySelector('h1')?.textContent==='Before using Framely');document.querySelector('.language-picker button').click();const chinese=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='简体中文'));chinese.click();await until(()=>[...document.querySelectorAll('button')].find(b=>b.textContent==='拒绝协议'));[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议").click();await until(()=>document.body.textContent.includes("拒绝协议将停用全部插件"));[...document.querySelectorAll("button")].fiLine truncated
if(!passed&&!failed){auto* f=browser->get_main_frame(browser);cef_string_t diagnostic{},source{};str(diagnostic,"console.error('FRAMELY_TIMEOUT '+location.pathname.replace(/\\/boot\\/[^/]+/,'/boot/token')+' '+document.body.innerText.slice(0,400))");f->execute_java_script(f,&diagnostic,&source,1);cef_string_utf16_clear(&diagnostic);f->base.release(&f->base);for(int i=0;i<40;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}snapshot("FAILURE");}
host->close_browser(host,1);for(int i=0;i<400&&!closed;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}bool ok=passed&&!failed&&paints>0&&closed&&scaled_paint&&(notification_mode||rounded_paint)&&(notification_mode||install_review_mode||std::getenv("FRAMELY_STORE_TEST")||dpi_click);std::cout<<"BROWSER_RESULT pass="<<ok<<" paints="<<paints<<" closed="<<closed<<"\n";if(!closed)_Exit(5);host->base.release(&host->base);browser->base.release(&browser->base);cef_shutdown();return ok?0:5;
}
+1
View File
@@ -0,0 +1 @@
import{registerLauncherActions,framely}from'../sdk/src/index';registerLauncherActions({connect:async context=>{if(context.source!=='launcher')throw Error('Missing launch context');await framely.call('connect',{from:context.source});}});
+91
View File
@@ -0,0 +1,91 @@
import React,{useState} from 'react';import{createRoot}from'react-dom/client';
import{Launcher,LauncherSettings,type LauncherConfig}from'../ui/src/launcher';import{configureLanguage}from'../ui/src/i18n';import type{Plugin}from'../ui/src/main';import'../ui/src/style.css';
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>any)=>{for(let i=0;i<120;i++){if(f())return;await wait(30);}throw Error('Timed out '+document.body.innerText.slice(0,240)+' '+JSON.stringify(calls.slice(-4))+' edit='+!!document.querySelector('.launcher-editing'));};
let config:LauncherConfig={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:1};let order:string[]=[];let localApps:any[]=[];let redraw:()=>void;let settings=false;const calls:{method:string;params:any}[]=[];
const plugins:Record<string,Plugin>={
'demo.alpha':{manifest:{id:'demo.alpha',icon:'icon.png',name:'Termix',description:'Terminal',author:'Framely',version:'1.0.0',ui:{quickPage:'page.js',windows:{main:{}},launcherActions:[{id:'connect',label:'Connect terminal',target:{type:'frontend',entry:'actions.js'}}]}},enabled:true,favorite:true,order:0},
'demo.beta':{manifest:{id:'demo.beta',icon:'icon.png',name:'Passthrough color',description:'Color',author:'Framely',version:'1.0.0',ui:{quickPage:'page.js',windows:{}}},enabled:true,favorite:false,order:1},
'demo.off':{manifest:{id:'demo.off',name:'Disabled app',description:'',author:'Framely',version:'1.0.0',ui:{windows:{}}},enabled:false,favorite:false,order:2},
};
window.fetch=async(url,init)=>{if(String(url)!=='/api')throw Error('Unexpected fetch');const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
if(method==='steam.list')result=[{id:620980,name:'Beat Saber'},{id:546560,name:'Half-Life: Alyx'},{id:413150,name:'Stardew Valley'},{id:1245620,name:'Elden Ring'},{id:367520,name:'Hollow Knight'},{id:570,name:'Dota 2'},{id:440,name:'Team Fortress 2'}].map(a=>({...a,icon:`/preview-icons/${a.id}.jpg`,iconFit:'cover'}));
else if(method==='desktop.list')result=localApps;
else if(method==='status')result={database:{plugins,safeMode:false}};
else if(method==='plugin.disable.preview')result={dependents:[],fingerprint:'test'};
else if(method==='launcher.order.save'){order=params.order;}
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 button(text:string){return [...document.querySelectorAll<HTMLButtonElement>('button')].find(b=>b.textContent===text)!;}
function openMenu(){const b=document.querySelector<HTMLButtonElement>('.launch-icon')!;b.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));}
(window as any).runInstallReviewChecks=async()=>{try{
configureLanguage('en-US',[]);redraw();await until(()=>document.querySelectorAll('.launch-icon').length===10);
if(document.body.textContent?.includes('Disabled app'))throw Error('Disabled plugin displayed');
if(document.querySelector('.launcher-header')||document.querySelector('.launcher-shell 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 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 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');}};
assertRows([4,5,1]);
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');
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]);
for(let n=1;n<13;n++){plugins[`layout.${n}`]={...plugins['demo.beta'],enabled:true,manifest:{...plugins['demo.beta'].manifest,id:`layout.${n}`,name:`Layout ${String(n).padStart(2,'0')}`}};redraw();await until(()=>document.querySelectorAll('.launch-icon').length===n+1);sameLayout();assertRows(arrangements[n]);}
for(let n=1;n<=13;n++)delete plugins[`layout.${n}`];plugins['demo.alpha'].enabled=true;plugins['demo.beta'].enabled=true;redraw();await until(()=>document.querySelectorAll('.launch-icon').length===3);
document.querySelector<HTMLButtonElement>('.launcher-filters button')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10);
const category=document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label=Steam]')!;const categoryRect=category.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(categoryRect.x+categoryRect.width/2)}_${Math.round(categoryRect.y+categoryRect.height/2)}_28_15_150`);await until(()=>document.querySelectorAll('.launch-icon').length===7);sameLayout();assertRows([4,3]);document.querySelector<HTMLButtonElement>('.launcher-filters button')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===10);
const assertControlsPainted=()=>{for(const node of document.querySelectorAll('.launcher-filters button svg,.launcher-pages button span')){const r=node.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_REGION_${Math.floor(r.x)}_${Math.floor(r.y)}_${Math.ceil(r.width)}_${Math.ceil(r.height)}`);}};
await wait(500);assertControlsPainted();for(const label of document.querySelectorAll('.launch-name')){const r=label.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_INK_${Math.floor(r.x)}_${Math.floor(r.y)}_${Math.ceil(r.width)}_${Math.ceil(r.height)}`);}console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_EN');
const icon=document.querySelector<HTMLButtonElement>('.launch-icon')!;
const rect=icon.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_HOVER_${Math.round(rect.x+rect.width/2)}_${Math.round(rect.y+rect.height/2)}`);await wait(350);if(icon.getBoundingClientRect().width!==rect.width)throw Error('Hover moved the input target');console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_HOVER_EN');console.log('FRAMELY_LAUNCHER_HOVER_0_0');await wait(250);
const beforeClick=calls.filter(c=>c.method==='plugin.launch').length;console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(rect.x+10)}_${Math.round(rect.y+rect.height/2)}_-28_15_150`);await until(()=>calls.filter(c=>c.method==='plugin.launch').length===beforeClick+1);if(calls.filter(c=>c.method==='plugin.launch').at(-1)?.params.plugin!=='demo.alpha')throw Error('Jitter click launched a different app');
const beforeDrag=calls.filter(c=>c.method==='plugin.launch').length;console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(rect.x+rect.width/2)}_${Math.round(rect.y+rect.height/2)}_80_0_300`);await wait(550);if(calls.filter(c=>c.method==='plugin.launch').length!==beforeDrag||document.querySelector('[role=menu]'))throw Error('Deliberate movement activated an app');
console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(rect.x+rect.width/2)}_${Math.round(rect.y+rect.height/2)}_28_18_1000`);await wait(200);console.log('FRAMELY_LAUNCHER_CANCEL_PRESS');await wait(800);if(calls.filter(c=>c.method==='plugin.launch').length!==beforeDrag||document.querySelector('[role=menu]'))throw Error('Hidden launcher retained a pending gesture');
const initial=calls.filter(c=>c.method==='plugin.launch').length;console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(rect.x+rect.width/2)}_${Math.round(rect.y+rect.height/2)}_28_18_650`);await wait(850);
if(!document.querySelector('[role=menu]')||calls.filter(c=>c.method==='plugin.launch').length!==initial)throw Error('Long press triggered a launch');
if(document.querySelectorAll('[role=menuitem]').length!==4)throw Error('Built-in/custom menu actions missing');
await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_MENU_EN');
button('Connect terminal').click();await until(()=>calls.some(c=>c.method==='plugin.call'&&c.params.method==='connect'));await until(()=>!document.querySelector('.launcher-action-frame'));
if(!calls.some(c=>c.method==='plugin.call'&&c.params.method==='connect'))throw Error('Frontend action did not run');
document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));await wait();if(document.querySelector('[role=menu]'))throw Error('Outside click did not close');
openMenu();await wait();const item=document.querySelector('.launch-item-expanded')!;item.dispatchEvent(new PointerEvent('pointerout',{bubbles:true,relatedTarget:document.body}));await wait(500);item.dispatchEvent(new PointerEvent('pointerover',{bubbles:true,relatedTarget:document.body}));await wait(700);if(!document.querySelector('[role=menu]'))throw Error('Reentry failed to cancel timer');item.dispatchEvent(new PointerEvent('pointerout',{bubbles:true,relatedTarget:document.body}));await wait(1150);if(document.querySelector('[role=menu]'))throw Error('Leave timer did not close');
openMenu();await wait();button('Uninstall plugin').click();await wait();if(!document.querySelector('[role=dialog]')||calls.some(c=>c.method==='plugin.uninstall'))throw Error('Uninstall bypassed confirmation');button('Cancel').click();await wait();
configureLanguage('zh-CN',[]);config={...config,menuTimeoutSeconds:10};redraw();await wait(650);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_MENU_ZH');document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));await wait(650);assertControlsPainted();for(const label of document.querySelectorAll('.launch-name')){const r=label.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_INK_${Math.floor(r.x)}_${Math.floor(r.y)}_${Math.ceil(r.width)}_${Math.ceil(r.height)}`);}console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_ZH');
// Exercise real CEF pointer capture across the 400 / 800 ms transition,
// then reorder and persist. No app may launch when a long hold is released.
configureLanguage('en-US',[]);redraw();await wait(120);
const orderedBefore=[...document.querySelectorAll<HTMLElement>('.launch-item')].map(n=>n.dataset.launchKey!);
const first=document.querySelector<HTMLButtonElement>('.launch-icon')!,firstRect=first.getBoundingClientRect();const last=document.querySelectorAll<HTMLButtonElement>('.launch-icon')[3].getBoundingClientRect();
const launchesBefore=calls.filter(c=>c.method==='plugin.launch'||c.method==='steam.launch').length;
console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(firstRect.x+firstRect.width/2)}_${Math.round(firstRect.y+firstRect.height/2)}_0_0_1500`);
await until(()=>!!document.querySelector('[role=menu]'));await until(()=>document.querySelector('.launcher-editing'));if(document.querySelector('[role=menu]'))throw Error('800 ms left the menu open');
console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(last.x+last.width/2)}_${Math.round(last.y+last.height/2)}`);await until(()=>calls.some(c=>c.method==='launcher.order.save'));
await until(()=>!document.querySelector('.launcher-drag-ghost'));const reordered=[...document.querySelectorAll<HTMLElement>('.launch-item')].map(n=>n.dataset.launchKey!);if(reordered[3]!==orderedBefore[0]||order[3]!==orderedBefore[0])throw Error('Drag did not insert and persist order');if(calls.filter(c=>c.method==='plugin.launch'||c.method==='steam.launch').length!==launchesBefore)throw Error('Arrange gesture launched an app');const doneStyle=getComputedStyle(button('Done')),railStyle=getComputedStyle(document.querySelector('.launcher-tools')!);if(doneStyle.backgroundColor!==railStyle.backgroundColor||doneStyle.borderTopColor!==railStyle.borderTopColor||doneStyle.boxShadow!==railStyle.boxShadow)throw Error('Done button does not match category rail material');console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_EDIT_EN');await until(()=>!button('Done').disabled);button('Done').click();await until(()=>!document.querySelector('.launcher-editing'));await wait(100);
const selfIcon=document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Framely]')!;selfIcon.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await wait(80);button('Arrange icons').click();await until(()=>!!document.querySelector('.launcher-editing'));const closeCount=calls.filter(c=>c.method==='host.launcher.close').length;window.dispatchEvent(new Event('framely.back'));await until(()=>!document.querySelector('.launcher-editing'));if(calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('B closed the launcher instead of exiting arrangement');selfIcon.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true}));await wait(80);button('Arrange icons').click();await until(()=>!!document.querySelector('.launcher-editing'));document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));await until(()=>!document.querySelector('.launcher-editing'));if(calls.filter(c=>c.method==='host.launcher.close').length!==closeCount)throw Error('Blank click closed launcher during arrangement');
// Add enough icons for edge paging and swipe navigation.
for(let n=0;n<9;n++)plugins[`edge.${n}`]={...plugins['demo.beta'],manifest:{...plugins['demo.beta'].manifest,id:`edge.${n}`,name:`Edge ${n}`}};redraw();await until(()=>document.querySelectorAll('.launcher-pages button').length===2);
console.log('FRAMELY_LAUNCHER_PRESS_700_40_-180_0_550');await until(()=>!!document.querySelector('.launcher-adjacent-page'));const activePage=document.querySelector<HTMLElement>('.launcher-page')!,adjacent=document.querySelector<HTMLElement>('.launcher-adjacent-page')!;if(!document.querySelector('.launcher-grid')!.classList.contains('launcher-panning')||!getComputedStyle(activePage).translate.startsWith('-180px'))throw Error('Canvas did not follow the held trigger');if(getComputedStyle(adjacent).translate==='none')throw Error('Neighbor did not slide alongside canvas');if(document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')!=='page')throw Error('Swipe committed before release');await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');
window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-1}));await until(()=>document.querySelectorAll('.launcher-pages button')[0].getAttribute('aria-current')==='page');
await wait(340);const edgeFirst=document.querySelector<HTMLButtonElement>('.launch-icon')!.getBoundingClientRect();const saves=calls.filter(c=>c.method==='launcher.order.save').length;
console.log(`FRAMELY_LAUNCHER_PRESS_${Math.round(edgeFirst.x+edgeFirst.width/2)}_${Math.round(edgeFirst.y+edgeFirst.height/2)}_0_0_2600`);await until(()=>document.querySelector('.launcher-editing'));const edgeBox=document.querySelector('.launcher-grid')!.getBoundingClientRect();console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(edgeBox.right-25)}_400`);await until(()=>document.querySelectorAll('.launcher-pages button')[1].getAttribute('aria-current')==='page');console.log('FRAMELY_EDGE_PAGE_OK');await wait(340);const leftEdge=Math.max(edgeBox.left,categoryRight+24)+25;if(leftEdge<=categoryRight)throw Error('Left drag edge overlaps category rail');console.log(`FRAMELY_LAUNCHER_DRAG_${Math.round(leftEdge)}_400`);await until(()=>!!document.querySelector('.launcher-drag-edge.left'));const indicator=document.querySelector('.launcher-drag-edge.left')!.getBoundingClientRect();if(indicator.left<=categoryRight)throw Error('Left edge feedback is behind the category rail');await until(()=>calls.filter(c=>c.method==='launcher.order.save').length>saves);console.log('FRAMELY_EDGE_SAVE_OK');await until(()=>!button('Done').disabled);button('Done').click();await until(()=>!document.querySelector('.launcher-editing'));
for(let n=0;n<9;n++)delete plugins[`edge.${n}`];redraw();window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:-1}));await wait(100);
console.log('FRAMELY_EDGE_DONE_OK');
// Framely and new app kinds use their own safe launch APIs.
document.querySelector<HTMLButtonElement>('.launch-icon[aria-label=Framely]')!.click();await until(()=>calls.some(c=>c.method==='host.manager.open'));
console.log('FRAMELY_SELF_LAUNCH_OK');localApps=[{id:'terminal.desktop',name:'Desktop terminal',kind:'desktop'},{id:'test/com.example.browser',name:'Android browser',kind:'lepton'}];window.dispatchEvent(new Event('framely.launcher.open'));await until(()=>document.querySelector('.launch-icon[aria-label="Desktop terminal"]'));
document.querySelector<HTMLButtonElement>('.launcher-filters button[aria-label="Desktop apps"]')!.click();await until(()=>document.querySelectorAll('.launch-icon').length===1);document.querySelector<HTMLButtonElement>('.launch-icon')!.click();await until(()=>calls.some(c=>c.method==='desktop.launch'&&c.params.app==='desktop:terminal.desktop'));
await until(()=>!document.querySelector<HTMLButtonElement>('.launch-icon')!.disabled);window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:2}));await until(()=>document.querySelector('.launcher-filters button[aria-label=Lepton]')!.getAttribute('aria-pressed')==='true');document.querySelector<HTMLButtonElement>('.launch-icon')!.click();await until(()=>calls.some(c=>c.method==='desktop.launch'&&c.params.app==='lepton:test/com.example.browser'));
const closes=calls.filter(c=>c.method==='host.launcher.close').length;document.querySelector('.launcher-shell')!.dispatchEvent(new MouseEvent('click',{bubbles:true}));window.dispatchEvent(new Event('framely.back'));await until(()=>calls.filter(c=>c.method==='host.launcher.close').length===closes+2);
settings=true;redraw();await wait();const switches=document.querySelectorAll<HTMLInputElement>('input[type=checkbox]');switches[0].click();await wait();if(!config.primaryTrigger)throw Error('Trigger setting not saved');await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_ZH');configureLanguage('en-US',[]);redraw();await wait(180);console.log('FRAMELY_PREVIEW_INSTALL_LAUNCHER_SETTINGS_EN');console.log('FRAMELY_BRIDGE_PASS');
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}};
createRoot(document.getElementById('root')!).render(<Fixture/>);wait(150).then(()=>console.log('FRAMELY_VIEW_READY'));
+36
View File
@@ -0,0 +1,36 @@
#include "launcher_input.h"
#include <cassert>
#include <cmath>
int main(){
LauncherStick stick;assert(!stick.update(1,0,true));assert(!stick.update(0,0,true));assert(stick.update(.8f,.75f,true)==1);assert(!stick.update(.4f,.9f,true));assert(!stick.update(0,0,true));assert(stick.update(-.7f,-.95f,true)==-2);assert(!stick.update(0,0,false));assert(!stick.update(0,1,true));assert(!stick.update(0,.2f,true));assert(stick.update(0,1,true)==2);assert(!stick.update(NAN,0,true));
vr::RenderModel_ComponentState_t joystick_rest{},joystick_x{},joystick_y{},joystick_current{};
joystick_rest.mTrackingToComponentRenderModel={{{1,0,0,0},{0,1,0,0},{0,0,1,0}}};joystick_x=joystick_y=joystick_current=joystick_rest;
auto turn=[](vr::RenderModel_ComponentState_t& value,float x,float y){auto& m=value.mTrackingToComponentRenderModel;float cx=std::cos(x),sx=std::sin(x),cy=std::cos(y),sy=std::sin(y);m={{{cy,sy*sx,sy*cx,0},{0,cx,-sx,0},{-sy,cy*sx,cy*cx,0}}};};
turn(joystick_x,0,.35f);turn(joystick_y,.35f,0);turn(joystick_current,-.28f,.14f);float joystick_dx=0,joystick_dy=0;assert(launcher_stick_axes(joystick_rest,joystick_x,joystick_y,joystick_current,joystick_dx,joystick_dy));assert(std::abs(joystick_dx-.4f)<.03f);assert(std::abs(joystick_dy+.8f)<.03f);assert(!launcher_stick_axes(joystick_rest,joystick_rest,joystick_rest,joystick_current,joystick_dx,joystick_dy));
auto now=std::chrono::steady_clock::now();EntryTrigger t;EntryPointer pointer;
pointer.move(1,320,64);assert(pointer.hit(1));assert(!pointer.hit(2));pointer.move(2,20,64);assert(!pointer.hit(2));pointer.move(2,320,64);assert(pointer.hit(2));pointer.leave();assert(!pointer.hit(2));
vr::HmdMatrix34_t tracked{{{0,0,1,1},{0,1,0,2},{-1,0,0,3}}},tip{{{1,0,0,.1f},{0,1,0,.2f},{0,0,1,.3f}}};auto composed=entry_tip_pose(tracked,tip);assert(std::abs(composed.m[0][3]-1.3f)<1e-6f);assert(std::abs(composed.m[1][3]-2.2f)<1e-6f);assert(std::abs(composed.m[2][3]-2.9f)<1e-6f);assert(composed.m[2][0]==-1);
vr::RenderModel_ComponentState_t rest{},full{},partial{};rest.mTrackingToComponentRenderModel={{{1,0,0,0},{0,1,0,0},{0,0,1,0}}};full=partial=rest;
auto rotate=[](vr::RenderModel_ComponentState_t& state,float angle){auto& m=state.mTrackingToComponentRenderModel;m.m[0][0]=m.m[2][2]=std::cos(angle);m.m[0][2]=std::sin(angle);m.m[2][0]=-std::sin(angle);};
rotate(full,.22f);rotate(partial,.121f);float pull=0;assert(entry_trigger_pull(rest,full,partial,pull));assert(std::abs(pull-.55f)<.001f);assert(!(partial.uProperties&vr::VRComponentProperty_IsPressed));EntryAnalogTrigger analog;assert(analog.update(pull));assert(analog.update(.47f));assert(!analog.update(.44f));assert(!analog.update(.49f));assert(analog.update(.51f));assert(!entry_trigger_pull(rest,rest,partial,pull));assert(!analog.update(NAN));
EntryAnalogTrigger sampledPull;EntryTrigger sampledClick;int activations=0,frame=0;
for(float sample:{0.f,.213733f,.378395f,.513260f,.718133f,.501608f,.345020f,.188268f,.0107336f,0.f}){bool opened=sampledClick.update(true,sampledPull.update(sample),true,now+std::chrono::milliseconds(25*frame));activations+=opened;if(frame==3)assert(opened);if(frame!=3)assert(!opened);frame++;}assert(activations==1);
EntryTrigger left,upper;assert(!left.update(true,false,true,now));assert(!upper.update(true,false,true,now));assert(left.update(true,true,true,now));assert(!left.update(true,true,true,now));assert(!left.update(true,false,true,now));assert(upper.update(true,true,true,now));assert(!upper.update(true,false,true,now));
// Dragging a held trigger into the target does not count as a new press.
assert(!t.update(true,false,false,now));assert(!t.update(true,true,false,now));assert(!t.update(true,true,true,now));assert(!t.update(true,false,true,now));
assert(t.update(true,true,true,now));assert(!t.update(true,true,true,now));assert(!t.update(true,false,true,now));
// Disconnect/reconnect while held cannot produce a stale activation.
now+=std::chrono::milliseconds(200);assert(!t.update(false,false,true,now));assert(!t.update(true,true,true,now));assert(!t.update(true,false,true,now));assert(t.update(true,true,true,now));assert(!t.update(true,false,true,now));
assert(!t.update(true,true,true,now));assert(!t.update(true,false,true,now));now+=std::chrono::milliseconds(200);assert(t.update(true,true,true,now));
// Reopening after a plugin quick page must rearm the modal guard each time.
LauncherDismissGuard guard;guard.opened(now);
assert(!guard.allows(now,true));assert(!guard.allows(now+std::chrono::milliseconds(400),true));
assert(guard.allows(now+std::chrono::milliseconds(400),false));assert(guard.allows(now+std::chrono::milliseconds(500),true));
now+=std::chrono::seconds(2);guard.opened(now);
assert(!guard.allows(now+std::chrono::milliseconds(20),false));
assert(!guard.allows(now+std::chrono::milliseconds(500),false,1.f));
assert(guard.allows(now+std::chrono::milliseconds(500),false,.1f));
// Releasing the opening trigger rearms input once, even without mouse events.
guard.opened(now);guard.observe(true);guard.observe(false);
assert(guard.allows(now+std::chrono::milliseconds(700),true));
}
+24
View File
@@ -0,0 +1,24 @@
#include "../native/launcher_input.h"
#include <chrono>
#include <iostream>
#include <thread>
int main(int argc,char** argv){if(argc!=2)return 2;vr::EVRInitError error;auto* system=vr::VR_Init(&error,vr::VRApplication_Overlay);if(error){std::cerr<<"VR_Init: "<<error<<"\n";return 3;}auto* input=vr::VRInput();auto result=input->SetActionManifestPath(argv[1]);std::cout<<"manifest="<<result<<"\n";vr::VRActionSetHandle_t set=0;vr::VRActionHandle_t primary=0,upper=0,aim=0;bool ok=result==vr::VRInputError_None&&input->GetActionSetHandle("/actions/entry",&set)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/primary",&primary)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/upper",&upper)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/aim",&aim)==vr::VRInputError_None;
for(int i=0;i<20&&ok;i++){vr::VRActiveActionSet_t active{};active.ulActionSet=set;result=input->UpdateActionState(&active,sizeof(active),1);if(result!=vr::VRInputError_None){std::cerr<<"update="<<result<<"\n";ok=false;break;}std::this_thread::sleep_for(std::chrono::milliseconds(100));}
for(auto path:{"/user/hand/left","/user/hand/right"}){vr::VRInputValueHandle_t hand=0;input->GetInputSourceHandle(path,&hand);vr::InputDigitalActionData_t a{},b{};vr::InputPoseActionData_t pose{};auto e1=input->GetDigitalActionData(primary,&a,sizeof(a),hand),e2=input->GetDigitalActionData(upper,&b,sizeof(b),hand),e3=input->GetPoseActionDataRelativeToNow(aim,vr::TrackingUniverseStanding,0,&pose,sizeof(pose),hand);std::cout<<path<<" primary="<<e1<<"/"<<a.bActive<<" upper="<<e2<<"/"<<b.bActive<<" pose="<<e3<<"/"<<pose.bActive<<"/"<<pose.pose.bPoseIsValid<<" state="<<a.bState<<"/"<<b.bState<<"\n";
vr::VROverlayHandle_t button=0;auto* overlays=vr::VROverlay();if(!overlays->FindOverlay("framely.dock.button",&button)){
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){ray.vSource.v[r]=pose.pose.mDeviceToAbsoluteTracking.m[r][3];ray.vDirection.v[r]=-pose.pose.mDeviceToAbsoluteTracking.m[r][2];}vr::VROverlayIntersectionResults_t hit{};bool intersects=overlays->ComputeOverlayIntersection(button,&ray,&hit);std::cout<<"dock visible="<<overlays->IsOverlayVisible(button)<<" hover="<<overlays->IsHoverTargetOverlay(button)<<" intersection="<<intersects<<" uv="<<hit.vUVs.v[0]<<","<<hit.vUVs.v[1]<<"\n";
}}
for(unsigned i=0;i<vr::k_unMaxTrackedDeviceCount;i++){if(system->GetTrackedDeviceClass(i)!=vr::TrackedDeviceClass_Controller)continue;char type[256]{};system->GetStringTrackedDeviceProperty(i,vr::Prop_ControllerType_String,type,sizeof(type));std::cout<<"controller="<<i<<" type="<<type<<"\n";}
float previousPull[2]{-1,-1};
for(int tick=0;tick<2400&&ok;tick++){
vr::VRActiveActionSet_t active{};active.ulActionSet=set;input->UpdateActionState(&active,sizeof(active),1);
if(tick%80==0){vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);std::cout<<"tracking";for(unsigned d=0;d<3;d++)std::cout<<" "<<d<<":"<<poses[d].bDeviceIsConnected<<"/"<<poses[d].bPoseIsValid;std::cout<<std::endl;}
for(auto path:{"/user/hand/left","/user/hand/right"}){vr::VRInputValueHandle_t hand=0;input->GetInputSourceHandle(path,&hand);vr::InputDigitalActionData_t a{},b{};input->GetDigitalActionData(primary,&a,sizeof(a),hand);input->GetDigitalActionData(upper,&b,sizeof(b),hand);
auto device=system->GetTrackedDeviceIndexForControllerRole(std::string(path).find("right")!=std::string::npos?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);char model[256]{};system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t primaryState{},upperState{};bool c1=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"trigger",hand,&mode,&primaryState),c2=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"bumper",hand,&mode,&upperState);vr::VRControllerState_t restInput{},fullInput{};fullInput.rAxis[1].x=1;vr::RenderModel_ComponentState_t rest{},full{};float pull=-1;bool calibrated=c1&&vr::VRRenderModels()->GetComponentState(model,"trigger",&restInput,&mode,&rest)&&vr::VRRenderModels()->GetComponentState(model,"trigger",&fullInput,&mode,&full)&&entry_trigger_pull(rest,full,primaryState,pull);int side=std::string(path).find("right")!=std::string::npos?1:0;bool changed=std::abs(pull-previousPull[side])>.05f;previousPull[side]=pull;
if(tick%80==0||changed)std::cout<<path<<" components="<<c1<<"/"<<c2<<" pressed="<<bool(primaryState.uProperties&vr::VRComponentProperty_IsPressed)<<"/"<<bool(upperState.uProperties&vr::VRComponentProperty_IsPressed)<<" calibrated="<<calibrated<<" pull="<<pull<<" actions="<<a.bActive<<"/"<<b.bActive<<"\n";
if(a.bChanged||b.bChanged){vr::InputPoseActionData_t pose{};input->GetPoseActionDataRelativeToNow(aim,vr::TrackingUniverseStanding,0,&pose,sizeof(pose),hand);std::cout<<path<<" primary="<<a.bState<<" upper="<<b.bState<<" pose="<<pose.pose.bPoseIsValid;
vr::VROverlayHandle_t overlay=0;auto* overlays=vr::VROverlay();if(!overlays->FindOverlay("framely.dock.button",&overlay)){vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){ray.vSource.v[r]=pose.pose.mDeviceToAbsoluteTracking.m[r][3];ray.vDirection.v[r]=-pose.pose.mDeviceToAbsoluteTracking.m[r][2];}vr::VROverlayIntersectionResults_t hit{};bool intersects=overlays->ComputeOverlayIntersection(overlay,&ray,&hit);std::cout<<" hover="<<overlays->IsHoverTargetOverlay(overlay)<<" intersection="<<intersects<<" uv="<<hit.vUVs.v[0]<<","<<hit.vUVs.v[1];}std::cout<<std::endl;}}
std::this_thread::sleep_for(std::chrono::milliseconds(25));
}
vr::VR_Shutdown();return ok?0:4;}
+10
View File
@@ -0,0 +1,10 @@
#include "launcher_pointer.h"
#include <cassert>
int main(){auto now=std::chrono::steady_clock::now();LauncherPointer pointer;int x=0,y=0;
pointer.begin(100,200,now);x=122;y=211;assert(pointer.constrain(x,y,now+std::chrono::milliseconds(180)));assert(x==100&&y==200);
// A 600 ms long press remains stable across many different jitter samples.
for(int ms=100;ms<=900;ms+=100){x=100+(ms%35);y=190;assert(pointer.constrain(x,y,now+std::chrono::milliseconds(ms)));assert(x==100&&y==200);}
x=126;y=215;assert(pointer.constrain(x,y,now+std::chrono::milliseconds(1100)));pointer.clear();assert(!pointer.holding(now));
pointer.begin(100,200,now);x=160;y=200;assert(!pointer.constrain(x,y,now+std::chrono::milliseconds(100)));assert(x==160);x=100;assert(!pointer.constrain(x,y,now+std::chrono::milliseconds(200)));
pointer.begin(100,200,now);x=100;y=200;assert(!pointer.constrain(x,y,now+std::chrono::seconds(3)));pointer.clear();assert(!pointer.constrain(x,y,now));
}
+25
View File
@@ -0,0 +1,25 @@
#include "paint_buffer.h"
#include <cassert>
struct Rect{int x,y,width,height;};
int main(){
std::vector<uint8_t> source(4*3*4,80),normal,flipped;Rect full{0,0,4,3};
merge_paint(normal,source.data(),4,3,1,&full);merge_paint(flipped,source.data(),4,3,1,&full,true);
// A partial repaint may contain unrelated pixels outside the dirty area.
std::fill(source.begin(),source.end(),0);source[(1*4+2)*4]=200;Rect dirty{2,1,1,1};
merge_paint(normal,source.data(),4,3,1,&dirty);merge_paint(flipped,source.data(),4,3,1,&dirty,true);
assert(normal[0]==80&&normal[(1*4+2)*4]==200);assert(flipped[0]==80&&flipped[(1*4+2)*4]==200);
source[(0*4+1)*4]=150;Rect top{1,0,1,1};merge_paint(flipped,source.data(),4,3,1,&top,true);assert(flipped[(2*4+1)*4]==150);assert(flipped[1*4]==80);
Rect clipped{-1,-1,2,2};merge_paint(normal,source.data(),4,3,1,&clipped);assert(normal[0]==0&&normal[4]==80);
// Simulate alternating GPU textures: a texture must catch up on all paints
// since its last upload, including damage already sent to the other texture.
PaintDamage pending[2];std::vector<uint8_t> gpu[2];
source.assign(4*3*4,80);flipped.clear();
auto paint=[&](Rect r){merge_paint(flipped,source.data(),4,3,1,&r,true);for(auto& d:pending)d.add(4,3,1,&r);};
auto upload=[&](int slot){auto& d=pending[slot];if(gpu[slot].empty()){gpu[slot].resize(flipped.size());d.include(0,0,4,3);}for(int y=d.y;y<d.y+d.height;y++)std::copy_n(flipped.data()+(y*4+d.x)*4,d.width*4,gpu[slot].data()+(y*4+d.x)*4);d.clear();assert(gpu[slot]==flipped);};
paint(full);upload(0);upload(1);
source[(0*4+1)*4]=150;paint(top);assert(pending[0].x==1&&pending[0].y==2&&pending[0].width==1&&pending[0].height==1);upload(0);
source[(1*4+2)*4]=200;paint(dirty);upload(1);upload(0);
// Multiple CEF callbacks before a submission, clipping and missing dirty lists.
source[0]=25;paint(clipped);source[(2*4+3)*4]=35;paint(Rect{3,2,1,1});upload(0);upload(1);
PaintDamage bounds;bounds.add(4,3,0,static_cast<Rect*>(nullptr));assert(bounds.width==4&&bounds.height==3);bounds.clear();Rect outside{9,9,3,3};bounds.add(4,3,1,&outside);assert(bounds.empty());
}
+8
View File
@@ -0,0 +1,8 @@
#include "render_timing.h"
#include <cassert>
#include <limits>
int main(){
assert(browser_frame_rate(72)==72);assert(browser_frame_rate(90)==90);assert(browser_frame_rate(120)==120);
assert(browser_frame_rate(0)==90);assert(browser_frame_rate(-1)==90);assert(browser_frame_rate(std::numeric_limits<float>::quiet_NaN())==90);
assert(browser_frame_rate(30)==60);assert(browser_frame_rate(1000)==144);
}
+60
View File
@@ -0,0 +1,60 @@
import base64
import importlib.util
import io
import json
import pathlib
import tempfile
import unittest
import zipfile
spec = importlib.util.spec_from_file_location('diagnostics', pathlib.Path(__file__).parents[1] / 'tools/export-diagnostics.py')
diag = importlib.util.module_from_spec(spec)
spec.loader.exec_module(diag)
class DiagnosticsTests(unittest.TestCase):
def archive(self, root):
result = diag.collect(root, commands=False, runtime=root / 'runtime')
self.assertTrue(result['name'].endswith('.zip'))
return zipfile.ZipFile(io.BytesIO(base64.b64decode(result['data'])))
def test_stopped_service_partial_logs_and_credentials(self):
with tempfile.TemporaryDirectory() as temp:
root = pathlib.Path(temp)
(root / 'logs').mkdir()
(root / 'current').mkdir()
(root / 'current/VERSION').write_text('0.4.3-preview.2-test')
(root / 'logs/demo.log').write_text('password=private token=secret\nAuthorization: Bearer abc.def\n{"password":"hidden"}\nhttps://user:pass@example.org\nUseful error\n')
(root / 'network-password.json').write_text('do-not-export')
(root / 'state.json').write_text(json.dumps({'plugins': {'demo': {'manifest': {'name':'Demo', 'version':'1.0.0'}, 'enabled': True, 'config': {'secret':'do-not-export'}}}, 'proxy': {'http': 'do-not-export'}}))
archive = self.archive(root)
text = '\n'.join(archive.read(n).decode() for n in archive.namelist())
for value in ('private', 'abc.def', 'hidden', 'user:pass', 'do-not-export'):
self.assertNotIn(value, text)
self.assertIn('Useful error', text)
self.assertIn('0.4.3-preview.2-test', text)
self.assertIn('Demo', text)
self.assertNotIn('network-password.json', archive.namelist())
def test_corrupt_state_and_missing_logs_still_export(self):
with tempfile.TemporaryDirectory() as temp:
root = pathlib.Path(temp)
(root / 'state.json').write_text('invalid')
archive = self.archive(root)
self.assertTrue(json.loads(archive.read('report.json'))['warnings'])
self.assertIn('system.json', archive.namelist())
def test_large_logs_and_symlinks(self):
with tempfile.TemporaryDirectory() as temp:
root = pathlib.Path(temp)
(root / 'logs').mkdir()
(root / 'secret').write_text('DO_NOT_FOLLOW')
(root / 'logs/link.log').symlink_to(root / 'secret')
(root / 'logs/large.log').write_text('X' * (diag.MAX_FILE * 2) + '\nLAST ERROR')
archive = self.archive(root)
self.assertNotIn('logs/link.log', archive.namelist())
data = archive.read('logs/large.log')
self.assertLessEqual(len(data), diag.MAX_FILE)
self.assertTrue(data.endswith(b'LAST ERROR'))
if __name__ == '__main__':
unittest.main()
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Collect a bounded diagnostic ZIP without requiring a running Framely service."""
import argparse
import base64
import datetime
import io
import json
import os
import pathlib
import platform
import re
import subprocess
import zipfile
MAX_FILE = 256 * 1024
MAX_TOTAL = 8 * 1024 * 1024
SECRET = re.compile(r'(?i)((?:password|passwd|token|secret|authorization|api[_-]?key)[\"\']?\s*[=:]\s*)(?:"[^"]*"|\'[^\']*\'|[^\s,;]+)')
URL_CREDENTIALS = re.compile(r'(https?://)[^/\s:@]+:[^/\s@]+@')
def redact(text):
text = re.sub(r'(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+', 'Bearer [redacted]', text)
text = SECRET.sub(r'\1[redacted]', text)
text = re.sub(r'(/boot/)[a-f0-9]{32,}', r'\1[redacted]', text)
return URL_CREDENTIALS.sub(r'\1[redacted]@', text)
def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_log=None):
report = {'format': 1, 'createdAt': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'warnings': []}
entries = {}
budget = MAX_TOTAL
def add(name, data):
nonlocal budget
data = redact(data).encode('utf-8', errors='replace')
if len(data) > MAX_FILE:
report['warnings'].append(name + ': truncated to recent output')
data = data[-MAX_FILE:]
if len(data) > budget:
report['warnings'].append(name + ': omitted (archive budget)')
return
entries[name] = data
budget -= len(data)
def tail(path, name):
try:
if path.is_symlink() or not path.is_file():
return
with path.open('rb') as source:
source.seek(max(0, os.fstat(source.fileno()).st_size - MAX_FILE))
add(name, source.read(MAX_FILE).decode('utf-8', errors='replace'))
except (OSError, ValueError) as error:
report['warnings'].append(name + ': ' + str(error))
def command(name, args):
try:
# Temporary files avoid buffering unbounded journal output in memory.
import tempfile
with tempfile.TemporaryFile() as output:
result = subprocess.run(args, stdout=output, stderr=subprocess.STDOUT, timeout=8, check=False)
output.seek(0, 2)
output.seek(max(0, output.tell() - MAX_FILE))
add(name, output.read(MAX_FILE).decode('utf-8', errors='replace'))
if result.returncode:
report['warnings'].append(name + ': command exited ' + str(result.returncode))
except (OSError, subprocess.TimeoutExpired) as error:
report['warnings'].append(name + ': ' + str(error))
if installer_log is not None:
add('installer.log', installer_log)
add('system.json', json.dumps({'system': platform.system(), 'release': platform.release(), 'machine': platform.machine()}, indent=2))
for name in ('current', 'previous'):
tail(state / name / 'VERSION', 'versions/' + name + '.txt')
tail(state / 'previous-release', 'versions/previous-release.txt')
# Only a summary is included; plugin configuration, proxy credentials,
# inbox contents and authentication files must never enter the archive.
try:
database = json.loads((state / 'state.json').read_text())
plugins = database.get('plugins', {})
add('plugins.json', json.dumps({'safeMode': database.get('safeMode'), 'plugins': [
{'id': key, 'name': value.get('manifest', {}).get('name'), 'version': value.get('manifest', {}).get('version'), 'enabled': value.get('enabled'), 'error': value.get('error')}
for key, value in plugins.items()
]}, ensure_ascii=False, indent=2))
except (OSError, ValueError, TypeError, AttributeError) as error:
report['warnings'].append('plugins.json: ' + str(error))
try:
for path in sorted((state / 'logs').glob('*.log'))[:64]:
tail(path, 'logs/' + path.name)
for path in sorted(runtime.glob('*/framely-session-*/cef.log'))[-8:]:
tail(path, 'renderer/' + path.parent.parent.name + '-' + path.parent.name + '-cef.log')
except OSError as error:
report['warnings'].append('logs: ' + str(error))
# Record GPU evidence without copying command lines or authentication tokens.
gpu = []
if commands:
for process in pathlib.Path('/proc').glob('[0-9]*'):
try:
if pathlib.Path(os.readlink(process / 'exe')).name != 'framely-vr':
continue
arguments = (process / 'cmdline').read_bytes()
match = re.search(rb'--type=([a-z-]+)', arguments)
kind = match.group(1).decode() if match else 'browser'
info = {'pid': int(process.name), 'type': kind, 'gpuDisabled': b'--disable-gpu' in arguments.replace(b'\0', b' ').split()}
if kind == 'gpu-process':
maps = (process / 'maps').read_text(errors='replace')
info['drivers'] = sorted(set(line.split()[-1] for line in maps.splitlines() if any(word in line.lower() for word in ('freedreno', 'adreno', 'swiftshader', 'llvmpipe', 'vulkan', 'libegl', 'libgles'))))
devices = []
for fd in (process / 'fd').iterdir():
try:
target = os.readlink(fd)
if target.startswith('/dev/dri/') or target == '/dev/kgsl-3d0':
devices.append(target)
except OSError:
pass
info['devices'] = sorted(set(devices))
gpu.append(info)
except (OSError, ValueError):
continue
add('renderer/gpu.json', json.dumps(gpu, indent=2))
if commands:
command('services.txt', ['systemctl', 'status', 'framely.service', 'framely-session.service', '--no-pager', '--full'])
command('journal.txt', ['journalctl', '-u', 'framely.service', '-u', 'framely-session.service', '-u', 'framely-plugin-*', '-u', 'framely-backend-*', '-u', 'framely-hook-*', '--since=-24h', '-n', '3000', '--no-pager', '-o', 'short-iso'])
add('README.txt', 'Framely diagnostics. Recent log tails, service state and installed version summary only. Missing sources are listed in report.json. Logs may contain application data; inspect before sharing.\n')
entries['report.json'] = json.dumps(report, ensure_ascii=False, indent=2).encode()
memory = io.BytesIO()
with zipfile.ZipFile(memory, 'w', zipfile.ZIP_DEFLATED) as archive:
for name, data in entries.items():
archive.writestr(name, data)
name = 'framely-logs-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d-%H%M%S') + '.zip'
return {'name': name, 'data': base64.b64encode(memory.getvalue()).decode('ascii')}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--state', type=pathlib.Path)
parser.add_argument('--output', type=pathlib.Path)
parser.add_argument('--installer-log-base64')
args = parser.parse_args()
state = args.state or (pathlib.Path('/home/.framely/state') if pathlib.Path('/home/.framely/state').is_dir() else pathlib.Path('/var/lib/framely'))
extra = base64.b64decode(args.installer_log_base64).decode('utf-8', errors='replace') if args.installer_log_base64 else None
result = collect(state, installer_log=extra)
if args.output:
with args.output.open('xb') as output:
os.chmod(args.output, 0o600)
output.write(base64.b64decode(result['data']))
print(args.output)
else:
print(json.dumps(result))
if __name__ == '__main__':
main()
+2 -1
View File
@@ -24,6 +24,7 @@ rm -f "$stage/lib/cef/framely-probe" "$stage/lib/cef/framely-browser-probe"
strip --strip-debug "$stage/lib/cef/libcef.so"
cp native/vendor/openvr/lib/linuxarm64/libopenvr_api.so "$stage/lib/openvr/"
cp -a ui/dist/. "$stage/share/ui/"
cp -a native/input "$stage/share/"
cp "$cef/LICENSE.txt" "$stage/share/licenses/cef.txt"
cp native/vendor/openvr/LICENSE "$stage/share/licenses/openvr.txt"
cp node_modules/@tabler/icons-react/LICENSE "$stage/share/licenses/tabler-icons.txt"
@@ -44,7 +45,7 @@ cp -a assets/branding "$stage/tools/source/assets/"
cp -a examples/showcase "$stage/tools/source/examples/"
cp -a ui/src ui/locales ui/index.html ui/package.json "$stage/tools/source/ui/"
cp tools/build-ui.mjs tools/build-branding.mjs tools/plugin-dev.mjs "$stage/tools/source/tools/"
cp tools/extract-release.py tools/bootstrap.py tools/cef-runtime.py "$stage/tools/"
cp tools/extract-release.py tools/bootstrap.py tools/cef-runtime.py tools/export-diagnostics.py "$stage/tools/"
find "$stage/tools/source" -type d -name __pycache__ -prune -exec rm -rf -- {} +
cp -a docs "$stage/share/"
printf '%s\n' "$build_id" > "$stage/VERSION"
+2 -2
View File
@@ -22,13 +22,13 @@ if(command==='init'){
}else if(command==='build'||command==='dev'){
const {build,context}=await import('esbuild');const root=process.cwd();await mkdir(path.join(root,'payload'),{recursive:true});
let revision=0;const clients=new Set();
const options={entryPoints:[path.join(root,'page.tsx')],bundle:true,outfile:path.join(root,'payload/page.js'),minify:command==='build',sourcemap:command==='dev',define:{'process.env.NODE_ENV':JSON.stringify(command==='dev'?'development':'production')},plugins:[{name:'framely-preview',setup(builder){builder.onEnd(async result=>{if(!result.errors.length){await cp(path.join(root,'backend.py'),path.join(root,'payload/backend.py'));await cp(fileURLToPath(import.meta.resolve('@framely/sdk/python')),path.join(root,'payload/framely.py'));if(revision++)for(const client of clients)client.write('data: reload\n\n');}})}}]};
const options={entryPoints:{page:path.join(root,'page.tsx'),...(existsSync(path.join(root,'actions.ts'))?{actions:path.join(root,'actions.ts')}:{})},bundle:true,outdir:path.join(root,'payload'),minify:command==='build',sourcemap:command==='dev',define:{'process.env.NODE_ENV':JSON.stringify(command==='dev'?'development':'production')},plugins:[{name:'framely-preview',setup(builder){builder.onEnd(async result=>{if(!result.errors.length){await cp(path.join(root,'backend.py'),path.join(root,'payload/backend.py'));await cp(fileURLToPath(import.meta.resolve('@framely/sdk/python')),path.join(root,'payload/framely.py'));if(revision++)for(const client of clients)client.write('data: reload\n\n');}})}}]};
if(command==='build'){await build(options);console.log('Built payload. Package using framely pack.');}
else{
const ctx=await context(options);await ctx.watch();
const server=http.createServer(async(req,res)=>{try{
if(req.url==='/__framely/events'){res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache'});res.write('data: connected\n\n');clients.add(res);req.on('close',()=>clients.delete(res));return;}
if(req.url==='/'||req.url==='/quick'||req.url?.startsWith('/window/')||req.url?.startsWith('/?')){res.setHeader('Content-Type','text/html');res.end(`<!doctype html><meta charset="utf-8"><div id="root"></div><script>window.__framelyBridge={request:async(op,p)=>{if(op==='window.open'){window.open('/?window='+encodeURIComponent(p.window),'_blank');return true;}if(op==='window.close'){window.close();return true;}if(op==='notification.send'){alert(p.notification.title+'\\n'+p.notification.body);return true;}if(op==='dependencies')return [];if(op==='notification.remove'||op==='notification.dismiss'||op==='haptic'||op==='keyboard')return true;throw Error('浏览器预览不启动后端,请安装到 Frame 测试此调用。');},subscribe:()=>()=>{}};new EventSource('/__framely/events').onmessage=e=>{if(e.data==='reload')location.reload()};history.replaceState(null,'',location.pathname.startsWith('/window/')?location.pathname:location.search.includes('window=')?'/window/'+new URLSearchParams(location.search).get('window'):'/quick');</script><script src="/page.js"></script>`);return;}
if(req.url==='/'||req.url==='/quick'||req.url?.startsWith('/window/')||req.url?.startsWith('/?')){res.setHeader('Content-Type','text/html');res.end(`<!doctype html><meta charset="utf-8"><div id="root"></div><script>window.__framelyBridge={request:async(op,p)=>{if(op==='window.open'){window.open('/?window='+encodeURIComponent(p.window),'_blank');return true;}if(op==='window.close'){window.close();return true;}if(op==='notification.send'){alert(p.notification.title+'\\n'+p.notification.body);return true;}if(op==='ui.launchContext')return {source:'quickPanel',trigger:'shortPress'};if(op==='dependencies')return [];if(op==='notification.remove'||op==='notification.dismiss'||op==='haptic'||op==='keyboard')return true;throw Error('浏览器预览不启动后端,请安装到 Frame 测试此调用。');},subscribe:()=>()=>{}};new EventSource('/__framely/events').onmessage=e=>{if(e.data==='reload')location.reload()};history.replaceState(null,'',location.pathname.startsWith('/window/')?location.pathname:location.search.includes('window=')?'/window/'+new URLSearchParams(location.search).get('window'):'/quick');</script><script src="/page.js"></script>`);return;}
const name=req.url?.split('?')[0];if(name!=='/page.js'&&name!=='/page.js.map'){res.writeHead(404);res.end();return;}res.setHeader('Content-Type',name.endsWith('.map')?'application/json':'application/javascript');res.end(await readFile(path.join(root,'payload',name.slice(1))));
}catch(e){res.writeHead(500);res.end(String(e));}});
server.listen(Number(process.env.FRAMELY_DEV_PORT??5173),'127.0.0.1',()=>console.log(`Preview: http://127.0.0.1:${server.address().port} (no device privileges)`));
+17
View File
@@ -0,0 +1,17 @@
//! Diagnostic: enumerate local Steam libraries using the same code as the session agent.
#[path = "../src/steam.rs"] mod steam;
mod process {
pub fn tool(name:&str)->std::process::Command {std::process::Command::new(name)}
pub fn user_home(uid: u32) -> anyhow::Result<String> {
for line in std::fs::read_to_string("/etc/passwd")?.lines() {
let fields: Vec<_> = line.split(':').collect();
if fields.len() >= 7 && fields[2] == uid.to_string() { return Ok(fields[5].into()); }
}
anyhow::bail!("User home not found")
}
}
fn main() -> anyhow::Result<()> {
let apps=steam::discover(&steam::home()?);
for app in &apps { println!("{}\t{}\ticon={}",app.id,app.name,app.icon.is_some()); }
println!("apps={}",apps.len());Ok(())
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
cef=$(realpath "$1")
cd "$base"
out=$(mktemp -d "${TMPDIR:-/tmp}/framely-launcher.XXXXXX")
server_pid=''
cleanup() { if [[ -n $server_pid ]]; then kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; fi; }
trap cleanup EXIT
export FRAMELY_INSTALL_REVIEW_OUTPUT="$out"
export FRAMELY_INSTALL_REVIEW_FIXTURE="${2:-tests/launcher_fixture.tsx}"
node --input-type=module - <<'JS'
import {build} from 'esbuild';
import {writeFile} from 'node:fs/promises';
const out=process.env.FRAMELY_INSTALL_REVIEW_OUTPUT;
await build({entryPoints:[process.env.FRAMELY_INSTALL_REVIEW_FIXTURE],bundle:true,outfile:`${out}/fixture.js`,define:{'process.env.NODE_ENV':'"production"'}});
await build({entryPoints:['tests/launcher_action_fixture.ts'],bundle:true,outfile:`${out}/action.js`,define:{'process.env.NODE_ENV':'"production"'}});
await build({entryPoints:['sdk/src/bootstrap.ts'],bundle:true,outfile:`${out}/bootstrap.js`});
await writeFile(`${out}/index.html`,'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="fixture.css"><div id="root"></div><script src="fixture.js"></script></html>');
JS
mkdir -p "$out/assets"
cp -a assets/branding "$out/assets/branding"
if [[ -n ${FRAMELY_LAUNCHER_PREVIEW_ASSETS:-} ]]; then cp -a "$FRAMELY_LAUNCHER_PREVIEW_ASSETS" "$out/preview-icons"; fi
python3 - "$out" > "$out/server.log" 2>&1 <<'PY' &
import functools,http.server,pathlib,sys
root=pathlib.Path(sys.argv[1])
class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
if self.path.startswith('/plugin-assets/'):
icon='termix' if '/demo.alpha/' in self.path else 'passthrough'
self.path='/preview-icons/'+icon+'.png'
return super().do_GET()
if self.path.startswith('/plugin-action-frame/'):
data=b"<!doctype html><div id=root></div><script src='/bootstrap.js'></script><script src='/action.js'></script>"
self.send_response(200);self.send_header('Content-Type','text/html');self.end_headers();self.wfile.write(data)
else:super().do_GET()
server=http.server.ThreadingHTTPServer(('127.0.0.1',0),functools.partial(Handler,directory=str(root)))
(root/'port').write_text(str(server.server_port))
server.serve_forever()
PY
server_pid=$!
for _ in {1..100}; do [[ ! -f $out/port ]] || break; sleep 0.05; done
port=$(cat "$out/port")
cp -a "$cef/Resources/." "$cef/Release/"
g++ -std=c++17 -O2 -I"$cef" tests/browser_probe.cpp -L"$cef/Release" -lcef -pthread '-Wl,-rpath,$ORIGIN' -o "$cef/Release/framely-browser-probe"
for mode in desktop; do
mkdir "$out/$mode"
export FRAMELY_CEF_ROOT="$cef" FRAMELY_LAUNCHER_TEST=1 FRAMELY_INSTALL_REVIEW_TEST=1 FRAMELY_PREVIEW_DIR="$out/$mode" GSETTINGS_BACKEND=memory
if [[ $mode == desktop ]]; then export FRAMELY_STORE_TEST=1; else unset FRAMELY_STORE_TEST; fi
if ! "$cef/Release/framely-browser-probe" "http://127.0.0.1:$port" "$out/$mode-runtime" --ozone-platform=headless --disable-gpu > "$out/$mode.log" 2>&1; then
cat "$out/$mode.log"; exit 1
fi
tail -n 1 "$out/$mode.log"
done
printf 'Launcher previews: %s\n' "$out"
+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; do
for test in menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_pointer 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
+38 -2
View File
@@ -432,7 +432,43 @@
"搜索应用": "Search apps",
"全局开关优先;关闭全局开关不会改变应用偏好。": "Global switches take priority. App preferences are kept when a global switch is off.",
"应用": "App",
"未找到应用": "No apps found",
"本体通知,包括检查更新提醒。": "Framely notifications, including update reminders."
"未找到应用": "No matching apps",
"本体通知,包括检查更新提醒。": "Framely notifications, including update reminders.",
"启动台": "Launcher",
"主扳机打开启动台": "Open launcher with the primary trigger",
"开启后,上扳机打开快捷面板。": "When enabled, the upper trigger opens the quick panel.",
"长按菜单自动关闭": "Automatically close the action menu",
"指针离开后开始计时,移入后取消。": "Start the timer when the pointer leaves; cancel it when the pointer returns.",
"自动关闭时间(秒)": "Close delay (seconds)",
"Steam": "Steam",
"快捷面板": "Quick panel",
"卸载插件": "Uninstall plugin",
"正在执行…": "Running…",
"插件没有快捷页面": "This plugin has no quick page",
"没有可启动的应用": "No apps available",
"正在读取 Steam 应用…": "Reading Steam apps…",
"长按插件图标查看操作": "Hold a plugin icon to see actions",
"卸载 {0}?": "Uninstall {0}?",
"依赖此插件的应用也会停用;插件数据会保留。": "Dependent plugins will also be disabled. Plugin data will be kept.",
"确认卸载": "Confirm uninstall",
"收藏": "Favorite",
"取消收藏": "Remove favorite",
"要求 Framely:{0}": "Requires Framely: {0}",
" · 不兼容": " · Incompatible",
"分页": "Pages",
"第 {0} 页": "Page {0}",
"桌面程序": "Desktop apps",
"拖动图标排列,移到两侧翻页": "Drag icons to rearrange; move to either edge to turn pages",
"完成": "Done",
"整理图标": "Arrange icons",
"正在读取本机应用…": "Reading installed apps…",
"Lepton": "Lepton",
"打开": "Open",
"导出日志": "Export logs",
"正在导出日志…": "Exporting logs…",
"日志已保存到:{0}": "Logs saved to: {0}",
"日志包已生成,请查看浏览器下载。": "Log archive created. Check your browser downloads.",
"日志包缺少数据": "Log archive data is missing",
"导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "Export Framely, plugin and renderer logs with service status. Plugin settings and access password files are excluded."
}
}
+37 -1
View File
@@ -433,6 +433,42 @@
"全局开关优先;关闭全局开关不会改变应用偏好。": "全局开关优先;关闭全局开关不会改变应用偏好。",
"应用": "应用",
"未找到应用": "未找到应用",
"本体通知,包括检查更新提醒。": "本体通知,包括检查更新提醒。"
"本体通知,包括检查更新提醒。": "本体通知,包括检查更新提醒。",
"启动台": "启动台",
"主扳机打开启动台": "主扳机打开启动台",
"开启后,上扳机打开快捷面板。": "开启后,上扳机打开快捷面板。",
"长按菜单自动关闭": "长按菜单自动关闭",
"指针离开后开始计时,移入后取消。": "指针离开后开始计时,移入后取消。",
"自动关闭时间(秒)": "自动关闭时间(秒)",
"Steam": "Steam",
"快捷面板": "快捷面板",
"卸载插件": "卸载插件",
"正在执行…": "正在执行…",
"插件没有快捷页面": "插件没有快捷页面",
"没有可启动的应用": "没有可启动的应用",
"正在读取 Steam 应用…": "正在读取 Steam 应用…",
"长按插件图标查看操作": "长按插件图标查看操作",
"卸载 {0}?": "卸载 {0}?",
"依赖此插件的应用也会停用;插件数据会保留。": "依赖此插件的应用也会停用;插件数据会保留。",
"确认卸载": "确认卸载",
"收藏": "收藏",
"取消收藏": "取消收藏",
"要求 Framely:{0}": "要求 Framely:{0}",
" · 不兼容": " · 不兼容",
"分页": "分页",
"第 {0} 页": "第 {0} 页",
"桌面程序": "桌面程序",
"拖动图标排列,移到两侧翻页": "拖动图标排列,移到两侧翻页",
"完成": "完成",
"整理图标": "整理图标",
"正在读取本机应用…": "正在读取本机应用…",
"Lepton": "Lepton",
"打开": "打开",
"导出日志": "导出日志",
"正在导出日志…": "正在导出日志…",
"日志已保存到:{0}": "日志已保存到:{0}",
"日志包已生成,请查看浏览器下载。": "日志包已生成,请查看浏览器下载。",
"日志包缺少数据": "日志包缺少数据",
"导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。"
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import {t} from './i18n';
import {installKeyboard} from '../../sdk/src/keyboard';
import {installHoverFeedback} from '../../sdk/src/hover';
export async function api<T=any>(method:string,params:unknown={}):Promise<T>{const r=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method,params})});if(r.status===401&&r.headers.get('X-Framely-Reauthenticate')==='1')location.replace('/manager');if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));return v.result;}
export function viewKey(){const p=location.pathname.split('/');return p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='notifications'?'notifications':'menu';}
export function viewKey(){const p=location.pathname.split('/');return p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';}
installKeyboard(params=>api('host.keyboard',{view:viewKey(),...params}));
+62
View File
@@ -0,0 +1,62 @@
/* A transparent spatial surface, not a DOM backdrop-blur of the real world.
Layer order: content 0, categories 2, context menu 3, feedback 4, dialog 5. */
.launcher-view,.launcher-view body,.launcher-view #root{background:transparent!important}
.launcher-shell{--launch-ink:#eff4f8;--launch-blue:#b4dbf4;--launch-ease:cubic-bezier(.2,.75,.2,1);height:100dvh;min-height:600px;padding:90px 60px 104px;color:var(--launch-ink);background:transparent;display:flex;flex-direction:column;position:relative;overflow:hidden;font-size:20px}
.launcher-tools{position:absolute;left:0;top:calc(50% - 7px);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)}
.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}
.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% - 7px);height:570px;width:calc(100% - 90px);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: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)}
.launch-disc.plugin img,.launch-disc.framely img{inset:0;width:100%;height:100%;filter:none}
.launch-disc.cover-art img{inset:0;width:100%;height:100%;object-fit:cover;filter:none}
.launch-disc.no-art{background:#415665bb}
.launch-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-favorite{position:absolute;top:10px;right:5px;padding:7px;background:#263440ce;border:1px solid #e2effa2c;border-radius:50%;color:#dbe6f0;opacity:0;min-height:32px;width:32px;height:32px;transition:opacity .16s;z-index:1}
.launch-item:hover .launch-favorite,.launch-favorite:focus-visible{opacity:1}.launch-favorite.selected{color:var(--launch-blue)}
.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%}
.launch-menu button{display:flex;align-items:center;gap:13px;width:100%;padding:12px 15px;background:transparent;border:0;border-radius:15px;text-align:left;justify-content:flex-start;color:#eef3f7;font:inherit;font-size:17px;font-weight:470;min-height:49px;white-space:nowrap}
.launch-menu button svg{color:#c5d6e4}.launch-menu button:hover:not(:disabled){background:#b9d6ee1c}.launch-menu .danger,.launch-menu .danger svg{color:#eeb5b5}.launch-menu hr{border:0;border-top:1px solid #e4f0fc19;margin:6px 12px}.launch-menu small{display:block;padding:10px 15px;font-size:15px;color:var(--launch-blue)}
.launcher-pages{position:absolute;bottom:46px;left:50%;transform:translateX(-50%);display:flex;gap:0;max-width:80%;overflow:auto;align-items:center}
.launcher-pages button{min-width:32px;width:32px;min-height:36px;padding:12px;border:0;background:transparent;border-radius:50%}
.launcher-pages button span{display:block;width:6px;height:6px;border-radius:50%;background:#dceaf373;box-shadow:0 1px 3px #0c172c99;transition:background .18s,transform .18s}
.launcher-pages button[aria-current=page] span{background:#e9f4fc;transform:scale(1.2)}.launcher-pages button:hover:not(:disabled){background:#e3f1fc16}
.launcher-error,.launcher-shell>.banner{position:absolute;left:138px;right:62px;top:18px;z-index:4;display:flex;align-items:center;justify-content:space-between;background:#24313eea;padding:12px 18px;border-radius:16px;text-shadow:none;font-size:17px}
.launcher-error{background:#462c36ef}.launcher-error button{background:transparent;border:0}
.launcher-empty{position:absolute;inset:45% 20% auto;text-align:center;font-size:20px;color:#e5eff7;text-shadow:0 1px 5px #0c172c}
.launcher-loading{position:absolute;bottom:14px;left:0;right:0;text-align:center;font-size:13px;text-shadow:0 1px 4px #0c172c;color:#d7e3ed}
.launcher-action-frame{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;overflow:hidden}.launcher-action-frame iframe{width:1px;height:1px}
.launcher-shell button:disabled{opacity:.4;cursor:default}.launcher-shell button:not(.launch-icon):focus-visible{outline:2px solid var(--launch-blue);outline-offset:4px}
.launcher-shell .modal-backdrop{position:absolute;inset:0;background:#101d2b65;z-index:5;display:grid;place-items:center}.launcher-shell .modal{position:relative;transform:none;inset:auto;width:560px;max-width:90%;margin:0;border-radius:22px}
@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}}
.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}
.launcher-edit-toolbar span,.launcher-edit-toolbar button{display:flex;align-items:center;gap:10px}.launcher-edit-toolbar button{border-radius:32px;padding:10px 20px;background:#a7bac72b;border:1px solid #e8f4ff30;color:#d9e3ec;box-shadow:inset 0 1px 1px #f0f7ff30,inset 0 -1px 1px #0c172549,0 10px 30px #0b16252b}
.launcher-editing .launch-disc{animation:launcher-wiggle .32s ease-in-out infinite alternate}.launcher-editing .launch-item:nth-child(2n) .launch-disc{animation-direction:alternate-reverse}
.launch-item-dragged .launch-disc{opacity:.22;animation:none}.launcher-drag-ghost{position:fixed;transform:translate(-50%,-50%) scale(1.1);pointer-events:none;z-index:7;filter:drop-shadow(0 12px 18px #06132066)}
.launcher-drag-edge{position:absolute;top:150px;bottom:150px;width:8px;border-radius:8px;background:#b4dbf477;pointer-events:none;z-index:6}.launcher-drag-edge.left{left:max(45px,96px)}.launcher-drag-edge.right{right:45px}
@keyframes launcher-wiggle{from{rotate:-1.4deg}to{rotate:1.4deg}}
@media(prefers-reduced-motion:reduce){.launcher-editing .launch-disc{animation:none}}
.launcher-edit-toolbar button:hover:not(:disabled){background:#e3f1ff26;color:#f3f8fc}
@media(max-width:900px){.launcher-drag-edge.right{right:88px}}
.launcher-panning{overflow:hidden}.launcher-adjacent-page{pointer-events:none}.launcher-adjacent-page .launch-icon{display:block}.launcher-panning .launcher-page{will-change:translate}
+84
View File
@@ -0,0 +1,84 @@
import React,{useEffect,useLayoutEffect,useRef,useState} from 'react';
import {api} from './api';
import {t} from './i18n';
import {IconLayoutGrid,IconPuzzle,IconBrandSteam,IconStar,IconAdjustments,IconTrash,IconDeviceDesktop,IconBrandAndroid,IconGridDots,IconCheck} from '@tabler/icons-react';
import {installedIcon} from './store';
import {PluginFrame} from './plugin-frame';
import type {Plugin} from './main';
import './launcher.css';
export type LauncherConfig={primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number};
const defaults:LauncherConfig={primaryTrigger:false,menuAutoClose:true,menuTimeoutSeconds:10};
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);
useEffect(()=>setFailed(false),[src]);
const initials=name.trim().split(/\s+/).map(word=>Array.from(word)[0]).slice(0,2).join('').toUpperCase();
return <span className={`launch-disc ${kind} ${fit==='cover'?'cover-art':''} ${src&&!failed?'has-art':'no-art'}`}>
{src&&!failed?<img src={src} alt={t('{0}图标',{0:name})} draggable={false} onError={()=>setFailed(true)}/>:<span className="launch-monogram" aria-hidden="true">{initials}</span>}
</span>;
}
export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConfig;refresh:()=>Promise<void>}){
const[busy,setBusy]=useState(false),[error,setError]=useState(''),[seconds,setSeconds]=useState(config.menuTimeoutSeconds);
useEffect(()=>setSeconds(config.menuTimeoutSeconds),[config.menuTimeoutSeconds]);
async function save(p:Partial<LauncherConfig>){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
return <section className="settings-card launcher-settings"><h2>{t('启动台')}</h2><label><span><b>{t('主扳机打开启动台')}</b><small>{t('开启后,上扳机打开快捷面板。')}</small></span><input type="checkbox" role="switch" checked={config.primaryTrigger} disabled={busy} onChange={e=>void save({primaryTrigger:e.target.checked})}/></label><label><span><b>{t('长按菜单自动关闭')}</b><small>{t('指针离开后开始计时,移入后取消。')}</small></span><input type="checkbox" role="switch" checked={config.menuAutoClose} disabled={busy} onChange={e=>void save({menuAutoClose:e.target.checked})}/></label><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section>;
}
type LocalApp={id:string;name:string;kind:'desktop'|'lepton';icon?:string};
type LaunchItem={key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean};
const emptyOrder:string[]=[];
const categories=[{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,order=emptyOrder,refresh}:{plugins:Record<string,Plugin>;safeMode:boolean;config?:LauncherConfig;favorites:number[];order?:string[];refresh:()=>Promise<void>}){
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 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;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));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(){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);}
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(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:false},...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:false}))];
const rank=new Map(manual.map((key,index)=>[key,index]));all.sort((a,b)=>(rank.get(a.key)??Infinity)-(rank.get(b.key)??Infinity)||Number(b.favorite)-Number(a.favorite)||a.name.localeCompare(b.name));
const items=all.filter(a=>filter==='all'||a.kind===filter||(filter==='plugin'&&a.kind==='framely'));
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})));
state.current={editing,current,pages,filter,panning:!!panView,items,all};
const pageTransition=useRef({current,filter}),pageAnimation=useRef<Animation|null>(null);
useLayoutEffect(()=>{const previous=pageTransition.current;pageTransition.current={current,filter};if(previous.current===current&&previous.filter===filter)return;pageAnimation.current?.cancel();if(skipPageAnimation.current){skipPageAnimation.current=false;return;}if(window.matchMedia('(prefers-reduced-motion: reduce)').matches||!pageContent.current)return;const category=previous.filter!==filter,direction=category?Math.sign(categories.findIndex(c=>c.key===filter)-categories.findIndex(c=>c.key===previous.filter)):Math.sign(current-previous.current);pageAnimation.current=pageContent.current.animate([{opacity:0,translate:category?`0 ${direction*110}px`:`${direction*110}px 0`,scale:1},{opacity:1,translate:'0 0',scale:1}],{duration:category?360:300,easing:'cubic-bezier(.22,.65,.24,1)'});},[current,filter]);
useEffect(()=>()=>pageAnimation.current?.cancel(),[]);
const positions=useRef(new Map<string,{x:number;y:number}>()),layoutPage=useRef('');
useLayoutEffect(()=>{const next=new Map<string,{x:number;y:number}>(),pageKey=`${filter}:${current}`;const reduced=window.matchMedia('(prefers-reduced-motion: reduce)').matches;for(const node of Array.from(shell.current?.querySelectorAll<HTMLElement>('[data-launch-key]')??[])){const key=node.dataset.launchKey!,x=node.offsetLeft,y=node.offsetTop;next.set(key,{x,y});const before=positions.current.get(key);if(editing&&!reduced&&pageKey===layoutPage.current&&before&&key!==dragging&&(before.x!==x||before.y!==y))node.animate([{transform:`translate(${before.x-x}px,${before.y-y}px)`},{transform:'translate(0,0)'}],{duration:180,easing:'cubic-bezier(.2,.75,.2,1)'});}positions.current=next;layoutPage.current=pageKey;},[manual,current,filter,editing,dragging,items.map(a=>a.key).join('|')]);
async function run(f:()=>Promise<unknown>){if(busyRef.current)return;busyRef.current=true;setBusy(true);setError('');try{await f();await refresh();}catch(e){if(mounted.current)setError(String(e));}finally{busyRef.current=false;if(mounted.current)setBusy(false);}}
function startDrag(item:LaunchItem,x:number,y:number){const before=state.current.all.map(a=>a.key);drag.current={key:item.key,before,order:before,moved:false,over:null,edge:0,lastX:x,lastY:y,startedInEdit:state.current.editing};setDragging(item.key);setGhost({item,x,y});setMenu(null);}
function down(e:React.PointerEvent,item:LaunchItem){if(e.button!==0||busy||state.current.panning)return;cancelHold();long.current=false;point.current={x:e.clientX,y:e.clientY};holdTarget.current=item.key;if(!editing)swipe.current={x:e.clientX,y:e.clientY};try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}if(editing){try{shell.current?.setPointerCapture(e.pointerId);}catch{}long.current=true;startDrag(item,e.clientX,e.clientY);return;}hold.current=setTimeout(()=>{long.current=true;setMenu(item.key);setOutside(false);hold.current=null;},400);editHold.current=setTimeout(()=>{long.current=true;setEditing(true);swipe.current=null;try{shell.current?.setPointerCapture(e.pointerId);}catch{}startDrag(item,point.current.x,point.current.y);editHold.current=null;},800);}
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;}}};
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});}
async function custom(id:string,action:string){await run(async()=>{const v=await api('plugin.launcher.action',{plugin:id,action});if(v.frontend){busyRef.current=true;setBusy(true);await new Promise<void>((resolve,reject)=>{pending.current={resolve,reject};setFrontend({plugin:id,action,context:v.context});frontTimer.current=setTimeout(()=>done('Launcher action timed out'),90000);});}if(v.opened)setMenu(null);});}
const pending=useRef<{resolve:()=>void;reject:(e:Error)=>void}|null>(null);
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,.launch-menu,.modal,.launcher-error')){if(!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':''}`} onClick={e=>{if(long.current){long.current=false;return;}if(!(e.target as HTMLElement).closest('button,.launch-menu,.modal,.launcher-error'))close();}}>
<nav className="launcher-tools launcher-filters" aria-label={t('启动台')}><span className="launcher-category-active" aria-hidden="true" style={{transform:`translateY(${Math.max(0,categories.findIndex(c=>c.key===filter))*61}px)`}}/>{categories.map(({key,label,icon:CategoryIcon})=><button key={key} aria-label={t(label)} title={t(label)} aria-pressed={filter===key} disabled={!!dragging||!!panView} className={filter===key?'active':''} onClick={()=>{if(editing){close();return;}setFilter(key);setPage(0);}}><CategoryIcon 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);}}>
<button className="launch-icon" disabled={busy||(a.kind==='plugin'&&safeMode&&!editing)} aria-label={a.name} aria-haspopup="menu" aria-expanded={menu===a.key} onPointerDown={e=>down(e,a)} onClick={()=>{if(long.current){long.current=false;return;}if(editing)return;setMenu(null);void run(()=>launch(a));}} onContextMenu={e=>{e.preventDefault();setMenu(a.key);setOutside(false);}}><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/></button><span className="launch-name" title={a.name}>{a.name}</span>
{(a.kind==='plugin'||a.key.startsWith('steam:'))&&!editing&&<button className={`launch-favorite ${a.favorite?'selected':''}`} aria-label={t(a.favorite?'取消收藏':'收藏')} disabled={busy} onClick={()=>void run(()=>api(a.kind==='plugin'?'plugin.favorite':'steam.favorite',a.kind==='plugin'?{plugin:a.id,favorite:!a.favorite}:{app:+a.id,favorite:!a.favorite}))}><IconStar size={16} stroke={1.7}/></button>}
{menu===a.key&&<div ref={menuRef} role="menu" className={`launch-menu ${cells[index].column>=(columns-2)*4?'align-right':''} ${rows>1&&cells[index].row===rows-1?'above':''}`} onPointerEnter={()=>setOutside(false)} onPointerLeave={()=>setOutside(true)}>
{selectedPlugin?<>{(selectedPlugin.manifest.ui.launcherActions??[]).map(action=><button role="menuitem" key={action.id} disabled={busy||safeMode} onClick={()=>void custom(a.id,action.id)}>{action.label}</button>)}{!!selectedPlugin.manifest.ui.launcherActions?.length&&<hr/>}<button role="menuitem" disabled={busy||safeMode||!selectedPlugin.manifest.ui.quickPage} title={!selectedPlugin.manifest.ui.quickPage?t('插件没有快捷页面'):undefined} onClick={()=>void run(()=>api('plugin.launch',{plugin:a.id,source:'launcher',quickPage:true}))}><IconLayoutGrid size={22} stroke={1.7}/>{t('快捷面板')}</button><button role="menuitem" disabled={busy} onClick={()=>void run(()=>api('host.plugin.manage',{plugin:a.id}))}><IconAdjustments size={22} stroke={1.7}/>{t('管理插件')}</button><button role="menuitem" disabled={busy} className="danger" onClick={()=>void run(async()=>{const plan=await api('plugin.disable.preview',{plugin:a.id});setUninstall({...selectedPlugin,dependents:plan.dependents,fingerprint:plan.fingerprint});})}><IconTrash size={22} stroke={1.7}/>{t('卸载插件')}</button></>:<><button role="menuitem" disabled={busy} onClick={()=>void run(()=>launch(a))}><IconLayoutGrid size={22}/>{t('打开')}</button><button role="menuitem" disabled={busy} onClick={()=>{setMenu(null);setEditing(true);}}><IconGridDots size={22}/>{t('整理图标')}</button></>}
{busy&&<small role="status">{t('正在执行…')}</small>}</div>}</div>)}</div>{panView&&current+panView.direction>=0&&current+panView.direction<pages&&<div className="launcher-adjacent-page" aria-hidden="true" style={{translate:`${panView.offset+panView.direction*panView.width}px 0`,transition:panView.settling?'translate 240ms cubic-bezier(.2,.75,.2,1)':'none'}}>{items.slice((current+panView.direction)*pageSize,(current+panView.direction+1)*pageSize).map((a,index)=>{const count=Math.min(pageSize,items.length-(current+panView.direction)*pageSize),row=index<4?0:index<9?1:2,column=index-(row===0?0:row===1?4:9),slot=(columns-(count<=4?count:row===1?5:4))*2+1+column*4;return <div key={a.key} className="launch-item" style={{gridColumn:`${slot} / span 4`,gridRow:`${1+row*2} / span 2`}}><span className="launch-icon"><LauncherArtwork src={a.icon} name={a.name} kind={a.kind} fit={a.fit}/></span><span className="launch-name">{a.name}</span></div>;})}</div>}</div>
{ghost&&<div className="launcher-drag-ghost" style={{left:ghost.x,top:ghost.y}}><LauncherArtwork src={ghost.item.icon} name={ghost.item.name} kind={ghost.item.kind} fit={ghost.item.fit}/></div>}{!!edge&&<div className={`launcher-drag-edge ${edge<0?'left':'right'}`} aria-hidden="true"/>}
{!items.length&&<p className="launcher-empty">{t('没有可启动的应用')}</p>}<nav className="launcher-pages" aria-label={t('分页')}>{Array.from({length:pages},(_,n)=><button key={n} aria-label={t('第 {0} 页',{0:n+1})} aria-current={current===n?'page':undefined} disabled={busy||!!dragging||!!panView} onClick={()=>{if(editing){close();return;}setPage(n);}}><span/></button>)}</nav>{loading&&<span className="launcher-loading" role="status">{t('正在读取本机应用…')}</span>}
{uninstall&&<div className="modal-backdrop"><div className="modal" role="dialog" aria-modal="true"><h2>{t('卸载 {0}?',{0:uninstall.manifest.name})}</h2><p>{t('依赖此插件的应用也会停用;插件数据会保留。')}</p>{!!uninstall.dependents.length&&<p>{t('停用:')}{uninstall.dependents.map(id=>plugins[id]?.manifest.name??id).join(', ')}</p>}<button disabled={busy} onClick={()=>setUninstall(null)}>{t('取消')}</button><button disabled={busy} className="danger" onClick={()=>void run(async()=>{await api('plugin.uninstall',{plugin:uninstall.manifest.id,purge:false,approveDependents:true,fingerprint:uninstall.fingerprint});setUninstall(null);setMenu(null);})}>{t('确认卸载')}</button></div></div>}{frontend&&<div className="launcher-action-frame" aria-hidden="true"><PluginFrame plugin={frontend.plugin} entry={`action-${frontend.action}`} actionContext={frontend.context} onActionDone={done}/></div>}</div>;
}
+21 -54
View File
@@ -1,3 +1,5 @@
import {PluginFrame,pluginLanguagePreference,setPluginLanguagePreference} from './plugin-frame';
import {Launcher,LauncherSettings,type LauncherConfig} from './launcher';
import {NotificationSettings,PopupPermission,useReadInbox,defaultNotificationConfig,type NotificationConfig} from './notification-settings';
import {NotificationCenter,type NotificationEntry} from './notifications';
import {IconBell} from '@tabler/icons-react';
@@ -14,65 +16,27 @@ import {InstallReview} from './install-review';
import {MemoryLimit} from './memory-limit';
import {installScrollbars} from '../../sdk/src/scrollbars';
installScrollbars();
import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view');
type Manifest=Presentation&{id:string;name:string;version:string;author:string;description:string;backend?:{runAs:string;autostart:boolean;memoryLimitMiB?:number};lifecycle?:{runAs?:string};dependencies?:Record<string,unknown>;optionalDependencies?:Record<string,unknown>;conflicts?:Record<string,string>;exclusiveResources?:string[];ui:{quickPage?:string;windows:Record<string,unknown>}};
type Plugin={manifest:Manifest;enabled:boolean;favorite:boolean;order:number;source?:string;error?:string};
import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';if(location.pathname==='/launcher')document.documentElement.classList.add('launcher-view');if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view');
export type Manifest=Presentation&{id:string;name:string;version:string;author:string;description:string;backend?:{runAs:string;autostart:boolean;memoryLimitMiB?:number};lifecycle?:{runAs?:string};dependencies?:Record<string,unknown>;optionalDependencies?:Record<string,unknown>;conflicts?:Record<string,string>;exclusiveResources?:string[];engines?:{framely?:string};ui:{quickPage?:string;windows:Record<string,unknown>;launch?:Record<string,{type:string;window?:string}>;launcherActions?:{id:string;label:string;target:{type:string;entry?:string;window?:string}}[]}};
export type Plugin={manifest:Manifest;enabled:boolean;favorite:boolean;order:number;source?:string;error?:string};
type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:boolean};
type Status={agreement?:AgreementState;version:string;build?:string;systemUpdate?:any;previousRelease?:string;database:{notificationSettings?:NotificationConfig;language?:string;proxy?:ProxyConfig;networkPanel?:NetworkPanel;plugins:Record<string,Plugin>;sources:Source[];subscriptions?:Subscription[];sourceOrigins?:Record<string,{manual:boolean;subscriptions:Record<string,string>}>;safeMode:boolean;updateSource?:{url:string}|null;updateChannel?:'stable'|'testing'};notifications:NotificationEntry[];inbox?:NotificationEntry[];notificationBadge?:number;runtime?:Record<string,{phase:string;detail:any}>;running:string[]};
type Status={agreement?:AgreementState;version:string;build?:string;systemUpdate?:any;previousRelease?:string;database:{launcher?:LauncherConfig;steamFavorites?:number[];launcherOrder?:string[];notificationSettings?:NotificationConfig;language?:string;proxy?:ProxyConfig;networkPanel?:NetworkPanel;plugins:Record<string,Plugin>;sources:Source[];subscriptions?:Subscription[];sourceOrigins?:Record<string,{manual:boolean;subscriptions:Record<string,string>}>;safeMode:boolean;updateSource?:{url:string}|null;updateChannel?:'stable'|'testing'};notifications:NotificationEntry[];inbox?:NotificationEntry[];notificationBadge?:number;runtime?:Record<string,{phase:string;detail:any}>;running:string[]};
const managerMode=location.pathname==='/manager';
const empty:Status={version:'0.4.0',database:{plugins:{},sources:[],safeMode:false},notifications:[],running:[]};
let pluginLanguagePreference='auto';
function PluginFrame({plugin,entry='quick'}:{plugin:string;entry?:string}){
const ref=useRef<HTMLIFrameElement>(null);
const [error,setError]=useState(''),[source,setSource]=useState<string|null>(null),[sandbox,setSandbox]=useState('allow-scripts');
const [attempt,setAttempt]=useState(0);
useEffect(()=>{
let live=true;setError('');setSource(null);
const methods:Record<string,string>={call:'plugin.call',dependencies:'plugin.dependencies','window.open':'window.open','window.close':'window.close','notification.send':'notification.send','notification.remove':'notification.remove','notification.dismiss':'notification.dismiss'};
async function message(e:MessageEvent){
if(e.source!==ref.current?.contentWindow||e.data?.channel!=='framely.plugin')return;
const {id,op,params}=e.data;if(!Number.isSafeInteger(id)||typeof op!=='string')return;
try{let result;
if(op==='language.get')result={preference:pluginLanguagePreference,language:currentLanguage()};
else if(op==='ui.close'){
const view=viewKey();
if(view==='menu')result=await api('host.menu.close');
else if(view==='framely.manager')result=await api('host.manager.close');
else result=await api('window.close',{plugin,window:entry});
}
else if(op==='ui.visibility.get')result=await api('ui.visibility.get',{view:viewKey()});
else if(op==='haptic')result=await api('host.haptic',{view:viewKey()});
else if(op==='keyboard')result=await api('host.keyboard',{...params,view:viewKey()});
else{const method=methods[op];if(!method)throw new Error(t('插件请求不支持的能力'));result=await api(method,{...params,plugin});}
if(live)ref.current?.contentWindow?.postMessage({channel:'framely.reply',id,result},'*');
}catch(e){if(live)ref.current?.contentWindow?.postMessage({channel:'framely.reply',id,error:String(e)},'*');}
}
const event=(e:Event)=>{const value=(e as CustomEvent).detail;if(value.kind==='ui.visibility.changed'){api('ui.visibility.get',{view:viewKey()}).then(data=>{if(live)ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'ui.visibility.changed',data}},'*');}).catch(()=>{});}if(value.kind==='plugin.dependency.changed')ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'dependencies.changed'}},'*');if(value.plugin===plugin&&value.kind==='plugin.event')ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:value.event,data:value.data}},'*');};
const language=()=>ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'language.changed',data:{preference:pluginLanguagePreference,language:currentLanguage()}}},'*');
window.addEventListener('framely.event',event);window.addEventListener('framely.language',language);window.addEventListener('message',message);
void (async()=>{
await api('plugin.open',{plugin});const status=await api('status');
const local=!!(status.database.plugins[plugin]?.manifest.ui.windows[entry] as any)?.localWeb;
let url=`/plugin-frame/${plugin}/${entry}`,flags=local?'allow-scripts allow-downloads':'allow-scripts';
if(local&&entry!=='quick'){
if(!['localhost','127.0.0.1','[::1]'].includes(location.hostname))throw new Error('Open this window on the Frame device');
const value=await api('plugin.call',{plugin,method:'window.get',params:{window:entry}});
const target=new URL(value.url);
if(target.protocol!=='http:'||target.hostname!=='localhost'||!target.port||target.port===location.port||target.username||target.password||target.pathname!==`/framely-window/${entry}`||target.search||target.hash)throw new Error('Invalid local plugin window URL');
url=target.href;flags='allow-scripts allow-same-origin allow-forms allow-downloads allow-popups allow-popups-to-escape-sandbox';
}
if(live){setSandbox(flags);setSource(url);}
})().catch(e=>{if(live)setError(String(e));});
return()=>{live=false;window.removeEventListener('message',message);window.removeEventListener('framely.event',event);window.removeEventListener('framely.language',language);};
},[plugin,entry,attempt]);
return error?<div><p className="error">{error}</p><button onClick={()=>setAttempt(v=>v+1)}>{t('重试')}</button></div>:source?<iframe title={t('插件页面')} ref={ref} sandbox={sandbox} src={source} allow="clipboard-read; clipboard-write; fullscreen"/>:<p>{t('正在加载…')}</p>;
}
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':'installed'):'favorites'),[page,setPage]=useState<string|null>(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[]>([]);
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':'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);
async function exportLogs(){
setExportingLogs(true);setLogExport('');setError('');
try{const v=await api<{name:string;path?:string;data?:string}>('diagnostics.export');
if(v.path)setLogExport(t('日志已保存到:{0}',{0:v.path}));
else if(v.data){const bytes=Uint8Array.from(atob(v.data),c=>c.charCodeAt(0));const url=URL.createObjectURL(new Blob([bytes],{type:'application/zip'}));const link=document.createElement('a');link.href=url;link.download=v.name;document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);setLogExport(t('日志包已生成,请查看浏览器下载。'));}
else throw Error(t('日志包缺少数据'));
}catch(e){setError(String(e));}finally{setExportingLogs(false);}
}
async function refreshLanguages(){const v=await api('language.list');localePacks.current=v.packs??[];setLanguageFiles(v.invalidFiles??[]);await refresh();}
useEffect(()=>{
@@ -83,7 +47,7 @@ function App(){
const key=(event:KeyboardEvent)=>{if(event.key!=='Tab')return;const list=items(),first=list[0],last=list.at(-1);if(event.shiftKey&&(document.activeElement===first||!panel.contains(document.activeElement))){event.preventDefault();last?.focus();}else if(!event.shiftKey&&(document.activeElement===last||!panel.contains(document.activeElement))){event.preventDefault();first?.focus();}};
document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);previous?.focus();};
},[manage,install,logs,importing,transfer?.id,relationAction]);
async function refresh(){try{const s=await api<Status>('status');if(localePacks.current===null||localePreference.current!==(s.database.language??'auto')){const v=await api('language.list');localePacks.current=v.packs??[];setLanguageFiles(v.invalidFiles??[]);}const previousLanguage=currentLanguage(),previousPreference=pluginLanguagePreference;configureLanguage(s.database.language??'auto',localePacks.current??[]);pluginLanguagePreference=s.database.language??'auto';if(previousLanguage!==currentLanguage()||previousPreference!==pluginLanguagePreference)window.dispatchEvent(new Event('framely.language'));localePreference.current=s.database.language??'auto';setStatus(s);}catch(e){setError(String(e));}}
async function refresh(){try{const s=await api<Status>('status');if(localePacks.current===null||localePreference.current!==(s.database.language??'auto')){const v=await api('language.list');localePacks.current=v.packs??[];setLanguageFiles(v.invalidFiles??[]);}const previousLanguage=currentLanguage(),previousPreference=pluginLanguagePreference;configureLanguage(s.database.language??'auto',localePacks.current??[]);setPluginLanguagePreference(s.database.language??'auto');if(previousLanguage!==currentLanguage()||previousPreference!==pluginLanguagePreference)window.dispatchEvent(new Event('framely.language'));localePreference.current=s.database.language??'auto';setStatus(s);}catch(e){setError(String(e));}}
useEffect(()=>{void refresh();const t=setInterval(()=>void refresh(),1000);return()=>clearInterval(t);},[]);
useEffect(()=>{let live=true,cursor=0;const t=setInterval(()=>{api('ui.events',{cursor}).then(v=>{if(live){cursor=v.cursor;for(const e of v.events)window.dispatchEvent(new CustomEvent('framely.event',{detail:e}));}}).catch(()=>{});},1000);return()=>{live=false;clearInterval(t);};},[]);
useEffect(()=>{const back=(e:Event)=>{if(e instanceof KeyboardEvent&&e.key!=='Escape')return;if(transfer){if(['done','failed','cancelled'].includes(transfer.phase))setTransfer(null);}else if(relationAction)setRelationAction(null);else if(importing)setImporting(false);else if(logs!==null)setLogs(null);else if(manage)setManage(null);else if(install)setInstall(null);else if(page)setPage(null);else if(!managerMode)void api('host.menu.close');};window.addEventListener('framely.back',back);window.addEventListener('keydown',back);return()=>{window.removeEventListener('framely.back',back);window.removeEventListener('keydown',back);};},[manage,install,logs,page,importing,transfer,relationAction]);
@@ -97,6 +61,8 @@ function App(){
setManage(null);
}
useEffect(()=>{if(!managerMode)return;const navigate=()=>{if(['#catalog','#updates','#notification-settings'].includes(location.hash)){setPage(null);setCatalogTarget(null);setTab(location.hash==='#updates'?'about':location.hash==='#notification-settings'?'notification-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);}}else 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');}
const plugins=Object.values(status.database.plugins).sort((a,b)=>a.order-b.order);
const listedPlugins=plugins.filter(p=>(tab==='installed'||p.favorite)&&(managerMode||tab!=='installed'||p.enabled)&&`${p.manifest.name} ${p.manifest.description}`.toLowerCase().includes(query.toLowerCase()));
@@ -111,6 +77,7 @@ function App(){
if(parts[1]==='notifications'&&!status.agreement.accepted)return null;
if(!status.agreement.accepted)return <AgreementGate language={{value:status.database.language??'auto',packs:localePacks.current??[]}} key="required" initialDeclined={agreementDeclined} agreement={status.agreement} refresh={refresh}/>;
if(reviewAgreement)return <AgreementGate language={{value:status.database.language??'auto',packs:localePacks.current??[]}} key="review" agreement={status.agreement} refresh={refresh} onClose={()=>setReviewAgreement(false)} onRevoked={closed=>{setAgreementDeclined(!closed);setReviewAgreement(false);setPage(null);setManage(null);}}/>;
if(parts[1]==='launcher')return <Launcher plugins={status.database.plugins} safeMode={status.database.safeMode} config={status.database.launcher} favorites={status.database.steamFavorites??[]} order={status.database.launcherOrder??[]} refresh={refresh}/>;
if(parts[1]==='notifications')return <NotificationCenter items={status.notifications} plugins={status.database.plugins} safeMode={status.database.safeMode} refresh={refresh}/>;
if(windowPlugin){const p=status.database.plugins[windowPlugin];return <div className="app plugin-window">{p?.enabled&&!status.database.safeMode?<PluginFrame plugin={windowPlugin} entry={parts[3]}/>:<div className="empty"><p>{t("插件已停用或正在启动。")}</p><button onClick={()=>void action(()=>api('window.close',{plugin:windowPlugin,window:parts[3]}))}>{t("关闭")}</button></div>}{error&&<p className="error">{error}</p>}</div>;}
return <div className={`app ${managerMode?'manager':'quick'}`}><header className="shell-header"><strong className="brand-title"><Brand wordmark/></strong>{managerMode?<><button className="primary" onClick={()=>setImporting(true)}><Icon name="download"/>{t("安装插件")}</button><button className="icon-button" aria-label={t("关闭管理窗口")} onClick={()=>void api('host.manager.close')}><Icon name="close"/></button></>:<><button className="icon-button quick-library-button" aria-label={t("插件库")} onClick={()=>void action(()=>api('host.manager.open',{page:'catalog'}))}><Icon name="store"/><span>{t("插件库")}</span></button><button className="icon-button" aria-label={t("关闭菜单")} onClick={()=>void api('host.menu.close')}><Icon name="close"/></button></>}</header>{error&&<p className="error" role="alert">{error}<button onClick={()=>setError('')}>×</button></p>}{status.database.safeMode&&<p className="banner">{t("安全模式:第三方插件暂停运行。")}</p>}
@@ -118,7 +85,7 @@ function App(){
{(tab==='favorites'||tab==='installed')&&<>
{managerMode&&<div className="list-heading"><div><h1>{t("已安装")}</h1><p>{plugins.length}{t("个插件,")}{plugins.filter(p=>p.enabled).length}{t("个已启用")}</p></div><div className="search-field"><Icon name="search"/><input aria-label={t("搜索已安装插件")} placeholder={t("搜索插件")} value={query} onChange={e=>setQuery(e.target.value)}/></div></div>}
{managerMode&&<div className="list-columns"><span>{t("插件")}</span><span>{t("状态")}</span><span>{t("版本")}</span><span>{t("操作")}</span></div>}<div className="plugin-list">{listedPlugins.map(p=><article className={`plugin-row ${!p.enabled?'disabled':''}`} key={p.manifest.id}>
<button className="plugin-main" onClick={()=>setPage(p.manifest.id)}><PluginImage src={installedIcon(p)} name={p.manifest.name} size={48}/><span className="plugin-copy"><b>{p.manifest.name}</b>{managerMode&&<small>{p.manifest.description}</small>}<span className="plugin-meta"><i className={`status-dot ${p.error?'failed':!p.enabled?'off':''}`}/>{p.error?t("异常"):!p.enabled?t("已停用"):runtimeLabels[status.runtime?.[p.manifest.id]?.phase??'']??(status.running.includes(p.manifest.id)?t("运行中"):t("已启用"))}{!managerMode&&<span className="version">{p.manifest.version}</span>}</span></span></button>
<button className="plugin-main" onClick={()=>void action(()=>launchPlugin(p))}><PluginImage src={installedIcon(p)} name={p.manifest.name} size={48}/><span className="plugin-copy"><b>{p.manifest.name}</b>{managerMode&&<small>{p.manifest.description}</small>}<span className="plugin-meta"><i className={`status-dot ${p.error?'failed':!p.enabled?'off':''}`}/>{p.error?t("异常"):!p.enabled?t("已停用"):runtimeLabels[status.runtime?.[p.manifest.id]?.phase??'']??(status.running.includes(p.manifest.id)?t("运行中"):t("已启用"))}{!managerMode&&<span className="version">{p.manifest.version}</span>}</span></span></button>
{managerMode&&<><span className="row-state">{p.error?t("异常"):!p.enabled?t("已停用"):runtimeLabels[status.runtime?.[p.manifest.id]?.phase??'']??(status.running.includes(p.manifest.id)?t("运行中"):t("已启用"))}</span><span className="row-version">{p.manifest.version}</span><button className={`plugin-switch ${p.enabled?'on':''}`} role="switch" aria-checked={p.enabled} aria-label={t("启用 {0}", {"0": p.manifest.name})} disabled={busy} onClick={()=>void action(()=>changePlugin(p,'enable'))}><span/></button></>}<button className={`icon-button favorite ${p.favorite?'selected':''}`} aria-label={t("收藏 {0}", {"0": p.manifest.name})} onClick={()=>void action(()=>api('plugin.favorite',{plugin:p.manifest.id,favorite:!p.favorite}))}><Icon name="star" size={23} filled={p.favorite}/></button>
{managerMode&&<button className="icon-button" aria-label={t("管理 {0}", {"0": p.manifest.name})} onClick={()=>setManage(p)}><Icon name="more"/></button>}
</article>)}</div>
@@ -128,7 +95,7 @@ function App(){
{tab==='notification-settings'&&managerMode&&<NotificationSettings config={status.database.notificationSettings??defaultNotificationConfig} plugins={status.database.plugins} refresh={refresh}/>}
{tab==='sources'&&managerMode&&<><div className="list-heading"><div><h1>{t("插件源")}</h1><p>{t("管理插件目录及订阅。")}</p></div></div><section className="settings-card"><Sources origins={status.database.sourceOrigins} sources={status.database.sources} save={async s=>{await api('sources.save',{sources:s});await refresh();}}/></section><Subscriptions subscriptions={status.database.subscriptions??[]} refresh={refresh}/></>}
{tab==='about'&&managerMode&&<><div className="list-heading"><div><h1>{t("关于 Framely")}</h1></div></div><SystemUpdates status={status} refresh={refresh}/><section className="settings-card"><h2>{t("用户协议与隐私声明")}</h2><div className="row"><button onClick={()=>setReviewAgreement(true)}>{t("查看协议与隐私声明")}</button></div></section></>}
{tab==='settings'&&(!managerMode?<div className="quick-settings"><button className="setting-entry" onClick={()=>void api('host.manager.open')}><span className="setting-symbol"><Icon name="plugins" size={28}/></span><span><b>{t("管理插件")}</b><small>{t("安装、更新与管理插件源")}</small></span><span className="forward">›</span></button><section className="settings-card"><h2>{t("运行模式")}</h2><button className="setting-toggle" onClick={()=>void action(()=>api('safeMode',{enabled:!status.database.safeMode}))}><span><b>{t("安全模式")}</b><small>{t("暂停所有第三方插件")}</small></span><span className={`toggle ${status.database.safeMode?'on':''}`}/></button></section><LanguageSettings selectionOnly value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><button className="setting-entry quick-update-entry" onClick={()=>void action(()=>api('host.manager.open',{page:'updates'}))}><span className="setting-symbol"><Icon name="refresh" size={28}/></span><span><b>{t("检查更新")}</b></span><span className="forward">›</span></button><div className="about"><Brand/><b>Framely</b><small>{t("版本")}{status.build??status.version}</small></div></div>:<><div className="list-heading"><div><h1>{t("设置")}</h1><p>{t("运行模式与诊断。")}</p></div></div><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><NetworkSettings config={status.database.networkPanel??{enabled:true,port:15915}} refresh={refresh}/><ProxySettings config={status.database.proxy??{http:"",github:""}} refresh={refresh}/><section className="settings-card"><h2>{t("恢复与诊断")}</h2><div className="row"><button onClick={()=>void action(()=>api('safeMode',{enabled:!status.database.safeMode}))}>{status.database.safeMode?t("退出安全模式"):t("进入安全模式")}</button><button onClick={()=>void action(async()=>setLogs(JSON.stringify(await api('status'),null,2)))}>{t("诊断信息")}</button></div></section><section className="settings-card"><h2>{t("运行用户")}</h2><p className="sub">{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}</p></section></>)}
{tab==='settings'&&(!managerMode?<div className="quick-settings"><button className="setting-entry" onClick={()=>void api('host.manager.open')}><span className="setting-symbol"><Icon name="plugins" size={28}/></span><span><b>{t("管理插件")}</b><small>{t("安装、更新与管理插件源")}</small></span><span className="forward">›</span></button><section className="settings-card"><h2>{t("运行模式")}</h2><button className="setting-toggle" onClick={()=>void action(()=>api('safeMode',{enabled:!status.database.safeMode}))}><span><b>{t("安全模式")}</b><small>{t("暂停所有第三方插件")}</small></span><span className={`toggle ${status.database.safeMode?'on':''}`}/></button></section><LanguageSettings selectionOnly value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><button className="setting-entry quick-update-entry" onClick={()=>void action(()=>api('host.manager.open',{page:'updates'}))}><span className="setting-symbol"><Icon name="refresh" size={28}/></span><span><b>{t("检查更新")}</b></span><span className="forward">›</span></button><div className="about"><Brand/><b>Framely</b><small>{t("版本")}{status.build??status.version}</small></div></div>:<><div className="list-heading"><div><h1>{t("设置")}</h1><p>{t("运行模式与诊断。")}</p></div></div><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><LauncherSettings config={status.database.launcher} refresh={refresh}/><NetworkSettings config={status.database.networkPanel??{enabled:true,port:15915}} refresh={refresh}/><ProxySettings config={status.database.proxy??{http:"",github:""}} refresh={refresh}/><section className="settings-card"><h2>{t("恢复与诊断")}</h2><p className="sub">{t("导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。")}</p><div className="row"><button onClick={()=>void action(()=>api('safeMode',{enabled:!status.database.safeMode}))}>{status.database.safeMode?t("退出安全模式"):t("进入安全模式")}</button><button onClick={()=>void action(async()=>setLogs(JSON.stringify(await api('status'),null,2)))}>{t("诊断信息")}</button><button disabled={exportingLogs||busy} onClick={()=>void exportLogs()}>{exportingLogs?t("正在导出日志…"):t("导出日志")}</button></div>{logExport&&<p role="status">{logExport}</p>}</section><section className="settings-card"><h2>{t("运行用户")}</h2><p className="sub">{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}</p></section></>)}
{tab==='catalog'&&<Store target={catalogTarget} plugins={status.database.plugins} sources={status.database.sources} onInspect={inspect} onSources={()=>setTab('sources')} busy={busy}/>}
</main></div>}
{importing&&<div className="modal" role="dialog" aria-modal="true" aria-label={t("安装插件")}><button className="icon-button modal-close" aria-label={t("关闭导入")} onClick={()=>setImporting(false)}><Icon name="close"/></button><InstallInput inspect={async p=>{await inspect(p);setImporting(false);}} busy={busy}/></div>}
+55
View File
@@ -0,0 +1,55 @@
import React,{useEffect,useRef,useState} from 'react';
import {api,viewKey} from './api';
import {t,currentLanguage} from './i18n';
export let pluginLanguagePreference='auto';
export function setPluginLanguagePreference(value:string){pluginLanguagePreference=value;}
export function PluginFrame({plugin,entry='quick',actionContext,onActionDone}:{plugin:string;entry?:string;actionContext?:any;onActionDone?:(error?:string)=>void}){
const ref=useRef<HTMLIFrameElement>(null);
const [error,setError]=useState(''),[source,setSource]=useState<string|null>(null),[sandbox,setSandbox]=useState('allow-scripts');
const [attempt,setAttempt]=useState(0);
useEffect(()=>{
let live=true;setError('');setSource(null);
const methods:Record<string,string>={call:'plugin.call',dependencies:'plugin.dependencies','window.open':'window.open','window.close':'window.close','notification.send':'notification.send','notification.remove':'notification.remove','notification.dismiss':'notification.dismiss'};
async function message(e:MessageEvent){
if(e.source!==ref.current?.contentWindow||e.data?.channel!=='framely.plugin')return;
const {id,op,params}=e.data;if(!Number.isSafeInteger(id)||typeof op!=='string')return;
try{let result;
if(op==='launcher.ready'){if(!entry.startsWith('action-')||!actionContext)throw new Error('Not an action host');ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'launcher.action',data:actionContext}},'*');result=true;}
else if(op==='launcher.done'){if(!entry.startsWith('action-')||!actionContext)throw new Error('Not an action host');onActionDone?.(params?.error);result=true;}
else if(op==='ui.launchContext')result=actionContext??await api('plugin.launch.context',{plugin,entry});
else if(op==='language.get')result={preference:pluginLanguagePreference,language:currentLanguage()};
else if(op==='ui.close'){
const view=viewKey();
if(view==='menu')result=await api('host.menu.close');
else if(view==='launcher')result=await api('host.launcher.close');
else if(view==='framely.manager')result=await api('host.manager.close');
else result=await api('window.close',{plugin,window:entry});
}
else if(op==='ui.visibility.get')result=await api('ui.visibility.get',{view:viewKey()});
else if(op==='haptic')result=await api('host.haptic',{view:viewKey()});
else if(op==='keyboard')result=await api('host.keyboard',{...params,view:viewKey()});
else{const method=methods[op];if(!method)throw new Error(t('插件请求不支持的能力'));result=await api(method,{...params,plugin,...(op==='window.open'&&actionContext?{launchContext:actionContext}:{})});}
if(live)ref.current?.contentWindow?.postMessage({channel:'framely.reply',id,result},'*');
}catch(e){if(live)ref.current?.contentWindow?.postMessage({channel:'framely.reply',id,error:String(e)},'*');}
}
const event=(e:Event)=>{const value=(e as CustomEvent).detail;if(value.kind==='ui.visibility.changed'){api('ui.visibility.get',{view:viewKey()}).then(data=>{if(live)ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'ui.visibility.changed',data}},'*');}).catch(()=>{});}if(value.kind==='plugin.dependency.changed')ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'dependencies.changed'}},'*');if(value.plugin===plugin&&value.kind==='plugin.launch.context'&&value.entry===entry)ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'ui.launch',data:value.context}},'*');if(value.plugin===plugin&&value.kind==='plugin.event')ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:value.event,data:value.data}},'*');};
const language=()=>ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'language.changed',data:{preference:pluginLanguagePreference,language:currentLanguage()}}},'*');
const launched=(e:Event)=>ref.current?.contentWindow?.postMessage({channel:'framely.reply',event:{type:'ui.launch',data:(e as CustomEvent).detail}},'*');
window.addEventListener('framely.launch',launched);window.addEventListener('framely.event',event);window.addEventListener('framely.language',language);window.addEventListener('message',message);
void (async()=>{
await api('plugin.open',{plugin});const status=await api('status');
const local=!!(status.database.plugins[plugin]?.manifest.ui.windows[entry] as any)?.localWeb;
let url=actionContext?`/plugin-action-frame/${plugin}/${entry.slice(7)}`:`/plugin-frame/${plugin}/${entry}`,flags=local?'allow-scripts allow-downloads':'allow-scripts';
if(local&&entry!=='quick'){
if(!['localhost','127.0.0.1','[::1]'].includes(location.hostname))throw new Error('Open this window on the Frame device');
const value=await api('plugin.call',{plugin,method:'window.get',params:{window:entry}});
const target=new URL(value.url);
if(target.protocol!=='http:'||target.hostname!=='localhost'||!target.port||target.port===location.port||target.username||target.password||target.pathname!==`/framely-window/${entry}`||target.search||target.hash)throw new Error('Invalid local plugin window URL');
url=target.href;flags='allow-scripts allow-same-origin allow-forms allow-downloads allow-popups allow-popups-to-escape-sandbox';
}
if(live){setSandbox(flags);setSource(url);}
})().catch(e=>{if(live)setError(String(e));});
return()=>{live=false;window.removeEventListener('message',message);window.removeEventListener('framely.event',event);window.removeEventListener('framely.language',language);};
},[plugin,entry,attempt]);
return error?<div><p className="error">{error}</p><button onClick={()=>setAttempt(v=>v+1)}>{t('重试')}</button></div>:source?<iframe title={t('插件页面')} ref={ref} sandbox={sandbox} src={source} allow="clipboard-read; clipboard-write; fullscreen"/>:<p>{t('正在加载…')}</p>;
}
+3 -3
View File
@@ -7,7 +7,7 @@ import {RunUserBadge,RunUserNotice,RuntimeUser} from './run-user';
import {Icon} from './icons';
export type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:boolean};
export type Presentation={authorUrl?:string|null;documentationUrl?:string|null;homepage?:string|null;icon?:string|null;details?:string;tags?:string[];screenshots?:string[];changelog?:string;dependencies?:Record<string,string|{version:string;source:string}>;optionalDependencies?:Record<string,string|{version:string;source:string}>;conflicts?:Record<string,string>;exclusiveResources?:string[]};
export type Entry=Presentation&{id:string;name:string;author:string;description:string;version:string;apiVersion:number;url:string;sha256:string;runAs?:RuntimeUser|null};
export type Entry=Presentation&{id:string;name:string;author:string;description:string;version:string;apiVersion:number;compatible?:boolean;engines?:{framely?:string};url:string;sha256:string;runAs?:RuntimeUser|null};
export type CatalogResult={source:Source;catalog?:{name:string;plugins:Entry[]};error?:string;cached?:boolean;fetchedAt?:number};
export type Job={id:string;kind:string;phase:string;received:number;verified?:number;total?:number;result?:any;error?:string;completedSources?:number;totalSources?:number;partial?:CatalogResult[]};
export async function runJob(method:string,params:unknown={},onUpdate?:(job:Job)=>void,statusMethod='job.status'):Promise<{job:string;result:any}>{
@@ -32,7 +32,7 @@ export function PluginLinks({plugin}:{plugin:Presentation}){
export function installedIcon(plugin:any){return plugin?.manifest.icon?`/plugin-assets/${plugin.manifest.id}/${plugin.manifest.icon}`:null;}
function versionParts(value:string){const m=value.match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([^+]+))?(?:\+.*)?$/);return m?{parts:[+m[1],+(m[2]??0),+(m[3]??0)],pre:m[4]??''}:null;}
export function compareVersions(a:string,b:string):number|null{const x=versionParts(a),y=versionParts(b);if(!x||!y)return a===b?0:null;for(let i=0;i<3;i++)if(x.parts[i]!==y.parts[i])return Math.sign(x.parts[i]-y.parts[i]);if(x.pre===y.pre)return 0;if(!x.pre)return 1;if(!y.pre)return -1;const p=x.pre.split('.'),q=y.pre.split('.');for(let i=0;i<Math.max(p.length,q.length);i++){if(p[i]===q[i])continue;if(p[i]===undefined)return-1;if(q[i]===undefined)return 1;const pn=/^\d+$/.test(p[i]),qn=/^\d+$/.test(q[i]);if(pn&&qn)return Math.sign(+p[i]-+q[i]);if(pn!==qn)return pn?-1:1;return p[i]<q[i]?-1:1;}return 0;}
function stateFor(p:Entry,installed:any,source:Source){if(p.apiVersion!==1)return{label:t("不兼容"),kind:'incompatible'};if(!installed)return{label:t("安装"),kind:'new'};if(installed.source&&installed.source!==source.id)return{label:t("其他来源"),kind:'other'};const diff=compareVersions(p.version,installed.manifest.version);if(diff===0)return{label:t("已安装"),kind:'installed'};if(diff!==null&&diff>0)return{label:t("可更新"),kind:'update'};if(diff!==null&&diff<0)return{label:t("较旧版本"),kind:'older'};return{label:t("不同版本"),kind:'different'};}
function stateFor(p:Entry,installed:any,source:Source){if(p.apiVersion!==1||p.compatible===false)return{label:t("不兼容"),kind:'incompatible'};if(!installed)return{label:t("安装"),kind:'new'};if(installed.source&&installed.source!==source.id)return{label:t("其他来源"),kind:'other'};const diff=compareVersions(p.version,installed.manifest.version);if(diff===0)return{label:t("已安装"),kind:'installed'};if(diff!==null&&diff>0)return{label:t("可更新"),kind:'update'};if(diff!==null&&diff<0)return{label:t("较旧版本"),kind:'older'};return{label:t("不同版本"),kind:'different'};}
export function Store({plugins,sources,onInspect,onSources,busy,target}:{target?:{plugin:string;source?:string}|null;plugins:Record<string,any>;sources:Source[];onInspect:(request:any)=>Promise<void>;onSources:()=>void;busy:boolean}){
const[data,setData]=useState<CatalogResult[]>([]),[loading,setLoading]=useState(false),[error,setError]=useState(''),[query,setQuery]=useState(''),[selectedTags,setSelectedTags]=useState<string[]>([]),[source,setSource]=useState('all'),[filter,setFilter]=useState('all'),[selected,setSelected]=useState<{entry:Entry;source:Source}|null>(null);
const[loadJob,setLoadJob]=useState<Job|null>(null);const loadSequence=useRef(0);
@@ -49,7 +49,7 @@ export function Store({plugins,sources,onInspect,onSources,busy,target}:{target?
const tags=[...new Set(all.flatMap(x=>x.entry.tags??[]))].sort();
const shown=all.filter(x=>`${x.entry.name} ${x.entry.description} ${x.entry.author} ${(x.entry.tags??[]).join(' ')}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())&&(!selectedTags.length||(x.entry.tags??[]).some(tag=>selectedTags.includes(tag)))&&(source==='all'||x.source.id===source)&&(filter==='all'||filter==='installed'&&!!plugins[x.entry.id]||filter==='update'&&x.state.kind==='update'));
const request=(p:Entry,s:Source)=>({url:p.url,sha256:p.sha256,pluginId:p.id,version:p.version,source:s.id,allowHttp:s.allowHttp});
if(selected){const p=selected.entry,s=selected.source,installed=plugins[p.id],state=stateFor(p,installed,s);const latest=all.find(x=>x.entry.id===p.id&&x.source.id===s.id)?.entry??p;const versions=[latest,...(history.key===historyKey?history.entries:[]).filter(v=>v.version!==latest.version)];return <><button className="back-link" onClick={()=>setSelected(null)}><Icon name="arrow"/>{t("返回插件库")}</button><section className="store-detail"><div className="detail-heading"><PluginImage src={p.icon} name={p.name} size={80}/><div><h1>{p.name}</h1><p><PluginAuthor plugin={p}/> · {p.version}</p></div><button className="primary" disabled={busy||['incompatible'].includes(state.kind)} onClick={()=>void onInspect(request(p,s))}>{state.kind==='update'?t("更新"):state.kind==='new'?t("安装"):state.kind==='other'||state.kind==='different'?t("检查此版本"):state.kind==='installed'?t("重新安装"):state.kind==='older'?t("安装所选版本"):state.label}</button></div><RunUserNotice user={p.runAs}/><section className="settings-card">{history.key===historyKey&&history.loading&&<p className="sub" role="status">{t("正在加载历史版本…")}</p>}{history.key===historyKey&&history.error&&<p className="error" role="alert">{t("历史版本暂时不可用,仍可安装最新版。")}<button onClick={()=>setHistoryRetry(v=>v+1)}>{t("重试")}</button></p>}<Select label={t("可选安装版本")} value={p.version} onChange={version=>{const entry=versions.find(v=>v.version===version);if(entry)setSelected({entry,source:s});}} options={versions.map(v=>({value:v.version,label:v.version+(v.version===installed?.manifest.version?t(" · 已安装"):v===versions[0]?t(" · 推荐版本"):"")}))}/></section><div className="badges"><span>{state.label}</span><span>{s.name}</span>{(p.tags??[]).map(t=><span key={t}>{t}</span>)}</div>{state.kind==='other'&&<p className="banner">{t("当前安装来自其他源,确认安装此版本后会更换来源。")}</p>}<p className="detail-summary">{p.description}</p><PluginLinks plugin={p}/>{!!p.screenshots?.length&&<div className="screenshots">{p.screenshots.map((url,i)=><figure key={url}><img src={url} alt={t("{0}截图 {1}", {"0": p.name, "1": i+1})}/></figure>)}</div>}<h2>{t("关于插件")}</h2><p className="prose">{p.details||p.description||t("开发者尚未提供详细介绍。")}</p><h2>{t("依赖与冲突")}</h2>{Object.entries(p.dependencies??{}).map(([id,d])=><p key={id}>{t("必需:")}{id} · {typeof d==='string'?d:d.version}{typeof d==='object'&&<small className="source-url">{d.source}</small>}</p>)}{Object.entries(p.optionalDependencies??{}).map(([id,d])=><p key={id}>{t("可选:")}{id} · {typeof d==='string'?d:d.version}</p>)}{Object.entries(p.conflicts??{}).map(([id,range])=><p key={id}>{t("冲突:")}{id} · {range}</p>)}{!!p.exclusiveResources?.length&&<p>{t("独占资源:")}{p.exclusiveResources.join(', ')}</p>}<h2>{t("更新记录")}</h2><p className="prose">{p.changelog||t("开发者尚未提供更新记录。")}</p><section className="settings-card"><h2>{t("版本与来源")}</h2><p>{installed?t("本机版本:{0}", {"0": installed.manifest.version}):t("尚未安装")}{t("· 可用版本:")}{p.version}</p><p className="sub">{t("安装前会展示运行用户。插件库资料由插件源提供,以包内的信息为准。")}</p></section></section></>;}
if(selected){const p=selected.entry,s=selected.source,installed=plugins[p.id],state=stateFor(p,installed,s);const latest=all.find(x=>x.entry.id===p.id&&x.source.id===s.id)?.entry??p;const versions=[latest,...(history.key===historyKey?history.entries:[]).filter(v=>v.version!==latest.version)];return <><button className="back-link" onClick={()=>setSelected(null)}><Icon name="arrow"/>{t("返回插件库")}</button><section className="store-detail"><div className="detail-heading"><PluginImage src={p.icon} name={p.name} size={80}/><div><h1>{p.name}</h1><p><PluginAuthor plugin={p}/> · {p.version}</p></div><button className="primary" disabled={busy||['incompatible'].includes(state.kind)} onClick={()=>void onInspect(request(p,s))}>{state.kind==='update'?t("更新"):state.kind==='new'?t("安装"):state.kind==='other'||state.kind==='different'?t("检查此版本"):state.kind==='installed'?t("重新安装"):state.kind==='older'?t("安装所选版本"):state.label}</button></div><RunUserNotice user={p.runAs}/><section className="settings-card">{history.key===historyKey&&history.loading&&<p className="sub" role="status">{t("正在加载历史版本…")}</p>}{history.key===historyKey&&history.error&&<p className="error" role="alert">{t("历史版本暂时不可用,仍可安装最新版。")}<button onClick={()=>setHistoryRetry(v=>v+1)}>{t("重试")}</button></p>}<Select label={t("可选安装版本")} value={p.version} onChange={version=>{const entry=versions.find(v=>v.version===version);if(entry)setSelected({entry,source:s});}} options={versions.map(v=>({value:v.version,label:v.version+(v.version===installed?.manifest.version?t(" · 已安装"):v.compatible===false?t(" · 不兼容"):v===versions[0]?t(" · 推荐版本"):"")}))}/></section><div className="badges"><span>{state.label}</span><span>{s.name}</span>{(p.tags??[]).map(t=><span key={t}>{t}</span>)}</div>{state.kind==='other'&&<p className="banner">{t("当前安装来自其他源,确认安装此版本后会更换来源。")}</p>}<p className="detail-summary">{p.description}</p><PluginLinks plugin={p}/>{p.engines?.framely&&<p className={p.compatible===false?'banner':'sub'}>{t('要求 Framely:{0}',{0:p.engines.framely})}</p>}{!!p.screenshots?.length&&<div className="screenshots">{p.screenshots.map((url,i)=><figure key={url}><img src={url} alt={t("{0}截图 {1}", {"0": p.name, "1": i+1})}/></figure>)}</div>}<h2>{t("关于插件")}</h2><p className="prose">{p.details||p.description||t("开发者尚未提供详细介绍。")}</p><h2>{t("依赖与冲突")}</h2>{Object.entries(p.dependencies??{}).map(([id,d])=><p key={id}>{t("必需:")}{id} · {typeof d==='string'?d:d.version}{typeof d==='object'&&<small className="source-url">{d.source}</small>}</p>)}{Object.entries(p.optionalDependencies??{}).map(([id,d])=><p key={id}>{t("可选:")}{id} · {typeof d==='string'?d:d.version}</p>)}{Object.entries(p.conflicts??{}).map(([id,range])=><p key={id}>{t("冲突:")}{id} · {range}</p>)}{!!p.exclusiveResources?.length&&<p>{t("独占资源:")}{p.exclusiveResources.join(', ')}</p>}<h2>{t("更新记录")}</h2><p className="prose">{p.changelog||t("开发者尚未提供更新记录。")}</p><section className="settings-card"><h2>{t("版本与来源")}</h2><p>{installed?t("本机版本:{0}", {"0": installed.manifest.version}):t("尚未安装")}{t("· 可用版本:")}{p.version}</p><p className="sub">{t("安装前会展示运行用户。插件库资料由插件源提供,以包内的信息为准。")}</p></section></section></>;}
return <><div className="list-heading"><div><h1>{t("插件库")}</h1><p>{t("浏览已添加插件源中的插件。")}</p></div><div className="row"><button onClick={onSources}><Icon name="link"/>{t("管理插件源")}</button><button disabled={loading} onClick={()=>void load()}><Icon name="refresh"/>{loading?t("刷新中…"):t("刷新")}</button></div></div>{loading&&<div className="banner" role="status"><span>{t("插件源加载进度:{0} / {1}",{0:loadJob?.completedSources??0,1:loadJob?.totalSources??sources.filter(s=>s.enabled).length})}</span><button disabled={!loadJob} onClick={()=>void cancelLoad()}>{t("取消加载")}</button></div>}<div className="store-filters"><div className="search-field"><Icon name="search"/><input aria-label={t("搜索插件库")} placeholder={t("搜索名称、作者或标签")} value={query} onChange={e=>setQuery(e.target.value)}/></div><Select multiple label={t("标签")} value={selectedTags} onChange={setSelectedTags} options={tags.map(value=>({value,label:value}))}/><Select label={t("插件源")} value={source} onChange={setSource} options={[{value:'all',label:t("所有来源")},...sources.filter(s=>s.enabled).map(s=>({value:s.id,label:s.name}))]}/><Select label={t("安装状态")} value={filter} onChange={setFilter} options={[{value:"all",label:t("全部状态")},{value:"installed",label:t("已安装")},{value:"update",label:t("可更新")}]}/></div>{error&&<p className="error">{error}<button onClick={()=>void load()}>{t("重试")}</button></p>}{data.filter(c=>c.error).map(c=><div className="banner" key={c.source?.id}><b>{c.source?.name??t("插件源")}{t("暂时不可用")}</b><p>{c.error}{c.cached?t(",正在显示上次成功读取的目录。"):''}</p><button disabled={loading} onClick={()=>void load()}>{t("重新连接")}</button></div>)}{loading&&!all.length?<div className="empty-state"><h2>{t("正在加载插件目录…")}</h2><p>{t("已安装插件仍可正常使用。")}</p></div>:!sources.some(s=>s.enabled)?<div className="empty-state"><Icon name="link" size={48}/><h2>{t("尚未添加插件源")}</h2><p>{t("配置你信任的目录地址,即可浏览和安装插件。")}</p><button className="primary" onClick={onSources}>{t("管理插件源")}</button></div>:!shown.length?<div className="empty-state"><h2>{all.length?t("没有符合条件的插件"):t("目录暂时没有插件")}</h2><p>{all.length?t("试试其他关键词或清除筛选。"):t("可以检查插件源连接,或导入本地插件包。")}</p>{!!all.length&&<button onClick={()=>{setQuery('');setSelectedTags([]);setSource('all');setFilter('all');}}>{t("清除筛选")}</button>}</div>:<><p className="sub">{shown.length}{t("个结果")}</p><div className="store-grid">{shown.map(({entry:p,source:s,state})=><article className="store-card" key={`${s.id}:${p.id}`} onClick={()=>setSelected({entry:p,source:s})}><PluginImage src={p.icon} name={p.name} size={80}/><div className="store-copy"><h2 className="store-title-row"><button className="store-title" onClick={()=>setSelected({entry:p,source:s})}>{p.name}</button><RunUserBadge user={p.runAs}/></h2><p>{p.description||t("查看插件详情")}</p><small><PluginAuthor plugin={p}/> · {s.name}</small></div><span className="store-version">{p.version}</span><span className={`badge ${state.kind==='update'?'accent':''}`}>{state.label}</span><Icon name="arrow"/></article>)}</div></>}</>;
}
export function Transfer({job,retry,cancel,close}:{job:Job;retry?:()=>void;cancel?:()=>void;close:()=>void}){const labels:Record<string,string>={queued:t("等待开始"),downloading:t("正在下载"),verifying:t("正在校验内容"),resolving:t("正在解析插件依赖"),installing:t("正在安装"),loading:t("正在连接"),done:t("已完成"),failed:t("操作失败"),cancelled:t("已取消")};const percent=job.total?Math.min(100,Math.round(job.received/job.total*100)):undefined;return <div className="modal transfer"><h2>{labels[job.phase]??job.phase}</h2>{job.phase==='downloading'&&<><progress max={100} value={percent}/><p>{(job.received/1024/1024).toFixed(1)} MiB{job.total?` / ${(job.total/1024/1024).toFixed(1)} MiB · ${percent}%`:''}</p></>}{job.error&&<p className="error">{job.error}</p>}{job.phase==='failed'&&retry&&<button className="primary" onClick={retry}>{t("重试")}</button>}{!['done','failed','cancelled','installing'].includes(job.phase)&&cancel&&<button onClick={cancel}>{t("取消")}</button>}{['done','failed','cancelled'].includes(job.phase)&&<button onClick={close}>{t("关闭")}</button>}{job.phase==='installing'&&<p className="sub">{t("请稍候,正在保存插件和配置。")}</p>}</div>}