Compare commits

..
Author SHA1 Message Date
toorux cea24afbc9 release: core 0.8.3 2026-10-20 00:31:08 +08:00
toorux e43000875c feat(installer): support explicit GitHub proxy during installation 2026-10-20 00:30:56 +08:00
toorux a159dfc2d2 docs(install): use latest release bootstrap for stable installs 2026-10-19 23:40:22 +08:00
toorux 7be3553936 fix(input): keep scrolling focused page with keyboard open 2026-10-19 23:37:23 +08:00
toorux 9e25abb9b6 fix(radial): default menu off and retain progress overlay 2026-10-19 23:33:23 +08:00
toorux 590ae46bc5 fix(installer): skip redundant confirmation for explicit installs
Treat missing terminal input as a distinct error for operations that still require confirmation, and document SSH terminal requirements.
2026-10-19 23:18:07 +08:00
toorux f9f14947ee feat(headset): connect screenshot, radial, and keyboard runtime flows
Wire native overlays, screenshot sessions, radial actions, and keyboard interception through the host and session APIs. Keep the progress ring in a separate CEF view so repeat long presses cannot flash the previous menu frame.
2026-10-19 22:52:42 +08:00
toorux 4746357769 feat(cast): play Steam camera sound after screenshots 2026-10-19 22:52:17 +08:00
toorux b3182c46ec feat(input): add configurable Steam button radial controls 2026-10-19 22:51:58 +08:00
toorux 109160ac5b feat(keyboard): bridge Frame keyboard input into embedded views 2026-10-19 22:51:27 +08:00
toorux 342fa96056 feat(cast): add native-resolution screenshot capture and framing 2026-10-19 22:51:02 +08:00
toorux f4886c4de2 fix(installer): handle nonblocking accepted sockets in download tests
Wait for complete bounded HTTP headers in the shared test server, cover delayed fragmented requests, and release installer 0.4.3.
2026-10-18 02:34:04 +08:00
toorux 05e3215204 release: core 0.8.3-preview.3 and installer 0.4.2 2026-10-17 18:12:42 +08:00
toorux dbf37ecd33 fix(installer): allow confirming changed SSH host fingerprints 2026-10-17 18:08:55 +08:00
toorux f301327eb8 feat(plugins): discover GitHub releases alongside catalogs 2026-10-17 18:08:23 +08:00
toorux ed51e913bb fix(network): avoid self-redirect loops on passwordless manager entry 2026-10-17 13:03:55 +08:00
75 changed files with 3809 additions and 219 deletions

No files matched your search

Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.8.3-preview.2"
version = "0.8.3"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.8.3-preview.2"
version = "0.8.3"
edition = "2021"
license = "AGPL-3.0-only"
+1 -1
View File
@@ -36,7 +36,7 @@ Download the package for your computer from the [Framely Installer release](http
Run the following command **in Frame's SSH terminal** to install the latest stable release:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes
```
For a Preview release, specify an actual published tag:
+1 -1
View File
@@ -36,7 +36,7 @@ Framely 使用 Rust 核心服务和独立的 CEF/OpenVR 宿主,并提供电脑
**在 Frame 的 SSH 终端中**执行以下命令,安装最新正式版:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes
```
安装 Preview 时指定实际已发布的标签:
+2
View File
@@ -6,6 +6,8 @@
## Default GitHub Release URL
GitHub topic discovery is also supported without a catalog. See [GitHub plugin discovery and release.json](../github-plugin-discovery.md) for the generated Release asset, legacy compatibility and publishing commands.
A public GitHub plugin repository's **source `manifest.json` may omit `downloadUrl`**. The community database derives it from the registered repository, version and plugin ID:
```text
+22
View File
@@ -0,0 +1,22 @@
# GitHub plugin discovery
[简体中文及完整 JSON 示例](zh-CN/github-plugin-discovery.md)
Framely searches public GitHub repositories with topic `framely-plugin`, excluding forks and archived repositories. Releases appear in the existing plugin library alongside catalogs. One row per plugin ID recommends the highest compatible SemVer; version selection retains different package hashes and origins. Existing install confirmation, runtime-user checks and dependency resolution apply.
Upload a `release.json` asset with `schemaVersion: 1` and a nonempty `plugins` array. Required plugin fields are `id`, `name`, `version`, `apiVersion`, `author`, `description`, `asset`, `size`, `sha256`, and `runAs`. `asset` identifies a `.framely` asset in that Release; size is whole-package bytes and SHA256 is 64 hexadecimal characters. `runAs` is the effective backend/lifecycle user, or explicit `null` for UI-only plugins. Optional presentation, engines and relation fields follow existing catalog metadata. Images may name Release assets or fixed HTTPS URLs. Release tag, prerelease flag and download URLs come from GitHub.
Generate metadata from final, verified packages rather than maintaining hashes manually:
```bash
framely release-json --packages ./release --output ./release/release.json
gh release upload "$TAG" ./release/*
```
The generator also exports packaged images into that directory unless external publishing images are configured. Upload every generated file. Add these commands to your own publishing CI and add the repository topic to opt into discovery. Existing external plugin repositories are not changed by this feature.
Only Releases without `release.json` use legacy compatibility: resolve the tag to a commit SHA, read its root `manifest.json`, require tag/version agreement, match the explicit download URL or `<id>-<version>.framely`, and require GitHub's SHA256 asset digest or `SHA256SUMS`. All available hashes must agree. Malformed new metadata never falls back to legacy metadata. Old clients continue using their existing catalogs; package format is unchanged.
Metadata uses pagination, SemVer sorting, a ten-minute cache, ETag revalidation and a global six-request concurrency limit. Manual refresh revalidates. Retryable network failures may use stale cache with a warning; invalid JSON, certificate errors and ordinary 404s do not. Proxy settings and cancellation are reused. Browsing does not download package bytes.
GitHub sources use canonical repository URLs and stable IDs. A newly discovered source is persisted only when installation is confirmed. Updates, history and dependencies subsequently read its Releases. Disabled sources remain disabled. Installation verifies actual package hash, identity, version, API, engine requirements, runtime user and relations against Release metadata.
+2
View File
@@ -64,6 +64,8 @@ framely catalog --name 'My plugins' --base-url https://example.org/plugins --pac
Archives contain Manifest and hashed payload only, without keys/signatures. Host the main catalog and relative history JSON. Authors can host packages on GitHub Releases. Current first initialization includes community stable/testing sources. Origins remain explicit; same-ID plugins in other sources do not silently replace existing origins. HTTP needs explicit development permission; up to five validated redirects, without HTTPS downgrade.
Alternatively, opt into [GitHub plugin discovery](github-plugin-discovery.md) with topic `framely-plugin` and a generated `release.json` asset. The library merges identical IDs and recommends the highest compatible version, preserving origin/hash choices and installation confirmation. Existing catalogs and older Releases remain supported.
## Hover feedback
Interactive controls automatically receive one hover pulse per entry, excluding disabled/inert controls and movement between their children. Supported elements include buttons, links, inputs, interactive ARIA roles/focusable controls or pointer cursors. Custom controls may mark `data-framely-interactive`. Native feedback uses the current hit controller, 12 ms/120 Hz/amplitude 0.15 with at least 80 ms spacing. Pages cannot target other windows/controllers.
+6 -6
View File
@@ -38,8 +38,8 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven
Device and installer share the repository with independent versions/tags/workflows:
- Device: root Cargo version, `v<version>` (currently `v0.7.1`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.10`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
- Device: root Cargo version, `v<version>` (currently `v0.8.3`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.3`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
Versions need not match or ship together. Update the relevant Cargo.lock with version changes. Installer package/macOS versions use the installer version.
@@ -47,11 +47,11 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
```bash
# After committing and pushing the intended source:
git tag v0.7.1
git push origin v0.7.1
git tag v0.8.3
git push origin v0.8.3
# Independent installer release:
git tag installer-v0.4.1-preview.10
git push origin installer-v0.4.1-preview.10
git tag installer-v0.4.3
git push origin installer-v0.4.3
```
Prerelease suffixes create GitHub Prereleases without Latest. Installer releases are never Latest. Only stable device releases become Latest, keeping default installer/update URLs on the device product. The desktop installer filters device archive names and does not mistake ARM64 installer packages for device runtimes. Download desktop installers from their explicit installer tags.
+11 -1
View File
@@ -27,7 +27,7 @@ Downloads are checked locally and again on the device. Passwords are not saved i
Run these commands **on Frame**. To install the latest stable release:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes
```
To select a Preview or another tag:
@@ -38,6 +38,16 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
Replace the example with an existing device release tag. An explicit tag selects the engine and package from that Release and works without a stable release. Without a tag, the latest stable release is selected. Enter the sudo password when prompted.
Explicit `install` and `update` commands need no second confirmation. The interactive action menu and uninstall still ask for `y` and Enter. If sudo needs a password in a single remote `ssh` command, allocate a terminal with `ssh -t`; `--yes` skips only Framely's confirmation, not sudo authentication.
To route the script download and all later GitHub requests through a chosen HTTPS URL prefix, use `--github-proxy`:
```bash
curl -fsSL https://gh-proxy.com/https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes --github-proxy https://gh-proxy.com
```
Release `v0.8.2` does not include this option; use `v0.8.3` or newer. The installer still verifies archive checksums.
## Option 3: local archive
Download `framely-<version-and-build>-offline-linux-arm64.tar.gz`, the external `SHA256SUMS`, and `bootstrap.py` from the same Release. Place them together on Frame:
+2
View File
@@ -6,6 +6,8 @@
## GitHub Release 默认地址
现在也支持通过 GitHub topic 自动发现,无需目录文件。`release.json` 格式、生成命令和旧 Release 兼容规则见 [GitHub 插件自动发现](../github-plugin-discovery.md)。
公开 GitHub 插件仓库的**源码 `manifest.json` 可以省略 `downloadUrl`**。社区数据库从登记的仓库地址、版本和插件 ID 自动生成:
```text
+79
View File
@@ -0,0 +1,79 @@
# GitHub 插件自动发现与 Release 元数据
[English](../github-plugin-discovery.md)
Framely 在现有插件库中搜索带 `framely-plugin` topic 的公开 GitHub 仓库,排除 fork 和 archived 仓库,再读取 Releases。无需 npm 或额外目录文件。现有社区源、自建源继续使用;同 ID 合并为一个列表项,优先推荐语义版本最高的兼容版本。来源、正式版/预览版和历史版本可在原界面选择。同 ID、版本和包哈希相同的条目合并;同版本不同哈希仍保留,版本选项显示短哈希。更换来源继续经过安装确认。
## 新 Release:release.json
每个 Release 上传一个 `release.json` 附件,可描述多个插件。不要放在仓库默认分支来代替 Release 附件。
```json
{
"schemaVersion": 1,
"plugins": [{
"id": "example.plugin",
"name": "Example Plugin",
"version": "1.2.0",
"apiVersion": 1,
"author": "Developer",
"description": "插件简介",
"engines": {"framely": ">=0.8.3"},
"asset": "example.plugin-1.2.0.framely",
"size": 123456,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"runAs": "steamos",
"icon": "example.plugin-1.2.0-icon.png",
"screenshots": [],
"tags": ["工具"],
"details": "详细介绍",
"changelog": "更新记录",
"homepage": "https://github.com/example/plugin",
"dependencies": {},
"optionalDependencies": {},
"conflicts": {},
"exclusiveResources": []
}]
}
```
必填:`id`、`name`、`version`(SemVer)、`apiVersion`、`author`、`description`、`asset`、`size`(整包字节数)、`sha256`(整包 64 位十六进制 SHA256)、`runAs`。有后端或生命周期脚本时,`runAs` 是实际运行用户;纯界面插件为 `null`,不能省略。其他字段遵循现有插件目录格式;图片可以是同一 Release 的附件名或固定 HTTPS 地址。`authorUrl`、`documentationUrl` 也支持现有格式。
`asset` 必须对应同一 Release 的 `.framely` 附件;大小必须与 GitHub 附件资料一致。GitHub 提供 tag、预览版标记及附件下载地址,JSON 无需重复 `downloadUrl`。有 GitHub asset digest/`SHA256SUMS` 时必须与 JSON 一致。声明不合法、缺附件或哈希冲突的 Release 不会作为可安装版本,也不会退回旧格式掩盖错误。
## 自动生成及上传
先完成正常构建、打包和验证,把最终 `.framely` 包放入一个专用发行目录,再运行新版 Framely:
```bash
framely release-json --packages ./release --output ./release/release.json
```
在 Framely 源码目录也可用 `cargo run --locked -- release-json ...`。生成器验证包内文件哈希,读取最终包内清单,自动计算整包 SHA256、字节数、运行用户及依赖。没有 `publish.icon`/非空 `publish.screenshots` 外链时,自动导出包内图标和截图到同一发行目录,JSON 使用这些附件名。拒绝覆盖内容不同的同名发行图片。每个插件 ID 在一次发行中只出现一次。
将生成步骤加入自己的发布 CI,并上传发行目录中的全部包、JSON 和图片,例如已有 GitHub CLI 发布流程中:
```bash
framely release-json --packages ./release --output ./release/release.json
gh release upload "$TAG" ./release/*
```
不要在生成 JSON 后重新打包,也不要覆盖已发布的同版本附件。想加入自动发现,由仓库维护者添加 `framely-plugin` topic。本次 Framely 实现不会替维护者修改外部插件仓库。
## 没有 release.json 的旧 Release
只有完全没有该附件时才采用兼容路径:
1. 将 Release 的 tag 解析为提交 SHA,读取该 SHA 的根目录 `manifest.json`;不读取默认分支。
2. tag 为 `v<version>` 或 `<version>`,且必须与清单版本完全一致。
3. 清单有 `downloadUrl` 时必须精确对应本 Release 的 `.framely` 附件;否则匹配 `<id>-<version>.framely`。
4. 使用 GitHub asset digest 或 Release 的 `SHA256SUMS`;二者都存在必须一致。均缺失则不允许安装,不猜测哈希。可选 `downloadSha256` 也必须一致。
5. 有 `publish` 图片外链时沿用;否则图标使用固定提交 SHA 的 Raw 地址。
旧插件和现有 `catalog.json`/`versions.json` 来源无需迁移,旧客户端仍沿用原有来源,忽略新 Release 附件。`release.json` 只是增加一种发现方式,不替换现有安装包格式和兼容性检查。
## 缓存、安装及失败处理
搜索和 Releases 分页读取,最多各 10 页,每页 100 条;Release 先全部读取再按 SemVer 排序,不依赖 API 返回顺序。达到搜索上限会提示;超过 Release 历史上限会报错,避免错误推荐未完整读取的版本。元数据缓存 10 分钟,携带 ETag,手动刷新重新验证;网络失败时可显示旧缓存并提示,证书、解析和普通 404 等错误不隐藏。所有 GitHub 请求最多同时 6 个,支持取消,复用现有 HTTP/手动 GitHub 代理和自动代理策略。浏览目录不下载插件包。
首次选择 GitHub 插件时来源是规范仓库地址 `https://github.com/<owner>/<repo>`,来源 ID 根据仓库名稳定生成,安装确认成功才写入配置。后续更新、依赖和历史版本从该仓库 Releases 读取;禁用来源不会被自动发现重新启用。安装时再次核对整包哈希及包内 ID、版本、API、Framely 要求、实际运行用户、依赖与冲突;权限和来源切换使用现有确认流程。部分仓库失败不会隐藏其他来源已成功加载的插件。
+1 -1
View File
@@ -79,7 +79,7 @@ framely catalog --name 'My plugins' --base-url https://example.org/plugins --pac
包中包含 manifest.json 和文件载荷,files 记录每个载荷的 SHA256;不生成公钥或签名。只支持 manifest.json 与清单中声明的载荷文件,拒绝未声明的附加文件。
源需静态托管 catalog.json 及相对路径下的 plugins/<ID>/versions.json;插件包可放在作者自己的 GitHub Release。在插件源管理中添加目录完整 URL;当前代码首次初始化加入社区 stable/testing 来源,实际内容以服务器为准。目录条目有版本、下载 URL、SHA256。每个源独立显示,插件记录所选来源;另一个源同 ID 不会静默替换。HTTP 必须用户明确允许;目录和包支持最多五次重定向,HTTPS 不允许降级为 HTTP。
传统源可静态托管 catalog.json 及相对路径下的 plugins/<ID>/versions.json;插件包可放在作者自己的 GitHub Release。在插件源管理中添加目录完整 URL;当前代码首次初始化加入社区 stable/testing 来源,实际内容以服务器为准。目录条目有版本、下载 URL、SHA256。插件库也支持 [GitHub 自动发现及 release.json](github-plugin-discovery.md),同 ID 合并展示并按兼容语义版本推荐,历史版本保留不同来源及哈希。插件记录所选来源,切换来源仍经过安装确认。HTTP 必须用户明确允许;目录和包支持最多五次重定向,HTTPS 不允许降级为 HTTP。
### 悬停反馈
+6 -6
View File
@@ -48,8 +48,8 @@ CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.7.1`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.10`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.8.3`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.3`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
@@ -59,12 +59,12 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
```bash
# 提交并推送代码后,选择需要发布的产品
git tag v0.7.1
git push origin v0.7.1
git tag v0.8.3
git push origin v0.8.3
# 独立发布安装器,不触发 Framely 构建
git tag installer-v0.4.1-preview.10
git push origin installer-v0.4.1-preview.10
git tag installer-v0.4.3
git push origin installer-v0.4.3
```
带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。
+11 -1
View File
@@ -27,7 +27,7 @@ Linux 用户可在安装器解压目录执行 `bash install-desktop-entry.sh`,
以下命令在 **Frame 上** 执行。默认安装最新正式版:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes
```
指定 Preview 或其他标签:
@@ -38,6 +38,16 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
将示例标签换成 Releases 中实际存在的本体标签。指定标签时从该 Release 下载引擎和包,不要求已有正式版;不指定标签时使用最新正式版。终端会按需请求 sudo 密码。
明确指定 `install` 或 `update` 时无需再次确认。使用交互菜单或卸载时,脚本会询问是否继续,需要输入 `y` 并回车。如果通过一条 `ssh` 远程命令执行且 sudo 需要密码,请用 `ssh -t` 分配交互终端;`--yes` 仅跳过 Framely 的确认,不会跳过 sudo 密码。
使用 `--github-proxy` 可通过指定的 HTTPS 地址代理入口脚本及后续所有 GitHub 下载。例如:
```bash
curl -fsSL https://gh-proxy.com/https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes --github-proxy https://gh-proxy.com
```
`v0.8.2` 不支持此参数,请使用 `v0.8.3` 或更新版本。安装器仍会校验下载包的哈希值。
## 方式三:本地发行包
下载完整离线包 `framely-<版本及构建号>-offline-linux-arm64.tar.gz`、外部 `SHA256SUMS` 和同一 Release 的 `bootstrap.py`,放入 Frame 的同一目录:
+28 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Public bootstrap: curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash
# Public bootstrap: curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash
set -euo pipefail
repo=${FRAMELY_REPOSITORY:-SteamFramelyHomebrew/framely}
[[ $repo =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]] || { echo 'Invalid repository.' >&2; exit 1; }
@@ -8,19 +8,43 @@ work=$(mktemp -d)
trap 'rm -rf -- "$work"' EXIT
release_path=latest/download
args=("$@")
github_proxy=${FRAMELY_GITHUB_PROXY:-}
for ((i=0; i<${#args[@]}; i++)); do
case ${args[i]} in
--version)
((i+=1))
version=${args[i]:-}
[[ $version =~ ^[a-zA-Z0-9][a-zA-Z0-9.+-]*$ ]] || { echo 'Invalid release version.' >&2; exit 1; }
release_path="download/$version"
;;
--version=*)
version=${args[i]#--version=}
[[ $version =~ ^[a-zA-Z0-9][a-zA-Z0-9.+-]*$ ]] || { echo 'Invalid release version.' >&2; exit 1; }
release_path="download/$version"
;;
--github-proxy)
((i+=1))
github_proxy=${args[i]:-}
[[ -n $github_proxy ]] || { echo 'Missing GitHub proxy URL.' >&2; exit 1; }
;;
--github-proxy=*)
github_proxy=${args[i]#--github-proxy=}
[[ -n $github_proxy ]] || { echo 'Missing GitHub proxy URL.' >&2; exit 1; }
;;
--version=*) version=${args[i]#--version=} ;;
*) continue ;;
esac
[[ $version =~ ^[a-zA-Z0-9][a-zA-Z0-9.+-]*$ ]] || { echo 'Invalid release version.' >&2; exit 1; }
release_path="download/$version"
done
if [[ -n $github_proxy ]]; then
[[ $github_proxy =~ ^https://[a-zA-Z0-9.-]+(:[0-9]{1,5})?(/[a-zA-Z0-9._~/-]*)?$ && /$github_proxy/ != *'/../'* && /$github_proxy/ != *'/./'* ]] || { echo 'Invalid GitHub proxy URL (HTTPS base URL required).' >&2; exit 1; }
github_proxy=${github_proxy%/}
export FRAMELY_GITHUB_PROXY=$github_proxy
fi
bootstrap_url="https://github.com/$repo/releases/$release_path/bootstrap.py"
if [[ -n $github_proxy ]]; then
curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --output "$work/bootstrap.py" "$github_proxy/$bootstrap_url"
python3 "$work/bootstrap.py" --repo "$repo" "$@"
exit
fi
if status=$(curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --dump-header "$work/headers" --write-out '%{http_code}' --output "$work/bootstrap.py" "$bootstrap_url"); then
:
else
+1 -1
View File
@@ -1985,7 +1985,7 @@ dependencies = [
[[package]]
name = "framely-installer"
version = "0.4.1-preview.10"
version = "0.4.3"
dependencies = [
"anyhow",
"base64 0.22.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely-installer"
version = "0.4.1-preview.10"
version = "0.4.3"
edition = "2024"
license = "AGPL-3.0-only"
+2 -2
View File
@@ -16,7 +16,7 @@ Only one installer instance can run per user; additional instances exit immediat
1. Enable developer mode and set a password on Frame. Connect the computer and device to the same LAN.
2. Discovery prioritizes `192.168.*` subnets, with at most 1024 addresses per scan. Name discovery and IP probing run together. Only devices whose hostname is `frame` are listed, with SSH availability indicated. Other devices and addresses without a confirmed name are omitted. Manual IP and SSH port entry is always available.
3. Check the SSH handshake and displayed host fingerprint, then enter credentials. The default user is `steamos`. Changed saved fingerprints cause the connection to be rejected. After verifying a device reinstall, you can remove its entry from `known-hosts.json` in the configuration directory.
3. Check the SSH handshake and displayed host fingerprint, then enter credentials. The default user is `steamos`. If a reinstall changes the host key, the connection page shows both old and new fingerprints. Verify the device and choose “Trust new fingerprint and connect” to continue without editing `known-hosts.json`. Cancellation or failed login preserves the old record; the new fingerprint is saved only after authentication and device checks succeed. A further key change before login stops the connection and requires a fresh SSH check.
4. Select a release or a local `framely-*-linux-arm64.tar.gz` together with its external `SHA256SUMS` or matching `.sha256` file. Local packages do not require GitHub access. Prereleases are hidden by default.
5. Choose an operation on Install & Maintenance and confirm the device, version and action in the modal dialog. Cancel or press Esc to return without executing. Errors and operation results also appear in dialogs. SSH fingerprint verification stays on the connection page. The installer does not disable SteamOS root filesystem read-only protection; it stops with an error when required directories are not writable.
@@ -64,7 +64,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.10`) 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.3`) 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.
+2 -2
View File
@@ -16,7 +16,7 @@ Rust + GPUI Kit 的桌面安装器,连接 Steam Frame,安装、更新、修
1. 在 Frame 开启开发者模式并设置密码;电脑和设备连接同一局域网。
2. 默认优先扫描 `192.168.*` 网段(每次最多 1024 个地址)。名称发现和 IP 探测同时开始;只列出主机名为 `frame` 的设备,并标注 SSH 是否可用。其他设备和无法确认名称的地址不会显示。手动 IP 和 SSH 端口始终可用。
3. 检查 SSH 握手,核实显示的主机指纹,再输入账号密码登录。用户名默认 `steamos`。已保存的指纹发生变化时拒绝连接;核实设备重装后可手动移除配置目录中的对应 `known-hosts.json` 条目。
3. 检查 SSH 握手,核实显示的主机指纹,再输入账号密码登录。用户名默认 `steamos`。设备重装等情况导致指纹变化时,连接页显示旧、新指纹,核实设备身份后点击“信任新指纹并连接”即可继续,无需手动修改 `known-hosts.json`。取消或登录失败保留原记录;登录及设备检查成功后才保存新指纹。登录前若指纹再次变化,会停止并要求重新检查 SSH。
4. 从 Releases 选择版本,或选择本地 `framely-*-linux-arm64.tar.gz` 和压缩包外部的 `SHA256SUMS`(也可选择对应 `.sha256` 文件)。本地包不需要访问 GitHub。默认隐藏测试版。
5. 在“安装与维护”选择操作,在弹窗中核对设备、版本和操作后确认;取消或按 Esc 返回,不会执行。错误和操作结果也使用弹窗。SSH 指纹确认仍保留在连接页面。安装器不会解除 SteamOS 根分区的只读保护,所需目录不可写时会报错停止。
@@ -64,7 +64,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.10`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.3`)触发 `.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 代码签名,发布前应分别验证系统的首次启动体验。
+82 -2
View File
@@ -275,7 +275,12 @@ impl Installer {
self.busy = false;
match result {
Ok(probe) => {
self.status = "SSH 握手成功。确认下方设备指纹后登录。".into();
self.status = if probe.changed() {
"设备指纹已变化。若设备已重装,请核对下方新指纹后重新信任并连接。"
} else {
"SSH 握手成功。确认下方设备指纹后登录。"
}
.into();
self.probe = Some(probe);
}
Err(error) => {
@@ -294,6 +299,10 @@ impl Installer {
self.busy = false;
match result {
Ok(connection) => {
if let Some(probe) = &mut self.probe {
probe.previous_fingerprint = Some(probe.fingerprint.clone());
probe.known = true;
}
self.status =
format!("已连接 {}:{}", connection.host, connection.version);
self.chosen_action = connection.installation.default_action().into();
@@ -527,7 +536,7 @@ impl Installer {
self.connection_stage = Some(self.status.clone());
let sender = self.sender.clone();
std::thread::spawn(move || {
let result = remote::connect(credentials, &probe.fingerprint, &mut |stage| {
let result = remote::connect(credentials, &probe, &mut |stage| {
let _ = sender.send(Event::ConnectionStage(stage));
})
.map_err(|e| format!("{e:#}"));
@@ -837,6 +846,75 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
})?;
context.update_window(handle, |_, window, cx| transparent_window_root(window, cx))?;
context.allow_parking();
if std::env::var("FRAMELY_INSTALLER_PREVIEW_CASE").as_deref() == Ok("fingerprint-change") {
context.update_window(handle, |_, window, cx| {
view.update(cx, |view, cx| {
view.page = 0;
view.busy = true;
view.connection = None;
view.sender
.send(Event::Probe(Ok(Probe {
endpoint: "192.168.1.42:22".into(),
fingerprint: "SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".into(),
known: false,
previous_fingerprint: Some(
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into(),
),
})))
.unwrap();
view.poll(window, cx);
assert!(!view.busy && view.probe.as_ref().unwrap().changed());
assert!(view.status.contains("设备指纹已变化"));
});
window.render_frame(cx);
assert!(window.find("connect").visible());
assert!(window.find("cancel-fingerprint-change").visible());
})?;
context.run_until_parked();
context.update_window(handle, |_, window, cx| window.render_frame(cx))?;
context
.capture_screenshot(handle)?
.save(dir.join("installer-fingerprint-change.png"))?;
let pending =
context.update_window(handle, |_, _, cx| view.read(cx).probe.clone().unwrap())?;
context.update_window(handle, |_, window, cx| {
window.click("cancel-fingerprint-change", cx);
window.render_frame(cx);
assert!(view.read(cx).probe.is_none() && !view.read(cx).busy);
assert!(view.read(cx).status.contains("未更改"));
assert!(window.find("probe").visible());
view.update(cx, |view, cx| {
view.probe = Some(pending.clone());
view.sender
.send(Event::Connected(Err("SSH 登录失败".into())))
.unwrap();
view.poll(window, cx);
assert!(view.probe.as_ref().unwrap().changed() && !view.busy);
});
window.close_all_dialogs(cx);
view.update(cx, |view, cx| {
view.prompts.clear();
view.sender
.send(Event::Connected(Ok(Connection {
session: ssh2::Session::new().unwrap(),
host: "192.168.1.42".into(),
user: "steamos".into(),
version: "frame · 0.8.3".into(),
installation: Installation {
present: true,
current_version: Some("0.8.3".into()),
previous_version: None,
},
})))
.unwrap();
view.poll(window, cx);
let probe = view.probe.as_ref().unwrap();
assert!(probe.known && !probe.changed() && !view.busy);
});
})?;
println!("Fingerprint change preview, cancellation and connection state checks passed");
return Ok(());
}
for (page, name) in [
(0, "devices"),
(1, "versions"),
@@ -870,8 +948,10 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
view.devices.clear();
} else if name == "connecting" {
view.probe = Some(Probe {
endpoint: "192.168.1.42:22".into(),
fingerprint: "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into(),
known: false,
previous_fingerprint: None,
});
view.busy = true;
view.sender
+142 -26
View File
@@ -23,8 +23,32 @@ pub struct Credentials {
}
#[derive(Clone, Debug)]
pub struct Probe {
pub endpoint: String,
pub fingerprint: String,
pub known: bool,
pub previous_fingerprint: Option<String>,
}
impl Probe {
pub fn changed(&self) -> bool {
self.previous_fingerprint
.as_ref()
.is_some_and(|old| old != &self.fingerprint)
}
fn confirm(&self, actual: &str, hosts: &Hosts) -> Result<()> {
ensure!(
actual == self.fingerprint,
"SSH 指纹与刚才检查的不一致,请重新检查 SSH 连接"
);
ensure!(
hosts
.keys
.get(&self.endpoint)
.map(|saved| normalized_saved_fingerprint(saved))
== self.previous_fingerprint,
"已保存的 SSH 指纹记录发生变化,请重新检查 SSH 连接"
);
Ok(())
}
}
pub struct Connection {
pub session: Session,
@@ -44,7 +68,9 @@ fn hosts_file() -> Result<PathBuf> {
.join("known-hosts.json"))
}
fn load_hosts() -> Result<Hosts> {
let path = hosts_file()?;
load_hosts_from(&hosts_file()?)
}
fn load_hosts_from(path: &Path) -> Result<Hosts> {
if path.exists() {
Ok(serde_json::from_slice(&fs::read(path)?)?)
} else {
@@ -88,20 +114,24 @@ pub fn probe(host: &str, port: u16) -> Result<Probe> {
let fingerprint = fingerprint(&session)?;
let hosts = load_hosts()?;
let endpoint = format!("{host}:{port}");
if let Some(saved) = hosts.keys.get(&endpoint) {
ensure!(
normalized_saved_fingerprint(saved) == fingerprint,
"SSH 主机指纹发生变化,已阻止连接;请先核实设备身份"
);
}
Ok(Probe {
Ok(observed_probe(endpoint, fingerprint, &hosts))
}
fn observed_probe(endpoint: String, fingerprint: String, hosts: &Hosts) -> Probe {
let previous_fingerprint = hosts
.keys
.get(&endpoint)
.map(|saved| normalized_saved_fingerprint(saved));
let known = previous_fingerprint.as_ref() == Some(&fingerprint);
Probe {
endpoint,
fingerprint,
known: hosts.keys.contains_key(&endpoint),
})
known,
previous_fingerprint,
}
}
pub fn connect(
credentials: Credentials,
expected: &str,
confirmed: &Probe,
progress: &mut dyn FnMut(String),
) -> Result<Connection> {
ensure!(
@@ -118,12 +148,15 @@ pub fn connect(
"密码不能包含换行符"
);
ensure!(!credentials.password.is_empty(), "请输入设备登录密码");
ensure!(
confirmed.endpoint == format!("{}:{}", credentials.host, credentials.port),
"设备地址已变化,请重新检查 SSH 连接"
);
progress("正在建立 SSH 连接…".into());
let session = handshake(&credentials.host, credentials.port).context("无法建立 SSH 连接")?;
ensure!(
fingerprint(&session)? == expected,
"SSH 指纹与刚才检查的不一致"
);
// The Connect button confirms this exact observation, including a changed
// key. Recheck before sending credentials; never accept a second change.
confirmed.confirm(&fingerprint(&session)?, &load_hosts()?)?;
progress("正在验证用户名和密码…".into());
session
.userauth_password(&credentials.user, &credentials.password)
@@ -161,16 +194,7 @@ pub fn connect(
"未安装"
});
progress("正在保存设备指纹…".into());
let path = hosts_file()?;
fs::create_dir_all(path.parent().unwrap())
.context("无法创建指纹配置目录,请检查本机目录权限")?;
let mut hosts = load_hosts()?;
hosts.keys.insert(
format!("{}:{}", credentials.host, credentials.port),
expected.into(),
);
fs::write(path, serde_json::to_vec_pretty(&hosts)?)
.context("无法保存设备指纹,请检查本机配置目录权限")?;
remember_probe(&hosts_file()?, confirmed)?;
Ok(Connection {
session,
host: credentials.host,
@@ -179,6 +203,21 @@ pub fn connect(
installation,
})
}
fn remember_probe(path: &Path, confirmed: &Probe) -> Result<()> {
let parent = path.parent().context("无效的指纹配置路径")?;
fs::create_dir_all(parent).context("无法创建指纹配置目录,请检查本机目录权限")?;
let mut hosts = load_hosts_from(path)?;
confirmed.confirm(&confirmed.fingerprint, &hosts)?;
hosts
.keys
.insert(confirmed.endpoint.clone(), confirmed.fingerprint.clone());
let mut file = tempfile::NamedTempFile::new_in(parent)?;
file.write_all(&serde_json::to_vec_pretty(&hosts)?)?;
file.as_file().sync_all()?;
file.persist(path)
.context("无法保存设备指纹,请检查本机配置目录权限")?;
Ok(())
}
fn read_installation(
session: &Session,
password: &str,
@@ -463,6 +502,83 @@ mod tests {
normalized_saved_fingerprint(&format!("SHA256:{}", hex::encode([1_u8; 32]))),
standard
);
let hosts = Hosts {
keys: BTreeMap::from([("frame:22".into(), format!("SHA256:{}", hex::encode(digest)))]),
};
let probe = observed_probe("frame:22".into(), standard.clone(), &hosts);
assert!(probe.known && !probe.changed());
probe.confirm(&standard, &hosts).unwrap();
}
#[test]
fn changed_fingerprint_can_be_confirmed_without_losing_other_devices() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known-hosts.json");
let old = "SHA256:old";
let new = "SHA256:new";
let hosts = Hosts {
keys: BTreeMap::from([
("frame:22".into(), old.into()),
("other:2222".into(), "SHA256:other".into()),
]),
};
fs::write(&path, serde_json::to_vec(&hosts).unwrap()).unwrap();
let fresh = observed_probe("fresh:22".into(), new.into(), &hosts);
assert!(!fresh.known && !fresh.changed() && fresh.previous_fingerprint.is_none());
let changed = observed_probe("frame:22".into(), new.into(), &hosts);
assert!(!changed.known && changed.changed());
assert_eq!(changed.previous_fingerprint.as_deref(), Some(old));
// Merely inspecting or cancelling leaves the original record intact.
assert_eq!(load_hosts_from(&path).unwrap().keys["frame:22"], old);
assert!(changed.confirm("SHA256:changed-again", &hosts).is_err());
assert_eq!(load_hosts_from(&path).unwrap().keys["frame:22"], old);
changed.confirm(new, &hosts).unwrap();
remember_probe(&path, &changed).unwrap();
let saved = load_hosts_from(&path).unwrap();
assert_eq!(saved.keys["frame:22"], new);
assert_eq!(saved.keys["other:2222"], "SHA256:other");
let next = observed_probe("frame:22".into(), new.into(), &saved);
assert!(next.known && !next.changed());
next.confirm(new, &saved).unwrap();
}
#[test]
fn stale_fingerprint_confirmation_does_not_replace_a_newer_record() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known-hosts.json");
let mut hosts = Hosts {
keys: BTreeMap::from([("frame:22".into(), "SHA256:old".into())]),
};
let pending = observed_probe("frame:22".into(), "SHA256:new".into(), &hosts);
hosts
.keys
.insert("frame:22".into(), "SHA256:confirmed-elsewhere".into());
fs::write(&path, serde_json::to_vec(&hosts).unwrap()).unwrap();
assert!(pending.confirm(&pending.fingerprint, &hosts).is_err());
assert!(remember_probe(&path, &pending).is_err());
assert_eq!(
load_hosts_from(&path).unwrap().keys["frame:22"],
"SHA256:confirmed-elsewhere"
);
}
#[test]
fn confirmed_fingerprint_is_bound_to_the_checked_address_and_port() {
let pending = observed_probe("frame:22".into(), "SHA256:new".into(), &Hosts::default());
for (host, port) in [("other", 22), ("frame", 2222)] {
let mut stages = vec![];
let error = connect(
Credentials {
host: host.into(),
port,
user: "steamos".into(),
password: Zeroizing::new("test".into()),
},
&pending,
&mut |stage| stages.push(stage),
)
.err()
.unwrap();
assert!(error.to_string().contains("设备地址已变化"));
assert!(stages.is_empty());
}
}
#[test]
#[ignore = "Requires an explicitly supplied SSH device and trusted OpenSSH fingerprints"]
@@ -492,7 +608,7 @@ mod tests {
user: "steamos".into(),
password: Zeroizing::new("framely-invalid-password-test-9c45bc39".into()),
},
&probe.fingerprint,
&probe,
&mut |stage| stages.push(stage),
);
let error = match result {
+85 -15
View File
@@ -336,6 +336,9 @@ impl Installer {
this.child(field("扫描范围", &self.cidr, self.busy || self.scanning))
});
let mut connection = panel()
.when(self.probe.as_ref().is_some_and(Probe::changed), |this| {
this.gap_3().p_5()
})
.flex_1()
.min_w_0()
.child(
@@ -391,32 +394,99 @@ impl Installer {
.gap_2()
.items_center()
.child(icon(IconName::ShieldCheck))
.child(div().text_sm().child(if probe.known {
.child(div().text_sm().child(if probe.changed() {
"设备指纹已变化"
} else if probe.known {
"设备指纹与上次一致"
} else {
"首次连接,请核对设备指纹"
})),
)
.when(probe.changed(), |this| {
this.child(muted("设备重装后,请核对并信任新指纹,继续连接。"))
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.flex_none()
.text_xs()
.text_color(rgb(MUTED))
.child("上次指纹"),
)
.child(
div()
.text_size(px(12.))
.text_color(rgb(MUTED))
.child(
probe
.previous_fingerprint
.clone()
.unwrap_or_default(),
),
),
)
})
.child(
div()
.text_size(px(12.))
.text_color(rgb(BLUE))
.child(probe.fingerprint.clone()),
.flex()
.items_center()
.gap_2()
.when(probe.changed(), |this| {
this.child(
div()
.flex_none()
.text_xs()
.text_color(rgb(MUTED))
.child("当前指纹"),
)
})
.child(
div()
.text_size(px(12.))
.text_color(rgb(BLUE))
.child(probe.fingerprint.clone()),
),
),
)
})
.child(
Button::new("connect")
.h(px(42.))
.primary()
.label(if self.connection_stage.is_some() {
"正在连接…"
} else {
"确认指纹并连接"
})
.loading(self.connection_stage.is_some())
.disabled(locked)
.on_click(cx.listener(|view, _, _, cx| view.connect(cx))),
div()
.flex()
.gap_3()
.child(
Button::new("connect")
.flex_1()
.h(px(42.))
.primary()
.label(if self.connection_stage.is_some() {
"正在连接…"
} else if probe.changed() {
"信任新指纹并连接"
} else {
"确认指纹并连接"
})
.loading(self.connection_stage.is_some())
.disabled(locked)
.on_click(cx.listener(|view, _, _, cx| view.connect(cx))),
)
.when(probe.changed(), |this| {
this.child(
Button::new("cancel-fingerprint-change")
.h(px(42.))
.ghost()
.label("取消")
.disabled(locked)
.on_click(cx.listener(|view, _, _, cx| {
view.probe = None;
view.error = None;
view.status = "未更改已保存的设备指纹。".into();
cx.notify();
})),
)
}),
);
} else {
connection = connection.child(
+12
View File
@@ -43,6 +43,7 @@ const uint FLAG_PER_CHANNEL = 1u;
const uint FLAG_SUPERSAMPLE = 2u;
const uint FLAG_ROTATED_EYES = 4u;
const uint FLAG_SHARP = 8u;
const uint FLAG_SNAPSHOT = 16u;
// Fringes narrower than this, in output pixels, aren't worth extra reads.
const float FRINGE_MIN = 0.3;
@@ -212,6 +213,17 @@ void main() {
}
}
if ((p.flags & FLAG_SNAPSHOT) != 0u) {
for (uint k = 0u; k < 8u; k++) {
uint x = x0 + (k & 3u);
uint y = y0 + (k >> 2);
if (x >= p.out_size.x || y >= p.out_size.y) continue;
uvec3 color = uvec3(clamp(px[k] * 255.0 + 0.5, 0.0, 255.0));
nv12[y * p.out_size.x + x] = color.r | (color.g << 8) | (color.b << 16) | (255u << 24);
}
return;
}
uint row0 = 0u;
uint row1 = 0u;
for (uint dx = 0u; dx < 4u; dx++) {
+66 -7
View File
@@ -93,6 +93,7 @@ pub struct Gpu {
/// Where images handed to us by someone else get copied before converting.
copy: Option<CopyTarget>,
physical_device: vk::PhysicalDevice,
snapshot: bool,
}
/// How the shader reads the source.
@@ -157,6 +158,10 @@ struct CopyTarget {
const COPY_SOURCE: u32 = u32::MAX;
impl Gpu {
pub fn new_snapshot(width: u32, height: u32, lut: &Lut) -> Result<Self> {
let size = width.checked_mul(height).and_then(|n| n.checked_mul(4)).context("snapshot too large")?;
Self::new_mode(Nv12Layout { width, height, y_stride: width * 4, uv_offset: 0, size }, 1, lut, Filter::Sharp, Priority::Low, None, true)
}
/// `extra` adds extensions someone else needs on our instance and device
/// (SteamVR, to hand us its headset view).
pub fn new(
@@ -166,6 +171,18 @@ impl Gpu {
filter: Filter,
priority: Priority,
extra: Option<&Extensions>,
) -> Result<Self> {
Self::new_mode(layout, target_count, lut, filter, priority, extra, false)
}
fn new_mode(
layout: Nv12Layout,
target_count: usize,
lut: &Lut,
filter: Filter,
priority: Priority,
extra: Option<&Extensions>,
snapshot: bool,
) -> Result<Self> {
let entry = unsafe { ash::Entry::load() }.context("loading Vulkan")?;
let app = vk::ApplicationInfo::default()
@@ -180,7 +197,7 @@ impl Gpu {
}
.context("creating Vulkan instance")?;
Self::with_instance(entry, instance, layout, target_count, lut, filter, priority, extra)
Self::with_instance(entry, instance, layout, target_count, lut, filter, priority, extra, snapshot)
}
#[allow(clippy::too_many_arguments)]
@@ -193,6 +210,7 @@ impl Gpu {
filter: Filter,
priority: Priority,
extra: Option<&Extensions>,
snapshot: bool,
) -> Result<Self> {
let pdev = unsafe { instance.enumerate_physical_devices() }?
.into_iter()
@@ -282,7 +300,8 @@ impl Gpu {
flags: (lut.per_channel as u32)
| (((filter == Filter::Supersample) as u32) << 1)
| ((lut.rotated_eyes as u32) << 2)
| (((filter == Filter::Sharp) as u32) << 3),
| (((filter == Filter::Sharp) as u32) << 3)
| ((snapshot as u32) << 4),
},
layout,
targets: Vec::new(),
@@ -290,6 +309,7 @@ impl Gpu {
tick: 0,
copy: None,
physical_device: pdev,
snapshot,
};
gpu.create_pipeline(target_count)?;
gpu.upload_lut(lut)?;
@@ -441,7 +461,7 @@ impl Gpu {
let buffer = d.create_buffer(
&vk::BufferCreateInfo::default()
.size(self.layout.size as u64)
.usage(vk::BufferUsageFlags::STORAGE_BUFFER)
.usage(vk::BufferUsageFlags::STORAGE_BUFFER | vk::BufferUsageFlags::TRANSFER_SRC)
.push_next(&mut ext),
None,
)?;
@@ -681,8 +701,8 @@ impl Gpu {
.image(image)
.subresource_range(color_range());
let acquire_buffer = vk::BufferMemoryBarrier::default()
.src_queue_family_index(foreign)
.dst_queue_family_index(ours)
.src_queue_family_index(if self.snapshot { vk::QUEUE_FAMILY_IGNORED } else { foreign })
.dst_queue_family_index(if self.snapshot { vk::QUEUE_FAMILY_IGNORED } else { ours })
.dst_access_mask(vk::AccessFlags::SHADER_WRITE)
.buffer(target.buffer)
.size(vk::WHOLE_SIZE);
@@ -715,8 +735,8 @@ impl Gpu {
.image(image)
.subresource_range(color_range());
let release_buffer = vk::BufferMemoryBarrier::default()
.src_queue_family_index(ours)
.dst_queue_family_index(foreign)
.src_queue_family_index(if self.snapshot { vk::QUEUE_FAMILY_IGNORED } else { ours })
.dst_queue_family_index(if self.snapshot { vk::QUEUE_FAMILY_IGNORED } else { foreign })
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
.buffer(target.buffer)
.size(vk::WHOLE_SIZE);
@@ -749,6 +769,45 @@ impl Gpu {
self.submit(&[cmd])
}
/// Read back the one-shot RGBA target after convert has completed.
pub fn snapshot_pixels(&self) -> Result<Vec<u8>> {
anyhow::ensure!(self.snapshot, "not a snapshot target");
let d = &self.device;
let size = self.layout.size as u64;
unsafe {
let staging = d.create_buffer(&vk::BufferCreateInfo::default().size(size).usage(vk::BufferUsageFlags::TRANSFER_DST), None)?;
let result = (|| -> Result<Vec<u8>> {
let req = d.get_buffer_memory_requirements(staging);
let ty = self.find_memory_type(req.memory_type_bits, vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT).context("no host-visible snapshot memory")?;
let memory = d.allocate_memory(&vk::MemoryAllocateInfo::default().allocation_size(req.size).memory_type_index(ty), None)?;
let result = (|| -> Result<Vec<u8>> {
d.bind_buffer_memory(staging, memory, 0)?;
let cmds = d.allocate_command_buffers(&vk::CommandBufferAllocateInfo::default().command_pool(self.cmd_pool).level(vk::CommandBufferLevel::PRIMARY).command_buffer_count(1))?;
let cmd = cmds[0];
let copied = (|| -> Result<Vec<u8>> {
d.begin_command_buffer(cmd, &vk::CommandBufferBeginInfo::default())?;
let barrier = vk::BufferMemoryBarrier::default().src_access_mask(vk::AccessFlags::SHADER_WRITE).dst_access_mask(vk::AccessFlags::TRANSFER_READ).buffer(self.targets[0].buffer).size(size);
d.cmd_pipeline_barrier(cmd, vk::PipelineStageFlags::COMPUTE_SHADER, vk::PipelineStageFlags::TRANSFER, vk::DependencyFlags::empty(), &[], &[barrier], &[]);
d.cmd_copy_buffer(cmd, self.targets[0].buffer, staging, &[vk::BufferCopy::default().size(size)]);
d.end_command_buffer(cmd)?;
d.queue_submit(self.queue, &[vk::SubmitInfo::default().command_buffers(&[cmd])], vk::Fence::null())?;
d.queue_wait_idle(self.queue)?;
let ptr = d.map_memory(memory, 0, size, vk::MemoryMapFlags::empty())?;
let bytes = std::slice::from_raw_parts(ptr.cast::<u8>(), size as usize).to_vec();
d.unmap_memory(memory);
Ok(bytes)
})();
d.free_command_buffers(self.cmd_pool, &cmds);
copied
})();
d.free_memory(memory, None);
result
})();
d.destroy_buffer(staging, None);
result
}
}
/// Runs command buffers, waits for them, and returns the conversion
/// shader's GPU time from the timestamps it wrote.
fn submit(&self, cmds: &[vk::CommandBuffer]) -> Result<std::time::Duration> {
+52 -1
View File
@@ -9,7 +9,7 @@ mod panel_geometry;
use anyhow::{Context, Result};
use clap::Parser;
use framely_capture::openvr::OpenVr;
use std::{io::{self, Write}, os::fd::AsRawFd, path::Path, time::{Duration, Instant}};
use std::{io::{self, Read, Write}, os::fd::AsRawFd, path::Path, time::{Duration, Instant}};
#[derive(Parser)]
struct Args {
@@ -20,6 +20,10 @@ struct Args {
#[arg(long, default_value_t = 0)] eye: usize,
#[arg(long)] raw: bool,
#[arg(long)] describe_view: bool,
/// Emit one native-resolution RGBA frame (width, height, then pixels).
#[arg(long)] snapshot: bool,
/// Prepare the snapshot pipeline, then wait for a byte on stdin before sampling.
#[arg(long)] snapshot_wait: bool,
#[arg(long, value_enum, default_value_t = encoder::Codec::H264)] codec: encoder::Codec,
#[arg(long)] fov: Option<f64>,
#[arg(long, default_value_t = 0.)] center_x: f64,
@@ -34,6 +38,9 @@ fn run(a: Args) -> Result<()> {
anyhow::ensure!(a.fov.is_none_or(|v| v.is_finite() && (10.0..=150.0).contains(&v))
&& [a.center_x, a.center_y].into_iter().all(|v| v.is_finite() && (-60.0..=60.0).contains(&v)), "Invalid field of view");
anyhow::ensure!(!a.describe_view || !a.raw, "Raw view has no single-eye region");
anyhow::ensure!(!a.snapshot || !a.describe_view, "Choose one capture mode");
anyhow::ensure!(!a.snapshot_wait || a.snapshot, "Snapshot wait requires snapshot mode");
if a.snapshot { return snapshot(&a); }
let kms = if a.describe_view {None} else {Some(kms::Kms::open(Path::new("/dev/dri/card0"))?)};
let height = if a.raw { let kms=kms.as_ref().unwrap(); ((a.width as f64 * kms.mode_size.1 as f64 / kms.mode_size.0 as f64).round() as u32 + 1) & !1 } else { a.height };
anyhow::ensure!(height>=160&&height<=4096&&u64::from(a.width)*u64::from(height)<=8_847_360,"Raw output exceeds supported dimensions");
@@ -86,6 +93,50 @@ fn run(a: Args) -> Result<()> {
}
}
}
fn snapshot(a: &Args) -> Result<()> {
let kms = kms::Kms::open(Path::new("/dev/dri/card0"))?;
let (width, height, map) = if a.raw {
let (w, h) = kms.mode_size;
(w, h, lut::raw(w, h, w, h))
} else {
let aspect = a.width as f64 / a.height as f64;
let panel_w = kms.mode_size.0 / 2;
let panel_h = kms.mode_size.1;
let max_w = (panel_h as f64 * aspect).round().min(panel_w as f64) as u32;
let max_h = (max_w as f64 / aspect).round().min(panel_h as f64) as u32;
let base = lut::undistorted(&OpenVr::connect()?, a.eye, None, max_w, max_h, a.center_x, a.center_y)?;
let full_tan = base.view.context("No eye projection")?.0;
let selected_tan = a.fov.map(|f| (f.to_radians() / 2.0).tan()).unwrap_or(full_tan);
let fraction = (selected_tan / full_tan).clamp(0.01, 1.0);
let width = ((max_w as f64 * fraction).round() as u32).max(2);
let height = ((max_h as f64 * fraction).round() as u32).max(2);
(width, height, lut::undistorted(&OpenVr::connect()?, a.eye, a.fov, width, height, a.center_x, a.center_y)?)
};
anyhow::ensure!(width > 0 && height > 0 && u64::from(width) * u64::from(height) <= 9_331_200, "Invalid native snapshot size");
let mut gpu = gpu::Gpu::new_snapshot(width, height, &map)?;
if a.snapshot_wait {
let mut trigger = [0u8; 1];
io::stdin().read_exact(&mut trigger).context("Snapshot was cancelled")?;
}
// The editor overlay has been closed by the caller. Skip one complete
// scanout before choosing the frame so its pixels cannot enter the PNG.
kms.wait_vblank()?;
kms.wait_vblank()?;
let fb = kms.current_fb()?.context("Headset display is unavailable")?;
let buf = kms.export(fb)?;
kms::wait_idle(&buf.fd, Duration::from_millis(4));
let id = kms::buffer_id(&buf.fd)?;
gpu.add_source(buf, id)?;
gpu.convert(fb, id, 0)?;
let pixels = gpu.snapshot_pixels()?;
let mut output = io::BufWriter::new(io::stdout().lock());
output.write_all(&width.to_le_bytes())?;
output.write_all(&height.to_le_bytes())?;
output.write_all(&pixels)?;
output.flush()?;
Ok(())
}
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
if let Err(e) = run(Args::parse()) { eprintln!("{e:#}"); std::process::exit(1); }
+419 -22
View File
@@ -34,12 +34,15 @@
#include "recording_indicator.h"
#include "recording_region.h"
#include "recording_region_depth.h"
#include "system_radial.h"
#include "framing_lifecycle.h"
#include "plugin_navigation.h"
#include <GL/gl.h>
#include <GL/glx.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <algorithm>
#include <atomic>
#include <csignal>
#include <chrono>
@@ -118,6 +121,15 @@ static cef_frame_t* keyboard_frame=nullptr;
static std::string keyboard_view;
static KeyboardMenuGuard keyboard_menu;
static uint64_t keyboard_token=0;static vr::VROverlayHandle_t keyboard_target=0;
constexpr const char* frame_keyboard_window_key="framely.window.tooru.frame-keyboard.keyboard";
struct ExternalKeyboard {
bool active=false,hide_pending=false;
vr::VROverlayHandle_t target=0;
uint64_t token=0;
std::string overlay_key,view_key;
std::chrono::steady_clock::time_point hide_pending_until{};
};
static ExternalKeyboard external_keyboard;
static std::mutex queue_mutex;
static std::mutex visibility_mutex;
static json visible_views=nullptr;
@@ -140,6 +152,7 @@ struct View {
vr::VROverlayHandle_t overlay=0,thumbnail=0;GLuint textures[2]{};int texture_slot=0;bool texture_ready[2]{};PaintDamage texture_damage[2];std::vector<uint8_t> pixels;
};
static std::map<std::string,std::unique_ptr<View>> views;
static void close_view(View& v);
static bool permission_trigger_held[2]{};
static View* find_browser(cef_browser_t* b){int id=b->get_identifier(b);for(auto& [key,v]:views)if(v->browser_id==id)return v.get();return nullptr;}
static Handler<cef_client_t> client;
@@ -178,10 +191,87 @@ static std::string pending_recording_stop;
static json recording_state=json::object();
static json recording_region_state=json::object();
static json framing_editor_state=nullptr;
static json screenshot_editor_state=nullptr;
static json screenshot_shortcut_preview=nullptr;
static bool screenshot_radial_held=false;
static bool system_radial_available=false;
static std::atomic<bool> system_radial_enabled{true};
static SystemRadial system_radial;
static bool system_radial_preview_prepared=false;
static bool system_radial_progress_visible=false;
static vr::HmdVector3_t system_radial_origin{};
static vr::HmdVector3_t system_radial_right{};
static vr::HmdVector3_t system_radial_up{};
static bool system_radial_origin_valid=false;
static vr::HmdMatrix34_t system_radial_pose[2]{};
static SystemRadial::Clock::time_point system_radial_pose_at[2]{};
static vr::TrackedDeviceIndex_t system_radial_pose_device[2]{vr::k_unTrackedDeviceIndexInvalid,vr::k_unTrackedDeviceIndexInvalid};
static std::chrono::steady_clock::time_point system_radial_sent{};
static std::string pending_screenshot_hotkey,pending_screenshot_close,pending_system_action;
static uint64_t system_action_sequence=0;
static RecordingRegionOverlay screenshot_region_overlay{"framely.screenshot.region"};
static std::string pending_region_close;
static RecordingRegionOverlay recording_region_overlay;
static RecordingIndicator recording_indicator;
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;std::string stop_id,region_closed;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();stop_id=pending_recording_stop;region_closed=pending_region_close;payload=json{{"views",visible_views},{"closedCasts",closed_casts},{"stopRecording",stop_id},{"framingClosed",region_closed}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard<std::mutex> lock(visibility_mutex);if(pending_region_close==region_closed)pending_region_close.clear();if(pending_recording_stop==stop_id)pending_recording_stop.clear();closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;std::string stop_id,region_closed,screenshot_closed,hotkey,system_action;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();stop_id=pending_recording_stop;region_closed=pending_region_close;screenshot_closed=pending_screenshot_close;hotkey=pending_screenshot_hotkey;system_action=pending_system_action;payload=json{{"views",visible_views},{"closedCasts",closed_casts},{"stopRecording",stop_id},{"framingClosed",region_closed},{"screenshotClosed",screenshot_closed},{"screenshotHotkey",hotkey},{"radialAction",system_action}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard<std::mutex> lock(visibility_mutex);if(pending_region_close==region_closed)pending_region_close.clear();if(pending_screenshot_close==screenshot_closed)pending_screenshot_close.clear();if(pending_screenshot_hotkey==hotkey)pending_screenshot_hotkey.clear();if(pending_system_action==system_action)pending_system_action.clear();if(pending_recording_stop==stop_id)pending_recording_stop.clear();closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
static json service_call(const std::string& method,const json& params){
const char* configured=std::getenv("FRAMELY_CONTROL_SOCKET");
const std::string path=configured&&*configured?configured:"/run/framely/control.sock";
sockaddr_un address{};address.sun_family=AF_UNIX;
if(path.size()>=sizeof(address.sun_path))throw std::runtime_error("Control socket path is too long");
std::strncpy(address.sun_path,path.c_str(),sizeof(address.sun_path)-1);
const int fd=socket(AF_UNIX,SOCK_STREAM|SOCK_CLOEXEC,0);
if(fd<0)throw std::runtime_error("Control socket failed");
timeval timeout{2,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));
if(connect(fd,reinterpret_cast<sockaddr*>(&address),sizeof(address))<0){close(fd);throw std::runtime_error("Control service unavailable");}
const std::string request=json{{"method",method},{"params",params}}.dump()+"\n";
size_t sent=0;
while(sent<request.size()){
const auto count=send(fd,request.data()+sent,request.size()-sent,MSG_NOSIGNAL);
if(count<=0){close(fd);throw std::runtime_error("Control request failed");}
sent+=static_cast<size_t>(count);
}
std::string response;char buffer[4096];
while(response.find('\n')==std::string::npos){
const auto count=recv(fd,buffer,sizeof(buffer),0);
if(count<=0){close(fd);throw std::runtime_error("Control response failed");}
response.append(buffer,static_cast<size_t>(count));
if(response.size()>1024*1024){close(fd);throw std::runtime_error("Control response is too large");}
}
close(fd);
auto reply=json::parse(response.substr(0,response.find('\n')));
if(reply.contains("error"))throw std::runtime_error(reply["error"].get<std::string>());
return reply.value("result",json(nullptr));
}
static bool frame_keyboard_command(const std::string& method,const json& params){
try{
auto result=service_call("plugin.call",json{{"plugin","tooru.frame-keyboard"},{"method",method},{"params",params}});
if(result.is_null())return false;
if(result.is_object()&&result.contains("ok")&&result["ok"].is_boolean()&&!result["ok"].get<bool>()){
std::cerr<<"Frame keyboard "<<method<<" returned ok=false";
if(result.contains("error")&&result["error"].is_string())std::cerr<<": "<<result["error"].get<std::string>();
std::cerr<<"\n";
return false;
}
return true;
}catch(const std::exception& error){
std::cerr<<"Frame keyboard command failed: "<<error.what()<<"\n";
return false;
}
}
static bool plugin_handles_external_keyboard(){
// Version 0.3 of the plugin listens to OpenVR global keyboard events itself.
// Cache the capability briefly so a missing plugin still falls back quickly.
static std::atomic<int> cached{-1};static std::atomic<long long> checked_at{0};
const auto now=std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
if(cached.load()>=0&&now-checked_at.load()<5000)return cached.load()==1;
bool enabled=false;
try{
auto result=service_call("plugin.call",json{{"plugin","tooru.frame-keyboard"},{"method","keyboardInterception"},{"params",json::object()}});
enabled=result.is_object()&&result.value("enabled",false);
}catch(...){}
cached.store(enabled?1:0);checked_at.store(now);return enabled;
}
// Frame renders the native laser cursor in the default overlay sort group.
// Raising a panel above that group can obscure the runtime cursor.
// OpenVR owns and renders the laser cursor. Coordinates must remain in the
@@ -320,26 +410,92 @@ static void show(View& v,bool shown){
if(shown!=actual){auto e=shown?overlays->ShowOverlay(v.overlay):overlays->HideOverlay(v.overlay);if(e){std::cerr<<"Overlay visibility failed "<<v.key<<" "<<e<<"\n";return;}}
if(shown!=v.shown){
if(!shown){overlays->ClearOverlayCursorPositionOverride(v.overlay);if(v.key=="notifications"&&v.notification_click.active){cef_mouse_event_t outside{};outside.x=outside.y=-1;v.host->send_mouse_click_event(v.host,&outside,MBT_LEFT,1,1);v.notification_click.clear();}}
if(v.key!="notifications")v.host->was_hidden(v.host,!shown);
if(v.key!="notifications"&&v.key!="framely.system.radial"&&v.key!="framely.system.radial.progress")v.host->was_hidden(v.host,!shown);
v.shown=shown;if(shown)v.host->invalidate(v.host,PET_VIEW);
if(v.key=="menu"||v.key=="notifications")update_menu_modal();
}
}
static void keyboard_state(bool open){if(keyboard_frame)execute(keyboard_frame,std::string("window.dispatchEvent(new CustomEvent('framely.keyboard',{detail:")+(open?"true":"false")+"}))");}
static void close_keyboard(bool hide=true){keyboard_state(false);if(keyboard_open&&hide)overlays->HideKeyboard();keyboard_open=false;keyboard_close_pending=false;keyboard_toolbar=false;keyboard_chars=false;keyboard_done=false;keyboard_target=0;keyboard_menu.end(std::chrono::steady_clock::now());if(keyboard_view=="menu")update_menu_modal();if(keyboard_frame){keyboard_frame->base.release(&keyboard_frame->base);keyboard_frame=nullptr;}keyboard_view.clear();}
static void external_keyboard_clear(bool notify_backend){
const bool notify=notify_backend&&external_keyboard.active&&running;
external_keyboard={};
if(notify)std::thread([]{frame_keyboard_command("dashboardClose",json::object());}).detach();
}
static void close_external_keyboard(bool close_frame);
static void external_keyboard_tick(std::chrono::steady_clock::time_point now){
// Hiding SteamVR's keyboard emits a close event which must not tear down the
// replacement. Bound that guard so a later real close is still observed.
if(external_keyboard.active&&external_keyboard.hide_pending&&now>=external_keyboard.hide_pending_until)external_keyboard.hide_pending=false;
// Closing the SteamVR dashboard also invalidates the X11 text target.
if(external_keyboard.active&&!external_keyboard.hide_pending&&!overlays->IsDashboardVisible())close_external_keyboard(true);
}
static void close_external_keyboard(bool close_frame){
const std::string view_key=external_keyboard.view_key;
external_keyboard_clear(true);
if(close_frame&&!view_key.empty()){
auto found=views.find(view_key);
if(found!=views.end()&&!found->second->closing)close_view(*found->second);
}
}
static bool open_external_keyboard(const std::string& overlay_key,vr::VROverlayHandle_t target,uint64_t token){
const json params={{"overlayKey",overlay_key},{"overlayHandle",target},{"token",token}};
if(!frame_keyboard_command("dashboardOpen",params))return false;
external_keyboard.active=true;
external_keyboard.hide_pending=true;
external_keyboard.target=target;
external_keyboard.token=token;
external_keyboard.overlay_key=overlay_key;
external_keyboard.view_key=frame_keyboard_window_key;
external_keyboard.hide_pending_until=std::chrono::steady_clock::now()+std::chrono::milliseconds(1500);
overlays->HideKeyboard();
try{
service_call("window.open",json{{"plugin","tooru.frame-keyboard"},{"window","keyboard"}});
}catch(const std::exception& error){
std::cerr<<"Frame keyboard window failed: "<<error.what()<<"\n";
external_keyboard_clear(true);
return false;
}
return true;
}
static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_overlay=0){
// The runtime toolbar opens the keyboard without a host.keyboard request.
if(e.eventType==vr::VREvent_KeyboardOpened_Global){
auto target=e.data.keyboard.overlayHandle;
if(keyboard_open&&target==keyboard_target)return true;
View* owner=nullptr;for(auto&[key,v]:views)if(v->overlay==target&&!v->closing){owner=v.get();break;}
if(!owner)return false;
if(!owner){
char key[1024]{};vr::EVROverlayError error{};overlays->GetOverlayKey(target,key,sizeof(key),&error);
const std::string overlay_key=error?"":key;
if(overlay_key.empty()||overlay_key.rfind("framely.",0)==0)return false;
if(plugin_handles_external_keyboard())return true;
if(external_keyboard.active&&external_keyboard.target==target){
// A repeated Opened event during the native hide is part of the same
// transition. After that grace period treat it as a second keyboard click.
if(!external_keyboard.hide_pending)close_external_keyboard(true);
overlays->HideKeyboard();
return true;
}
if(!open_external_keyboard(overlay_key,target,e.data.keyboard.uUserValue))return false;
return true;
}
if(owner->key==frame_keyboard_window_key){return true;}
close_keyboard(false);owner->host->set_focus(owner->host,1);
keyboard_frame=owner->browser->get_focused_frame(owner->browser);keyboard_view=owner->key;
keyboard_target=target;keyboard_token=e.data.keyboard.uUserValue;keyboard_open=true;keyboard_toolbar=true;
keyboard_menu.begin(owner->key=="menu");keyboard_state(true);return true;
}
if(e.eventType!=vr::VREvent_KeyboardCharInput&&e.eventType!=vr::VREvent_KeyboardDone&&e.eventType!=vr::VREvent_KeyboardClosed&&e.eventType!=vr::VREvent_KeyboardClosed_Global)return false;
const bool external_target=external_keyboard.active&&e.data.keyboard.overlayHandle==external_keyboard.target;
const bool external_close=e.eventType==vr::VREvent_KeyboardClosed||e.eventType==vr::VREvent_KeyboardClosed_Global;
const bool external_input=!e.data.keyboard.uUserValue||!external_keyboard.token||e.data.keyboard.uUserValue==external_keyboard.token;
if(external_target&&(external_close||external_input)){
if(external_close){
if(external_keyboard.hide_pending){external_keyboard.hide_pending=false;return true;}
close_external_keyboard(true);
}
return true;
}
if(!keyboard_open)return true;
if(e.data.keyboard.uUserValue&&e.data.keyboard.uUserValue!=keyboard_token)return true;
if(e.data.keyboard.overlayHandle&&e.data.keyboard.overlayHandle!=keyboard_target)return true;
@@ -349,8 +505,26 @@ static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_o
}else {keyboard_done=keyboard_done||e.eventType==vr::VREvent_KeyboardDone;keyboard_close_pending=true;}
return true;
}
static void close_view(View& v){if(v.closing)return;if(v.key.rfind("framely.cast.",0)==0){std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}if(v.key=="framely.framing"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_region_close=v.region_editor_id;}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu"||v.key=="framely.apk.permission")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
static void close_view(View& v){if(v.closing)return;if(external_keyboard.active&&v.key==external_keyboard.view_key)external_keyboard_clear(true);if(v.key==frame_keyboard_window_key){std::lock_guard<std::mutex> lock(queue_mutex);queue.erase(std::remove_if(queue.begin(),queue.end(),[](const json& command){return command.value("kind",std::string{})=="window.open"&&command.value("plugin",std::string{})=="tooru.frame-keyboard";}),queue.end());if(running)std::thread([]{frame_keyboard_command("dashboardClose",json::object());}).detach();}if(v.key.rfind("framely.cast.",0)==0){std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}if(v.key=="framely.framing"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_region_close=v.region_editor_id;}if(v.key=="framely.screenshot"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_screenshot_close=v.region_editor_id;}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu"||v.key=="framely.apk.permission")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
static void anchor_to_head(View& v,float width,float x=0,float y=0,float z=-1.35f){vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);if(!poses[0].bPoseIsValid)return;auto m=poses[0].mDeviceToAbsoluteTracking;for(int r=0;r<3;r++)m.m[r][3]+=m.m[r][0]*x+m.m[r][1]*y+m.m[r][2]*z;overlays->SetOverlayWidthInMeters(v.overlay,width);overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&m);}
static void anchor_frame_keyboard(View& v){
// A keyboard button must not move keyboard focus away from the edited app.
// SteamVR owns the keyboard's spatial container and its native grab handle.
vr::VROverlayHandle_t native=0;vr::HmdVector2_t scale{};float width=0;
vr::HmdMatrix34_t pose{};
if(!overlays->FindOverlay("valve.steam.gamepadui.keyboard",&native)&&overlays->IsOverlayVisible(native)&&
!overlays->GetOverlayMouseScale(native,&scale)&&scale.v[0]>0&&
!overlays->GetOverlayWidthInMeters(native,&width)&&width>0&&
!overlays->GetTransformForOverlayCoordinates(native,vr::TrackingUniverseStanding,{{scale.v[0]*.5f,scale.v[1]*.5f}},&pose)){
const float dy=(width*float(v.height)/v.width-width*scale.v[1]/scale.v[0])*.5f;
for(int r=0;r<3;++r)pose.m[r][3]+=pose.m[r][1]*dy+pose.m[r][2]*.003f;
overlays->SetOverlayWidthInMeters(v.overlay,width);
overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&pose);
float curvature=0,pitch=0;
overlays->GetOverlayCurvature(native,&curvature);overlays->GetOverlayPreCurvePitch(native,&pitch);
overlays->SetOverlayCurvature(v.overlay,curvature);overlays->SetOverlayPreCurvePitch(v.overlay,pitch);
}else anchor_to_head(v,1.35f,0,-.38f,-1.2f);
}
static void anchor_permission(View& v){
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);
@@ -380,15 +554,23 @@ static void anchor_notification(View& v){
v.placement.transform=toast.transform;v.placement.width=toast.width;v.placement.valid=true;
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){
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,bool keyboard_surface=false){
// Plugin windows always use Steam window controls; dock only controls the tab.
const bool dashboard=dock||!plugin.empty()||key.rfind("framely.cast.",0)==0;
keyboard_surface=keyboard_surface||key==frame_keyboard_window_key;
const bool dashboard=!keyboard_surface&&(dock||!plugin.empty()||key.rfind("framely.cast.",0)==0);
if(views.count(key)){auto& v=*views.at(key);if(v.dashboard&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="notifications"&&key!="launcher")show(v,true);return v;}
// Use 1.5x for launcher artwork and 2x for scrolling panels. CEF readback,
// texture upload and launcher mipmap generation scale with the pixel count.
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dashboard;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications"||key=="framely.framing")?2:1;
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dashboard;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications"||key=="framely.framing"||key=="framely.screenshot"||key=="framely.system.radial"||key=="framely.system.radial.progress")?2:1;
auto error=dashboard?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,key=="framely.apk.permission");overlays->SetOverlaySortOrder(v->overlay,key=="framely.apk.permission"?100:0);if(key=="framely.framing")recording_region_foreground(overlays,v->overlay);if(key=="framely.apk.permission")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true);
const bool radial=key=="framely.system.radial"||key=="framely.system.radial.progress";
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"||radial?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,radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,key==frame_keyboard_window_key||key=="framely.apk.permission"||radial);overlays->SetOverlaySortOrder(v->overlay,key==frame_keyboard_window_key?20:key=="framely.apk.permission"?100:radial?200:0);if(key=="framely.framing"||key=="framely.screenshot")recording_region_foreground(overlays,v->overlay);if(key=="framely.apk.permission")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true);
if(key=="framely.system.radial"){
vr::VROverlayIntersectionMaskPrimitive_t mask{};
mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Circle;
mask.m_Primitive.m_Circle={width*.5f,height*.5f,260.f};
if(auto e=overlays->SetOverlayIntersectionMask(v->overlay,&mask,1))std::cerr<<"Radial input mask failed "<<e<<"\n";
}
if(key=="framely.apk.permission"){
vr::VROverlayIntersectionMaskPrimitive_t mask{};
mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;
@@ -398,17 +580,18 @@ static View& create_view(const std::string& key,const std::string& title,const s
// Keep the runtime's full dashboard controls, including its keyboard and close buttons.
if(dashboard){
overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_NoDashboardTab,!dock||key=="launcher");
overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_NoDashboardTab,keyboard_surface||!dock||key=="launcher");
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);
result=overlays->SetOverlayFlag(v->overlay,flag,!keyboard_surface||flag==vr::VROverlayFlags_EnableControlBarClose);
if(result)std::cerr<<"Dashboard control unavailable "<<key<<" "<<result<<"\n";
}
}
if(dashboard){auto pixels=icon(false,true);overlays->SetOverlayRaw(v->thumbnail,pixels.data(),128,128,4);overlays->SetOverlayWidthInMeters(v->overlay,physical_width>0?physical_width:(key=="framely.manager"?3.2f:key=="launcher"?3.6f:1.2f));}else if(key!="menu"&&key!="notifications"&&key!="launcher"){anchor_to_head(*v,physical_width>0?physical_width:1.0f);}
if(keyboard_surface)anchor_frame_keyboard(*v);
else if(dashboard){auto pixels=icon(false,true);overlays->SetOverlayRaw(v->thumbnail,pixels.data(),128,128,4);overlays->SetOverlayWidthInMeters(v->overlay,physical_width>0?physical_width:(key=="framely.manager"?3.2f:key=="launcher"?3.6f:1.2f));}else if(key!="menu"&&key!="notifications"&&key!="launcher"&&!radial){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;info.runtime_style=CEF_RUNTIME_STYLE_ALLOY;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=browser_frame_rate();bs.background_color=0;std::cout<<"Browser frame rate "<<key<<" "<<bs.windowless_frame_rate<<"\n";cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&info,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);
if(!browser){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);throw std::runtime_error("Browser creation failed");}v->browser=browser;v->browser_id=browser->get_identifier(browser);v->host=browser->get_host(browser);glGenTextures(2,v->textures);auto* ptr=v.get();views.emplace(key,std::move(v));ptr->host->notify_screen_info_changed(ptr->host);ptr->host->was_resized(ptr->host);ptr->host->was_hidden(ptr->host,key!="notifications");if(ptr->dashboard&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications"&&key!="launcher")show(*ptr,true);return *ptr;
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"&&!radial);if(ptr->dashboard&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications"&&key!="launcher"&&!radial)show(*ptr,true);return *ptr;
}
static void plugin_thumbnail(View& view,const std::string& encoded){
if(!view.thumbnail||encoded.empty()||encoded.size()>1400000)return;
@@ -518,8 +701,8 @@ static void setup_entry_actions(){
}
static void page_scroll(float x,float y,bool active,vr::TrackedDeviceIndex_t device,std::chrono::steady_clock::time_point now){
View* target=nullptr;
if(!keyboard_open&&!launcher_open)for(auto&[key,v]:views){
if(key=="launcher"||!v->pointer_valid||!capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay))||!overlays->IsHoverTargetOverlay(v->overlay))continue;
if(!launcher_open)for(auto&[key,v]:views){
if(key=="launcher"||(keyboard_open&&keyboard_view!=key)||!v->pointer_valid||!capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay))||!overlays->IsHoverTargetOverlay(v->overlay))continue;
if(!target||(v->pointer_device==device&&target->pointer_device!=device)||(v->pointer_device==target->pointer_device&&v->pointer_at>target->pointer_at))target=v.get();
}
if(!target){page_scroll_stick.clear();page_scroll_target=0;return;}
@@ -579,6 +762,203 @@ static void launcher_gaze(vr::IVRInput* input,bool allowed,const bool (&connecte
}
gaze_active=true;
}
static const char* radial_direction(SystemRadialDirection direction){
switch(direction){
case SystemRadialDirection::Up:return "up";
case SystemRadialDirection::UpRight:return "upRight";
case SystemRadialDirection::Right:return "right";
case SystemRadialDirection::DownRight:return "downRight";
case SystemRadialDirection::Down:return "down";
case SystemRadialDirection::DownLeft:return "downLeft";
case SystemRadialDirection::Left:return "left";
case SystemRadialDirection::UpLeft:return "upLeft";
default:return "center";
}
}
static void radial_screenshot(bool active,bool capture){
if(active==screenshot_radial_held)return;
screenshot_radial_held=active;
if(!active){
screenshot_region_overlay.destroy();
if(auto preview=views.find("framely.screenshot.preview");preview!=views.end())close_view(*preview->second);
}
std::lock_guard<std::mutex> lock(visibility_mutex);
if(active)pending_screenshot_hotkey="radialPress";
else if(pending_screenshot_hotkey=="radialPress")pending_screenshot_hotkey=capture?"radialCapture":"";
else pending_screenshot_hotkey=capture?"release":"cancel";
}
static void system_radial_laser(vr::VROverlayHandle_t overlay,bool enabled){
auto method=enabled?vr::VROverlayInputMethod_Mouse:vr::VROverlayInputMethod_None;
if(auto e=overlays->SetOverlayInputMethod(overlay,method))std::cerr<<"Radial laser input failed "<<e<<"\n";
overlays->SetOverlayFlag(overlay,vr::VROverlayFlags_HideLaserIntersection,!enabled);
overlays->SetOverlayFlag(overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,enabled);
}
static std::optional<SystemRadialDirection> system_radial_stick(const char* model,vr::VRInputValueHandle_t hand){
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();float x=0,y=0;
if(!models->GetComponentStateForDevicePath(model,"thumbstick",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)||x*x+y*y<.35f*.35f)return std::nullopt;
return SystemRadial::select(x,y);
}
static void system_radial_tick(){
if(!system_radial_available||!views.count("framely.system.radial")||!views.count("framely.system.radial.progress"))return;
auto& progress_view=*views.at("framely.system.radial.progress");
if(!system_radial_enabled.load()){
auto& view=*views.at("framely.system.radial");
if(system_radial.active||system_radial_progress_visible||system_radial.open){
show(view,false);show(progress_view,false);system_radial_laser(view.overlay,false);overlays->ClearOverlayTexture(view.overlay);
system_radial={};system_radial_progress_visible=false;
if(screenshot_radial_held)radial_screenshot(false,false);
}
return;
}
auto now=SystemRadial::Clock::now();
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);
int first=system_radial.active?system_radial.hand:0,last=system_radial.active?first:1;
for(int hand=first;hand<=last;hand++){
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
if(device!=system_radial_pose_device[hand]){system_radial_pose_device[hand]=device;system_radial_pose_at[hand]={};}
if(device!=vr::k_unTrackedDeviceIndexInvalid&&poses[device].bPoseIsValid){system_radial_pose[hand]=poses[device].mDeviceToAbsoluteTracking;system_radial_pose_at[hand]=now;}
const bool pose_recent=system_radial_pose_at[hand].time_since_epoch().count()&&now-system_radial_pose_at[hand]<std::chrono::milliseconds(750);
bool valid=device!=vr::k_unTrackedDeviceIndexInvalid&&poses[0].bPoseIsValid&&hands[hand];
bool down=false;
char model[256]{};
if(valid){
vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));
vr::RenderModel_ControllerMode_State_t mode{};
vr::RenderModel_ComponentState_t state{};
bool component=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_system",hands[hand],&mode,&state);
down=component&&(state.uProperties&vr::VRComponentProperty_IsPressed);
// Another overlay (notably the Frame keyboard) can own the active input
// action while its panel is visible. The legacy controller state still
// reports the physical button, so use it if the action-path state is idle.
if(!down){
vr::VRControllerState_t controller{};
if(vr_system->GetControllerState(device,&controller,sizeof(controller))){
vr::RenderModel_ComponentState_t raw{};
down=bool(controller.ulButtonPressed&vr::ButtonMaskFromId(vr::k_EButton_System));
if(!down&&vr::VRRenderModels()->GetComponentState(model,"button_system",&controller,&mode,&raw))down=bool(raw.uProperties&vr::VRComponentProperty_IsPressed);
}
}
valid=component||down;
}
float right=0,up=0;
if(valid&&system_radial.active&&pose_recent){
const auto& point=system_radial_pose[hand];
if(!system_radial_origin_valid){for(int i=0;i<3;i++)system_radial_origin.v[i]=point.m[i][3];system_radial_origin_valid=true;}
for(int i=0;i<3;i++){
float delta=point.m[i][3]-system_radial_origin.v[i];
right+=delta*system_radial_right.v[i];
up+=delta*system_radial_up.v[i];
}
}
auto& view=*views.at("framely.system.radial");
std::optional<SystemRadialDirection> hover;
if(system_radial.open&&view.pointer_valid&&overlays->IsHoverTargetOverlay(view.overlay))hover=SystemRadial::hit(view.pointer.x,view.pointer.y);
if(system_radial.open&&!pose_recent&&!hover&&valid)hover=system_radial_stick(model,hands[hand]);
if(system_radial.open&&!pose_recent&&!hover)hover=system_radial.direction;
auto event=system_radial.update(hand,valid,down,right,up,now,hover);
if(event==SystemRadialEvent::Idle)continue;
if(event==SystemRadialEvent::Begin){
show(view,false);
show(progress_view,false);
system_radial_laser(view.overlay,false);
view.pointer_valid=false;
system_radial_preview_prepared=false;
system_radial_progress_visible=false;
const auto& point=system_radial_pose[hand];
const auto& head=poses[0].mDeviceToAbsoluteTracking;
system_radial_origin_valid=pose_recent;
for(int i=0;i<3;i++){
if(pose_recent)system_radial_origin.v[i]=point.m[i][3];
system_radial_right.v[i]=head.m[i][0];
system_radial_up.v[i]=head.m[i][1];
}
vr::HmdMatrix34_t transform{{{1,0,0,0},{0,1,0,0},{0,0,1,-.38f}}};
overlays->SetOverlayWidthInMeters(progress_view.overlay,.68f);
overlays->SetOverlayTransformTrackedDeviceRelative(progress_view.overlay,vr::k_unTrackedDeviceIndex_Hmd,&transform);
overlays->SetOverlayWidthInMeters(view.overlay,.68f);
overlays->ClearOverlayTexture(view.overlay);
system_radial_sent={};
}
if(event==SystemRadialEvent::Open){
show(progress_view,false);
system_radial_progress_visible=false;
if(pose_recent){
const auto& point=system_radial_pose[hand];
const auto& head=poses[0].mDeviceToAbsoluteTracking;
auto transform=head;
for(int i=0;i<3;i++){
if(!system_radial_origin_valid)system_radial_origin.v[i]=point.m[i][3];
system_radial_right.v[i]=head.m[i][0];
system_radial_up.v[i]=head.m[i][1];
transform.m[i][3]=point.m[i][3]-head.m[i][2]*.22f;
}
system_radial_origin_valid=true;
overlays->SetOverlayTransformAbsolute(view.overlay,vr::TrackingUniverseStanding,&transform);
}else{
vr::HmdMatrix34_t transform{{{1,0,0,0},{0,1,0,0},{0,0,1,-.55f}}};
overlays->SetOverlayTransformTrackedDeviceRelative(view.overlay,vr::k_unTrackedDeviceIndex_Hmd,&transform);
}
show(view,true);
system_radial_laser(view.overlay,true);
}
if(event==SystemRadialEvent::Change){
if(system_radial.direction==SystemRadialDirection::Right)radial_screenshot(true,false);
else if(screenshot_radial_held)radial_screenshot(false,false);
}
if(event==SystemRadialEvent::Cancel||event==SystemRadialEvent::Commit){
const auto direction=system_radial.direction;
if(event==SystemRadialEvent::Cancel||direction!=SystemRadialDirection::Right){
std::lock_guard<std::mutex> lock(visibility_mutex);
if(pending_screenshot_hotkey.empty()||pending_screenshot_hotkey=="radialPrepare")pending_screenshot_hotkey="radialCancel";
}
show(view,false);
show(progress_view,false);
system_radial_laser(view.overlay,false);
overlays->ClearOverlayTexture(view.overlay);
system_radial_progress_visible=false;
if(screenshot_radial_held)radial_screenshot(false,event==SystemRadialEvent::Commit&&direction==SystemRadialDirection::Right);
if(event==SystemRadialEvent::Commit){
if(direction==SystemRadialDirection::Center){
if(auto* chaperone=vr::VRChaperone())chaperone->ResetZeroPose(vr::TrackingUniverseStanding);
}else if(direction==SystemRadialDirection::Up)toggle_entry(true);
else if(direction==SystemRadialDirection::Down){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back({{"kind","manager.open"}});}
else if(direction==SystemRadialDirection::Left){std::lock_guard<std::mutex> lock(visibility_mutex);pending_system_action="record:"+std::to_string(++system_action_sequence);}
else if(direction==SystemRadialDirection::Right){
// A fast move can reach the sector between two host polls.
std::lock_guard<std::mutex> lock(visibility_mutex);
if(pending_screenshot_hotkey.empty())pending_screenshot_hotkey="radialCapture";
}
}
return;
}
}
if(system_radial.active&&!system_radial_preview_prepared&&system_radial.progress(now)>=.25f){
std::lock_guard<std::mutex> lock(visibility_mutex);
if(pending_screenshot_hotkey.empty()){pending_screenshot_hotkey="radialPrepare";system_radial_preview_prepared=true;}
}
if(system_radial.active&&!system_radial.open&&!system_radial_progress_visible&&system_radial.progress(now)>=.4f){
show(progress_view,true);
system_radial_progress_visible=true;
}
if(system_radial.active&&now-system_radial_sent>=std::chrono::milliseconds(33)){
system_radial_sent=now;
bool recording=false;{std::lock_guard<std::mutex> lock(queue_mutex);recording=recording_state.value("active",false);}
json detail={{"progress",std::clamp((system_radial.progress(now)-.4f)/.6f,0.f,1.f)},{"open",system_radial.open},{"direction",radial_direction(system_radial.direction)},{"recording",recording}};
for(auto* display:{views.at("framely.system.radial.progress").get(),views.at("framely.system.radial").get()}){
auto* frame=display->browser->get_main_frame(display->browser);
execute(frame,"window.dispatchEvent(new CustomEvent('framely.systemMenu',{detail:"+detail.dump()+"}))");
frame->base.release(&frame->base);
}
}
}
static void entry_actions(vr::VROverlayHandle_t button,bool available){
for(int hand=0;hand<2;hand++){framing_trigger_known[hand]=false;framing_trigger_down[hand]=false;}
const bool pointer_available=available||recording_indicator.active();
@@ -689,7 +1069,20 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
if(gaze_allowed&&gaze_active)launcher_outside_at={};
if(launcher_open&&launcher_outside_at.time_since_epoch().count()&&now>=launcher_outside_at){launcher_outside_at={};if(!keyboard_open&&!launcher_dock_interaction())launcher_back();}
}
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="cast.framing.open"){auto key="framely.framing";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto geometry=c.at("geometry");RecordingRegionPlane plane;auto view=geometry.at("view");if(!recording_region_plane(view,geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Recording frame",origin+"/cast/framing",false,1600,1600);v.region_editor_id=geometry.at("id");v.region_geometry=geometry;update_framing_placement(v);menu_open=false;hide_launcher(false);}else if(kind=="cast.framing.reference"){auto found=views.find("framely.framing");if(found!=views.end()&&!found->second->closing&&found->second->region_editor_id==c.value("id",std::string{})){double cx=c.at("centerTanX"),cy=c.at("centerTanY");if(std::isfinite(cx)&&std::isfinite(cy)&&std::abs(cx)<=1.733&&std::abs(cy)<=1.733){found->second->region_geometry["view"]["centerTanX"]=cx;found->second->region_geometry["view"]["centerTanY"]=cy;}}}else if(kind=="cast.framing.close"){if(views.count("framely.framing")&&views.at("framely.framing")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.framing"));}else if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu"Line truncated
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="cast.screenshot.open"){
auto key="framely.screenshot";
if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}
auto id=c.at("id").get<std::string>();bool spatial=c.value("spatial",false);
if(spatial){auto geometry=c.at("geometry");RecordingRegionPlane plane;if(!recording_region_plane(geometry.at("view"),geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Screenshot frame",origin+"/cast/screenshot",false,1600,1600);v.region_editor_id=id;v.region_geometry=geometry;update_framing_placement(v);}
else{auto& v=create_view(key,"Screenshot frame",origin+"/cast/screenshot",false,1120,780,"",1.25f);v.region_editor_id=id;}
menu_open=false;hide_launcher(false);
}else if(kind=="cast.screenshot.close"){
if(views.count("framely.screenshot")&&views.at("framely.screenshot")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.screenshot"));
}else if(kind=="cast.screenshot.shortcutPreview.open"){
if(screenshot_radial_held){auto key="framely.screenshot.preview";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}double aspect=c.value("aspect",1.0);if(!std::isfinite(aspect)||aspect<.3||aspect>4)aspect=1.0;int width=960,height=std::clamp(int(960/aspect),240,960);auto& v=create_view(key,"Screenshot preview",origin+"/cast/screenshot/shortcut",false,width,height,"",.95f);overlays->SetOverlayInputMethod(v.overlay,vr::VROverlayInputMethod_None);}
}else if(kind=="cast.screenshot.shortcutPreview.close"){
if(auto preview=views.find("framely.screenshot.preview");preview!=views.end())close_view(*preview->second);
}else if(kind=="cast.framing.open"){auto key="framely.framing";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto geometry=c.at("geometry");RecordingRegionPlane plane;auto view=geometry.at("view");if(!recording_region_plane(view,geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Recording frame",origin+"/cast/framing",false,1600,1600);v.region_editor_id=geometry.at("id");v.region_geometry=geometry;update_framing_placement(v);menu_open=false;hide_launcher(false);}else if(kind=="cast.framing.reference"){auto found=views.find("framely.framing");if(found!=views.end()&&!found->second->closing&&found->second->region_editor_id==c.value("id",std::string{})){double cx=c.at("centerTanX"),cy=c.at("centerTanY");if(std::isfinite(cx)&&std::isfinite(cy)&&std::abs(cx)<=1.733&&std::abs(cy)<=1.733){found->second->region_geometry["view"]["centerTanX"]=cx;found->second->region_geometry["view"]["centerTanY"]=cy;}}}else if(kind=="cast.framing.close"){if(views.count("framely.framing")&&views.at("framely.framing")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.framing"));}else if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"))Line truncated
}
static void release_framing_pointer(View& v){
if(!v.framing_left_down)return;v.framing_left_down=false;
@@ -697,7 +1090,7 @@ static void release_framing_pointer(View& v){
auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.pointerRelease'))");frame->base.release(&frame->base);
}
static void reconcile_framing_pointer(View& v){
if(v.key!="framely.framing"||!v.framing_left_down)return;
if((v.key!="framely.framing"&&!(v.key=="framely.screenshot"&&v.region_geometry.contains("view")))||!v.framing_left_down)return;
bool known=true,held=false,found=false,owner=false;
for(int hand=0;hand<2;hand++){
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
@@ -710,7 +1103,7 @@ static void reconcile_framing_pointer(View& v){
}
if((owner||found)&&known&&!held)release_framing_pointer(v);
}
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;if(menu_open&&v.key!="menu"&&e.eventType==vr::VREvent_MouseButtonDown&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now()))menu_open=false;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="framely.framing")release_framing_pointer(v);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=="framely.framing")mouse.modifiers=v.framing_left_down?EVENTFLAG_LEFT_MOUSE_BUTTON:0;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;if(v.key=="framely.framing"&&button==MBT_LEFT)v.framing_left_down=e.eventType==vr::VREvent_MouseButtonDown;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(v.key=="framely.framing")release_framing_pointer(v);if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key.rfind("framely.cast.",0)!=0&&v.key!="framely.apk.permission"&&v.key!="framely.framing")close_view(v);break;default:break;}}
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if(v.key=="framely.system.radial"&&e.eventType==vr::VREvent_FocusLeave)v.pointer_valid=false;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;if(menu_open&&v.key!="menu"&&e.eventType==vr::VREvent_MouseButtonDown&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now()))menu_open=false;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);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=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))mouse.modifiers=v.framing_left_down?EVENTFLAG_LEFT_MOUSE_BUTTON:0;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:{if(v.key!=frame_keyboard_window_key)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;if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view")))&&button==MBT_LEFT)v.framing_left_down=e.eventType==vr::VREvent_MouseButtonDown;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||(keyboard_open&&keyboard_view!=v.key)||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(!keyboard_open&&!v.dashboard&&v.key!=frame_keyboard_window_key&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key!="framely.system.radial"&&v.key.rfind("framely.cast.",0)!=0&&v.key!="framely.apk.permission"&&v.key!="framely.framing"&&v.key!="framely.screenshot")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];}
@@ -724,7 +1117,7 @@ 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();setup_entry_actions();
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();system_radial_available=std::getenv("FRAMELY_STEAM_MENU_ENABLED")&&std::strcmp(std::getenv("FRAMELY_STEAM_MENU_ENABLED"),"1")==0;
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};};
@@ -748,11 +1141,12 @@ int main(int argc,char** argv){
client.api.get_dialog_handler=[](cef_client_t*){return dialog_handler.acquire();};client.api.get_render_handler=[](cef_client_t*){return render_handler.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life_handler.acquire();};client.api.get_display_handler=[](cef_client_t*){return display_handler.acquire();};client.api.get_request_handler=[](cef_client_t*){return request_handler.acquire();};
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);DockIconTexture icon_texture;icon_texture.init();if(!icon_texture.submit(button,pixels)){icon_texture.destroy();overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}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);icon_texture.destroy();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;}
if(system_radial_available)try{create_view("framely.system.radial.progress","Framely radial progress",initial+"?view=radial-progress",false,720,720);create_view("framely.system.radial","Framely radial menu",initial+"?view=radial",false,720,720);}catch(const std::exception& e){std::cerr<<"Radial menu unavailable: "<<e.what()<<"\n";system_radial_available=false;}
const char* steam_root=std::getenv("FRAMELY_STEAM_ROOT");if(!ui_audio.prepare(steam_root?steam_root:""))std::cerr<<"Steam UI sounds unavailable\n";
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);recording_state=data.value("recording",json::object());recording_region_state=data.value("recordingRegion",json::object());framing_editor_state=data.value("framingEditor",json(nullptr));if(!recording_state.is_object())recording_state=json::object();auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",false)}});last_feedback=feedback;}auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));const auto launcher=data.value("launcher",json::object());launcher_prioritize_ui.store(launcher.value("prioritizeUi",true));system_radial_enabled.store(launcher.value("systemRadialMenu",true));std::lock_guard<std::mutex> lock(queue_mutex);recording_state=data.value("recording",json::object());recording_region_state=data.value("recordingRegion",json::object());framing_editor_state=data.value("framingEditor",json(nullptr));screenshot_editor_state=data.value("screenshotEditor",json(nullptr));screenshot_shortcut_preview=data.value("screenshotShortcutPreview",json(nullptr));if(!recording_state.is_object())recording_state=json::object();auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",false)}});last_feedback=feedback;}auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
std::thread priority_worker([]{LauncherPriority priority;while(running){priority.update(launcher_priority_active.load());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;int icon_progress=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();json recording,region,framing;{std::lock_guard<std::mutex> lock(queue_mutex);recording=recording_state;region=recording_region_state;framing=framing_editor_state;}if(auto editor=views.find("framely.framing");editor!=views.end()&&framing_editor_should_close(framing,editor->second->region_editor_id))close_view(*editor->second);recording_indicator.tick(overlays,recording.value("active",false)&&recording.value("indicatorVisible",true)?recording.value("id",std::string{}):std::string{},recording.value("elapsedSeconds",uint64_t(0)));if(auto editor=views.find("framely.framing");editor!=views.end())update_framing_placement(*editor->second);if(region.value("visible",false)&&region.contains("view")){auto presentation=recording_region_presentation(region["view"]);recording_region_overlay.tick(overlays,region,presentation.distance,&presentation.view);}else recording_region_overlay.tick(overlays,region);vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}observe_dashboard(global);keyboard_event(global);}launcher_priority_active.store(launcher_open&&launcher_prioritize_ui.load());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);
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();json recording,region,framing,screenshot_editor,screenshot_preview;{std::lock_guard<std::mutex> lock(queue_mutex);recording=recording_state;region=recording_region_state;framing=framing_editor_state;screenshot_editor=screenshot_editor_state;screenshot_preview=screenshot_shortcut_preview;}if(auto editor=views.find("framely.framing");editor!=views.end()&&framing_editor_should_close(framing,editor->second->region_editor_id))close_view(*editor->second);if(auto editor=views.find("framely.screenshot");editor!=views.end()&&framing_editor_should_close(screenshot_editor,editor->second->region_editor_id))close_view(*editor->second);recording_indicator.tick(overlays,recording.value("active",false)&&recording.value("indicatorVisible",true)?recording.value("id",std::string{}):std::string{},recording.value("elapsedSeconds",uint64_t(0)));if(auto editor=views.find("framely.framing");editor!=views.end())update_framing_placement(*editor->second);if(auto editor=views.find("framely.screenshot");editor!=views.end()&&editor->second->region_geometry.is_object()&&editor->second->region_geometry.contains("view"))update_framing_placement(*editor->second);if(screenshot_radial_held&&screenshot_preview.is_object()&&screenshot_preview.value("visible",false))screenshot_region_overlay.tick(overlays,screenshot_preview);else screenshot_region_overlay.destroy();if(region.value("visible",false)&&region.contains("view")){auto presentation=recording_region_presentation(region["view"]);recording_region_overlay.tick(overlays,region,presentation.distance,&presentation.view);}else recording_region_overlay.tick(overlays,region);external_keyboard_tick(std::chrono::steady_clock::now());vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}observe_dashboard(global);keyboard_event(global);}launcher_priority_active.store(launcher_open&&launcher_prioritize_ui.load());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;
// Width and aspect are only ray-solver seeds; physical size comes from intersections.
float width=0,height=0;bool fresh=visible&&!overlays->GetOverlayWidthInMeters(dock,&width)&&std::isfinite(width)&&width>0&&width<10&&std::isfinite(mouse_scale.v[0])&&std::isfinite(mouse_scale.v[1])&&mouse_scale.v[0]>0&&mouse_scale.v[1]>0;
@@ -768,7 +1162,7 @@ int main(int argc,char** argv){
if(!main_overlay)main_overlay=active_main_window();
if(main_overlay&&!overlays->GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose;
if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);}
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher"&&key.rfind("framely.cast.",0)!=0&&key!="framely.apk.permission")close_view(*v);}was_visible=visible;
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!=frame_keyboard_window_key&&key!="menu"&&key!="notifications"&&key!="launcher"&&key!="framely.system.radial"&&key!="framely.system.radial.progress"&&key.rfind("framely.cast.",0)!=0&&key!="framely.apk.permission")close_view(*v);}was_visible=visible;
if(launcher_close_at.time_since_epoch().count()&&now>=launcher_close_at){launcher_close_at={};if(!launcher_open)finish_launcher_close();}
// The runtime owns dashboard placement, curvature, visibility and switching.
// Never move the launcher into standing space or hide another app's overlay.
@@ -816,6 +1210,7 @@ int main(int argc,char** argv){
activate_entry(entry_hold.tick(std::chrono::steady_clock::now(),anchor_ok&&entry_connected,entry_inside));
for(auto&[key,v]:views)v->stick_scrolling=false;
entry_actions(button,anchor_ok);
system_radial_tick();
// Some dashboard runtimes do not emit Modal_Cancel for clicks on their own
// overlays. Observe the same controller click edge as a fallback, without
// consuming the click destined for the other surface.
@@ -831,7 +1226,7 @@ int main(int argc,char** argv){
if(menu_open&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now())&&(pressed&click)&&views.count("menu")&&!overlays->IsHoverTargetOverlay(views.at("menu")->overlay)&&!overlays->IsHoverTargetOverlay(button))menu_open=false;
}
if(launcher_open){bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held;launcher_dismiss.observe(held);}
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){if(!active&&key=="launcher"){if(v->launcher_pointer.active){cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;v->host->send_mouse_move_event(v->host,&outside,0);outside.modifiers=0;v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);}v->launcher_pointer.clear();auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->base.release(&frame->base);}v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;if(key!="launcher")for(auto&[other,t]:views)if(!t->dashboard&&other!="menu"&&other!="notifications"&&other!="launcher"&&other.rfind("framely.cast.",0)!=0&&other!="framely.apk.permission"&&!keyboard_open)close_view(*t);}}
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){if(!active&&key=="launcher"){if(v->launcher_pointer.active){cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;v->host->send_mouse_move_event(v->host,&outside,0);outside.modifiers=0;v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);}v->launcher_pointer.clear();auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->base.release(&frame->base);}v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;if(key!="launcher")for(auto&[other,t]:views)if(!t->dashboard&&other!=frame_keyboard_window_key&&other!="menu"&&other!="notifications"&&other!="launcher"&&other!="framely.system.radial"&&other!="framely.system.radial.progress"&&other.rfind("framely.cast.",0)!=0&&other!="framely.apk.permission"&&!keyboard_open)close_view(*t);}}
reconcile_framing_pointer(*v);
while(overlays->PollNextOverlayEvent(v->overlay,&event,sizeof(event)))overlay_input(*v,event);
if(!v->cast_frame.path.empty()){glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);if(v->cast_frame.update())v->dirty=true;}
@@ -910,5 +1305,7 @@ int main(int argc,char** argv){
}
recording_indicator.destroy();
recording_region_overlay.destroy();
screenshot_region_overlay.destroy();
if(external_keyboard.active){frame_keyboard_command("dashboardClose",json::object());external_keyboard={};}
close_keyboard();for(auto&[key,v]:views)close_view(*v);auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(5);while(std::chrono::steady_clock::now()<deadline){cef_do_message_loop_work();bool all=true;for(auto&[key,v]:views)all&=v->closed;if(all)break;std::this_thread::sleep_for(std::chrono::milliseconds(5));}poller.join();priority_worker.join();for(auto&[key,v]:views)if(!v->closed){std::cerr<<"Browser shutdown timed out\n";_Exit(7);}for(auto&[key,v]:views){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);glDeleteTextures(2,v->textures);}views.clear();overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();glXMakeContextCurrent(xdisplay,None,None,nullptr);glXDestroyContext(xdisplay,glcontext);glXDestroyPbuffer(xdisplay,glsurface);XCloseDisplay(xdisplay);return 0;
}
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Keep SteamVR's click and double-click actions while Framely handles hold."""
import hashlib
import json
import os
from pathlib import Path
import sys
from urllib.parse import urlencode
from urllib.request import Request, urlopen
APP = "openvr.component.vrcompositor"
CONTROLLER = "frame_controller"
BASE = "http://127.0.0.1:27062"
HEADERS = {
"Origin": BASE,
"Referer": BASE + "/dashboard/controllerbinding.html",
}
DIRECTORY = Path.home() / ".local/share/framely/input"
BINDING = DIRECTORY / "steam-system-menu.json"
META = DIRECTORY / "steam-system-menu-source.json"
def request(path, params=None, body=None):
if params:
path += "?" + urlencode(params)
data = None if body is None else json.dumps(body).encode()
headers = dict(HEADERS)
if data is not None:
headers["Content-Type"] = "application/json"
with urlopen(Request(BASE + path, data=data, headers=headers), timeout=3) as response:
return json.load(response)
def current_url():
data = request("/input/getactions.json", {"app_key": APP})
return data["current_binding_url"][CONTROLLER]
def select(url):
result = request(
"/input/selectconfig.action",
body={"app_key": APP, "controller_type": CONTROLLER, "url": url},
)
if not result.get("success"):
raise RuntimeError(f"SteamVR rejected controller binding: {result}")
def source_binding(url):
result = request(
"/input/loadbindingfromurl.json",
{"binding_url": url, "controller_type": CONTROLLER, "app_key": APP},
)
if "binding_config" not in result:
raise RuntimeError(f"SteamVR could not load controller binding: {result}")
return result["binding_config"]
def without_recenter(binding):
# Preserve every other compositor action, including click, double and
# screenshot chords. A user's unrelated quick-recenter sources survive.
result = json.loads(json.dumps(binding))
sources = result.get("bindings", {}).get("/actions/quickrecenter", {}).get("sources", [])
kept = [
source for source in sources
if source.get("path") not in (
"/user/hand/left/input/system",
"/user/hand/right/input/system",
) or "long" not in source.get("inputs", {})
]
result["bindings"]["/actions/quickrecenter"]["sources"] = kept
return result, len(kept) != len(sources)
def atomic_json(path, value):
DIRECTORY.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".new")
with temporary.open("w") as out:
json.dump(value, out, ensure_ascii=False, separators=(",", ":"))
out.flush()
os.fsync(out.fileno())
os.replace(temporary, path)
def main():
active = current_url()
ours = BINDING.resolve().as_uri()
metadata = json.loads(META.read_text()) if META.exists() else {}
if len(sys.argv) > 1 and sys.argv[1] == "--restore":
if active == ours and metadata.get("source_url"):
select(metadata["source_url"])
return 10
return 0
source_url = metadata.get("source_url") if active == ours else active
if not source_url:
raise RuntimeError("SteamVR did not report a Frame controller binding")
source = source_binding(source_url)
patched, changed = without_recenter(source)
if not changed:
return 0
digest = hashlib.sha256(json.dumps(source, sort_keys=True).encode()).hexdigest()
if active == ours and metadata.get("source_hash") == digest and BINDING.exists():
return 0
atomic_json(BINDING, patched)
atomic_json(META, {"source_url": source_url, "source_hash": digest})
select(ours)
return 10 # SteamVR caches the old long-press action until it restarts.
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as error:
print(f"SteamVR system-menu binding: {error}", file=sys.stderr)
sys.exit(1)
+4 -1
View File
@@ -4,6 +4,7 @@
#include <vector>
#include <array>
#include <chrono>
#include <utility>
#include "vendor/json.hpp"
using RegionJson=nlohmann::json;
struct RecordingRegionPlane {vr::HmdMatrix34_t transform{};float width=0;};
@@ -46,7 +47,9 @@ inline std::vector<unsigned char> recording_region_pixels(int w,int h,const std:
}
class RecordingRegionOverlay {
vr::IVROverlay* overlays=nullptr;vr::VROverlayHandle_t handle=0;std::string last,last_pixels;
std::string key;
public:
explicit RecordingRegionOverlay(std::string overlay_key="framely.recording.region"):key(std::move(overlay_key)){}
void destroy(){if(handle&&overlays)overlays->DestroyOverlay(handle);handle=0;last.clear();last_pixels.clear();}
void tick(vr::IVROverlay* api,const RegionJson& state,double distance=2.,const RegionJson* live_view=nullptr){
overlays=api;
@@ -56,7 +59,7 @@ public:
const auto& view=live_view?*live_view:state.at("view");double aspect=view.at("aspect");
RecordingRegionPlane plane;if(!recording_region_plane(view,view.at("tanHalfHorizontal"),view.at("centerTanX"),view.at("centerTanY"),aspect,plane,distance)){destroy();return;}
if(!handle){
if(overlays->CreateOverlay("framely.recording.region","Framely recording region",&handle))return;
if(overlays->CreateOverlay(key.c_str(),"Framely capture region",&handle))return;
overlays->SetOverlayInputMethod(handle,vr::VROverlayInputMethod_None);
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,false);
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_HideLaserIntersection,true);
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <algorithm>
#include <chrono>
#include <cmath>
#include <optional>
enum class SystemRadialDirection {Center,Up,UpRight,Right,DownRight,Down,DownLeft,Left,UpLeft};
enum class SystemRadialEvent {Idle,Begin,Open,Change,Cancel,Commit};
struct SystemRadial {
using Clock=std::chrono::steady_clock;
bool active=false,open=false;
int hand=-1;
Clock::time_point began{};
SystemRadialDirection direction=SystemRadialDirection::Center;
static constexpr auto hold=std::chrono::seconds(1);
static constexpr float deadzone=.09f;
float progress(Clock::time_point now) const {
return active?std::clamp(std::chrono::duration<float>(now-began).count(),0.f,1.f):0.f;
}
SystemRadialEvent update(int input_hand,bool valid,bool down,float right,float up,Clock::time_point now,std::optional<SystemRadialDirection> hover=std::nullopt) {
if(!active) {
if(!valid||!down)return SystemRadialEvent::Idle;
active=true;open=false;hand=input_hand;began=now;direction=SystemRadialDirection::Center;
return SystemRadialEvent::Begin;
}
if(input_hand!=hand)return SystemRadialEvent::Idle;
if(!valid) {active=false;open=false;hand=-1;return SystemRadialEvent::Cancel;}
if(!down) {
const bool commit=open||now-began>=hold;
active=false;open=false;hand=-1;
return commit?SystemRadialEvent::Commit:SystemRadialEvent::Cancel;
}
if(!open&&now-began>=hold){open=true;return SystemRadialEvent::Open;}
if(open) {
auto next=hover.value_or(select(right,up));
if(next!=direction){direction=next;return SystemRadialEvent::Change;}
}
return SystemRadialEvent::Idle;
}
static SystemRadialDirection select(float right,float up) {
if(right*right+up*up<deadzone*deadzone)return SystemRadialDirection::Center;
constexpr float sector=.78539816339f;
int index=(int)std::lround(std::atan2(right,up)/sector);
if(index<0)index+=8;
switch(index%8) {
case 0:return SystemRadialDirection::Up;
case 1:return SystemRadialDirection::UpRight;
case 2:return SystemRadialDirection::Right;
case 3:return SystemRadialDirection::DownRight;
case 4:return SystemRadialDirection::Down;
case 5:return SystemRadialDirection::DownLeft;
case 6:return SystemRadialDirection::Left;
default:return SystemRadialDirection::UpLeft;
}
}
// Match the four visible 124x82 slots and 112px center in the 720px overlay.
// A ray inside the painted button always selects it, including its border.
static std::optional<SystemRadialDirection> hit(float x,float y) {
const float dx=x-360.f,dy=y-360.f;
if(dx*dx+dy*dy<=56.f*56.f)return SystemRadialDirection::Center;
if(std::abs(dx)<=62.f&&std::abs(dy+177.f)<=41.f)return SystemRadialDirection::Up;
if(std::abs(dx-177.f)<=62.f&&std::abs(dy)<=41.f)return SystemRadialDirection::Right;
if(std::abs(dx)<=62.f&&std::abs(dy-177.f)<=41.f)return SystemRadialDirection::Down;
if(std::abs(dx+177.f)<=62.f&&std::abs(dy)<=41.f)return SystemRadialDirection::Left;
return std::nullopt;
}
};
+6 -5
View File
@@ -10,7 +10,7 @@
// never spawns a process or reads files on a hover/click.
class SteamUiSounds {
struct Spec { int format, channels, freq; };
struct Clip { void* stream=nullptr; uint8_t* data=nullptr; uint32_t size=0; } clips[2];
struct Clip { void* stream=nullptr; uint8_t* data=nullptr; uint32_t size=0; } clips[3];
void* library=nullptr;
bool initialized=false;
bool (*init)(uint32_t)=nullptr;
@@ -39,14 +39,15 @@ class SteamUiSounds {
if(!library)return false;
if(!(bind(init,"SDL_InitSubSystem")&&bind(quit,"SDL_QuitSubSystem")&&bind(load,"SDL_LoadWAV")&&bind(open,"SDL_OpenAudioDeviceStream")&&bind(resume,"SDL_ResumeAudioStreamDevice")&&bind(put,"SDL_PutAudioStreamData")&&bind(clear,"SDL_ClearAudioStream")&&bind(gain,"SDL_SetAudioStreamGain")&&bind(destroy,"SDL_DestroyAudioStream")&&bind(release,"SDL_free"))){shutdown();return false;}
if(!init(0x10)){shutdown();return false;}initialized=true;
const char* names[]={"deck_ui_typing.wav","deck_ui_typing.wav"};
for(int i=0;i<2;++i){Spec spec{};auto& clip=clips[i];
const char* names[]={"deck_ui_typing.wav","deck_ui_typing.wav","camera1.wav"};
for(int i=0;i<3;++i){Spec spec{};auto& clip=clips[i];
if(!load((root+"/steamui/sounds/"+names[i]).c_str(),&spec,&clip.data,&clip.size)||!clip.size||clip.size>1024*1024){shutdown();return false;}
clip.stream=open(0xffffffffu,&spec,nullptr,nullptr);
if(!clip.stream||!gain(clip.stream,i==0?.30f:.45f)||!resume(clip.stream)){shutdown();return false;}
if(!clip.stream||!gain(clip.stream,i==0?.30f:i==1?.45f:.55f)||!resume(clip.stream)){shutdown();return false;}
}
return true;
}
void silence(){if(!initialized)return;for(auto& clip:clips)clear(clip.stream);}
void silence(){if(!initialized)return;for(int i=0;i<2;i++)clear(clips[i].stream);}
void play(bool activation=false){if(!initialized)return;silence();auto& clip=clips[activation?1:0];put(clip.stream,clip.data,int(clip.size));}
void screenshot(){if(!initialized)return;auto& clip=clips[2];clear(clip.stream);put(clip.stream,clip.data,int(clip.size));}
};
+3
View File
@@ -5,6 +5,9 @@ root=/var/lib/framely
previous=$(cat "$root/previous-release")
[[ $previous == releases/* && $previous != *..* && -d $root/$previous ]] || { echo 'No previous release.' >&2; exit 1; }
current=$(readlink "$root/current")
if [[ ! -f $root/$previous/share/input/steam-system-menu.py && -f $root/current/system-menu-restore.sh ]]; then
bash "$root/current/system-menu-restore.sh"
fi
systemctl stop framely-session.service framely.service
ln -s "$previous" "$root/current.rollback"
mv -Tf "$root/current.rollback" "$root/current"
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
root=/var/lib/framely
helper=$root/current/share/input/steam-system-menu.py
[[ -f $helper && -f $root/steam-user ]] || exit 0
steam_user=$(cat "$root/steam-user")
steam_uid=$(id -u "$steam_user")
steam_home=$(getent passwd "$steam_user" | cut -d: -f6)
set +e
runuser -u "$steam_user" -- env HOME="$steam_home" XDG_RUNTIME_DIR="/run/user/$steam_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$steam_uid/bus" python3 "$helper" --restore
result=$?
set -e
if [[ $result == 10 ]]; then
systemctl --user --machine="$steam_user@" restart steamvr.service
elif [[ $result != 0 ]]; then
echo 'Could not restore the original SteamVR system-button binding; start SteamVR and retry.' >&2
exit "$result"
fi
+3
View File
@@ -13,6 +13,9 @@ restore_session() {
exit "$code"
}
trap restore_session ERR
if [[ -f $root/current/system-menu-restore.sh ]]; then
bash "$root/current/system-menu-restore.sh"
fi
systemctl start framely.service
ready=false
for attempt in {1..30}; do
+1 -1
View File
@@ -8,7 +8,7 @@ const listeners=new Set<(event:unknown)=>void>();
function request(op:string,params:unknown={}):Promise<any>{const id=next++;return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{pending.delete(id);reject(new Error('Framely request timed out'));},90000);pending.set(id,{resolve,reject,timer});parent.postMessage({channel:'framely.plugin',id,op,params},'*');});}
window.addEventListener('message',e=>{if(e.source!==parent||e.data?.channel!=='framely.reply')return;const data=e.data;if(data.event){listeners.forEach(f=>f(data.event));return;}const p=pending.get(data.id);if(!p)return;pending.delete(data.id);clearTimeout(p.timer);if(data.error)p.reject(new Error(data.error));else p.resolve(data.result);});
(window as any).__framelyBridge={request,subscribe:(f:(e:unknown)=>void)=>{listeners.add(f);return()=>listeners.delete(f);}};
installKeyboard(params=>request('keyboard',params));
installKeyboard(params=>request('keyboard',params),(method,params)=>request('keyboard',{bridge:{method,params}}));
window.addEventListener('pagehide',()=>{for(const p of pending.values()){clearTimeout(p.timer);p.reject(new Error('Plugin page closed'));}pending.clear();});
+67
View File
@@ -0,0 +1,67 @@
// Host SDK adapter. Reuses installKeyboard's editor; no layout/window changes.
export type KeyboardBridgeCall=(method:string,params:Record<string,unknown>)=>Promise<any>;
export function createKeyboardBridge(call:KeyboardBridgeCall){
let field:HTMLElement|null=null,context='',active=false,stopped=false;
const client=`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
let serial=0,timer:ReturnType<typeof setTimeout>|undefined;
const valid=()=>!!field?.isConnected&&document.activeElement===field&&!field.closest('[inert]')&&!(field as HTMLInputElement).disabled&&!(field as HTMLInputElement).readOnly;
const close=()=>{const old=context;active=false;context='';if(timer)clearTimeout(timer);if(old)void call('webInputClose',{context:old}).catch(()=>{});};
const key=(op:any)=>{
if(op.kind==='text'){(window as any).__framelyEditKeyboard(op.text);return true;}
const input=field as HTMLInputElement|HTMLTextAreaElement;
const map:Record<string,string>={BackSpace:'\b',Left:'\x1b[D',Right:'\x1b[C',Up:'\x1b[A',Down:'\x1b[B'};
if(!op.ctrl&&!op.alt&&map[op.key]){(window as any).__framelyEditKeyboard(map[op.key]);return true;}
if(op.ctrl&&op.key.toLowerCase()==='a'&&typeof input.select==='function'){input.select();return true;}
if(op.ctrl||op.alt)return false;
if(['Home','End','Delete'].includes(op.key)&&typeof input.value==='string'){
const a=input.selectionStart??input.value.length,b=input.selectionEnd??a;
if(op.key==='Delete'){
if(a===b){const next=Array.from(new (Intl as any).Segmenter(undefined,{granularity:'grapheme'}).segment(input.value.slice(a)))[0] as any;input.setSelectionRange(a,a+(next?.segment.length??0));}
if((input.selectionStart??0)!==(input.selectionEnd??0))(window as any).__framelyEditKeyboard('\b');
}else{const p=op.key==='Home'?0:input.value.length;input.setSelectionRange(op.shift?a:p,p);}
return true;
}
const event={key:op.key==='Return'?'Enter':op.key==='Escape'?'Escape':op.key,code:op.key==='Return'?'Enter':op.key,bubbles:true,cancelable:true,shiftKey:!!op.shift};
const allowed=field!.dispatchEvent(new KeyboardEvent('keydown',event));
if(op.key==='Return'&&allowed){
if(field instanceof HTMLTextAreaElement)(window as any).__framelyEditKeyboard('\n');
else if(field instanceof HTMLInputElement&&field.form)field.form.requestSubmit();
}
if(op.key==='Tab'&&allowed){const fields=Array.from(document.querySelectorAll<HTMLElement>('input:not([disabled]),textarea:not([disabled]),button:not([disabled]),select:not([disabled]),[tabindex="0"]')).filter(e=>e.getClientRects().length>0&&!e.closest('[inert]'));const index=fields.indexOf(field!);fields[(index+(op.shift?-1:1)+fields.length)%fields.length]?.focus();}
field?.dispatchEvent(new KeyboardEvent('keyup',event));return true;
};
const poll=async()=>{
const token=context;if(!active||!token||stopped)return;
if(!valid()){close();return;}
try{
const response=await call('webInputPoll',{context:token});
if(context!==token||!active)return;
if(!response.active){close();return;}
for(const edit of response.edits??[]){
let delivered=false;
if(edit.context===token&&context===token&&valid()&&Date.now()<edit.expiresAt){
const claim=await call('webInputClaim',{context:token,id:edit.id,generation:edit.generation});
if(claim.accepted&&context===token&&valid()&&Date.now()<edit.expiresAt)delivered=key(edit.operation);
}
await call('webInputAck',{context:token,id:edit.id,generation:edit.generation,delivered});
}
}catch{close();return;}
if(context===token&&active)timer=setTimeout(poll,20);
};
const lost=(event:FocusEvent)=>{if(event.target===field)close();};
const dispose=()=>{stopped=true;close();field=null;document.removeEventListener('focusout',lost,true);window.removeEventListener('pagehide',dispose);window.removeEventListener('blur',close);};
document.addEventListener('focusout',lost,true);window.addEventListener('pagehide',dispose);window.addEventListener('blur',close);
const start=async(target:HTMLElement,password:boolean):Promise<boolean>=>{
if(stopped||document.activeElement!==target)return false;
if(active&&field===target&&valid())return true;
close();field=target;context=`${client}-${++serial}`;
const token=context;
try{
const response=await call('webInputOpen',{context:token,client,sequence:serial,expiresAt:Date.now()+900,password});
if(context!==token||!valid()){if(response.ok)void call('webInputClose',{context:token});return true;}
active=!!response.ok;if(active){void poll();return true;}context='';return false;
}catch{if(context!==token||stopped||!valid())return true;context='';return false;}
};
return Object.assign(start,{dispose});
}
+11 -5
View File
@@ -1,18 +1,23 @@
import {createKeyboardBridge,type KeyboardBridgeCall} from './keyboard-bridge';
type Editable=HTMLInputElement|HTMLTextAreaElement|HTMLElement;
export function installKeyboard(open:(params:{existing:string;password:boolean;multiline:boolean})=>Promise<unknown>){
export function installKeyboard(open:(params:{existing:string;password:boolean;multiline:boolean})=>Promise<unknown>,bridgeCall?:KeyboardBridgeCall,options:{requestOnFocus?:boolean}={}){
const bridge=bridgeCall?createKeyboardBridge(bridgeCall):null;
let field:Editable|null=null,last:Editable|null=null,openedAt=0;
const request=(target:EventTarget|null)=>{
const embedded=target instanceof HTMLElement&&target.matches('[data-framely-editor-input][contenteditable="true"]');
if(!embedded&&(!(target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement)||target.disabled||target.readOnly))return;
if(!(target instanceof HTMLElement))return;
if(target instanceof HTMLInputElement&&!['text','password','url','search','email','number','tel'].includes(target.type))return;
field=target;const now=performance.now();if(last===target&&now-openedAt<300)return;last=target;openedAt=now;
if(document.activeElement!==target)return;
field=target;const now=performance.now();if(!bridge&&last===target&&now-openedAt<300)return;last=target;openedAt=now;
const params={existing:target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement?target.value:'',password:target instanceof HTMLInputElement&&target.type==='password',multiline:embedded||target instanceof HTMLTextAreaElement};
if(embedded)target.dispatchEvent(new CustomEvent('framely.keyboard.open',{detail:params}));
void open(params).catch(console.error);
void (async()=>{if(bridge&&await bridge(target,params.password))return;if(document.activeElement!==target)return;await open(bridge?{...params,existing:''}:params);})().catch(console.error);
};
document.addEventListener('focusin',e=>request(e.target));
document.addEventListener('pointerdown',e=>request(e.target));
const focus=(e:FocusEvent)=>{if(options.requestOnFocus!==false)request(e.target);};
const keyboardFocus=(e:KeyboardEvent)=>{if(e.key==='Tab'&&options.requestOnFocus===false)request(document.activeElement);};
const pointer=(e:PointerEvent)=>{const target=e.target;setTimeout(()=>request(target),0);};
document.addEventListener('focusin',focus);document.addEventListener('pointerdown',pointer);document.addEventListener('keyup',keyboardFocus);
(window as any).__framelyCommitKeyboard=(value:string)=>{
if(!field?.isConnected)return;
if(field.closest('[inert]')||((field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement)&&(field.disabled||field.readOnly)))return;
@@ -59,4 +64,5 @@ export function installKeyboard(open:(params:{existing:string;password:boolean;m
(window as any).__framelyCommitKeyboard(updated);
try{field.setSelectionRange(position,position);}catch{}
};
return {activate:()=>request(document.activeElement),dispose:()=>{bridge?.dispose();field=null;last=null;document.removeEventListener('focusin',focus);document.removeEventListener('pointerdown',pointer);document.removeEventListener('keyup',keyboardFocus);}};
}
+1
View File
@@ -3,6 +3,7 @@
pub mod settings;
pub mod runtime;
mod recording;
pub mod screenshot;
pub mod region;
#[cfg(test)]
mod region_device_test;
+129
View File
@@ -0,0 +1,129 @@
//! One-frame screenshots. The capture path is independent of the cast encoder.
use super::settings::{Eye, Output, Settings, Source};
use anyhow::{ensure, Context, Result};
use image::{DynamicImage, ImageBuffer, ImageFormat, Rgba};
use std::{fs, io::{BufWriter, Write}, os::unix::process::CommandExt, path::{Path, PathBuf}, process::{Child, Command, Output as ProcessOutput, Stdio}, time::{SystemTime, UNIX_EPOCH}};
#[derive(Clone, Copy, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Crop {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
impl Crop {
pub fn validate(self) -> Result<Self> {
ensure!([self.x,self.y,self.width,self.height].into_iter().all(f64::is_finite)
&& self.x >= 0. && self.y >= 0. && self.width > 0. && self.height > 0.
&& self.x + self.width <= 1.000001 && self.y + self.height <= 1.000001,
"截图取景范围无效");
Ok(self)
}
pub fn apply(self, image: DynamicImage) -> Result<DynamicImage> {
let self_ = self.validate()?;
let width = image.width(); let height = image.height();
let x = (self_.x * width as f64).round() as u32;
let y = (self_.y * height as f64).round() as u32;
let w = (self_.width * width as f64).round() as u32;
let h = (self_.height * height as f64).round() as u32;
ensure!(w > 0 && h > 0 && x + w <= width && y + h <= height,"截图取景范围无效");
Ok(image.crop_imm(x,y,w,h))
}
}
fn screen_process(settings: &Settings, binaries: &Path, panel_socket: &Path, wait: bool) -> Result<Child> {
let eye = if settings.output==Output::Raw {0} else {match settings.eye {Eye::Left=>0,Eye::Right=>1,Eye::SteamVR=>super::runtime::dominant_eye()?}};
let stream=std::os::unix::net::UnixStream::connect(panel_socket)?;
use std::os::fd::AsRawFd;
let fd=stream.as_raw_fd();
let mut command=Command::new(binaries.join("framely-capture"));
command.args(["--snapshot","--width",&settings.width.to_string(),"--height",&settings.height.to_string(),"--eye",&eye.to_string()]);
if wait {command.arg("--snapshot-wait");}
if settings.output==Output::Raw {command.arg("--raw");}
else {
if let Some(fov)=settings.horizontal_fov {command.arg(format!("--fov={fov}"));}
command.arg(format!("--center-x={}",settings.center_x)).arg(format!("--center-y={}",settings.center_y));
}
command.env("FRAMELY_GRAB_FD","3").stdin(if wait {Stdio::piped()} else {Stdio::null()}).stdout(Stdio::piped()).stderr(Stdio::piped()).process_group(0);
unsafe {command.pre_exec(move || {
if fd!=3 && libc::dup2(fd,3)<0 {return Err(std::io::Error::last_os_error());}
if libc::fcntl(3,libc::F_SETFD,0)<0 {return Err(std::io::Error::last_os_error());}
Ok(())
});}
let child=command.spawn().context("无法启动屏幕截图采集")?;
drop(stream);
Ok(child)
}
fn screen_image(output: ProcessOutput) -> Result<DynamicImage> {
ensure!(output.status.success(),"屏幕截图失败:{}",String::from_utf8_lossy(&output.stderr).trim());
ensure!(output.stdout.len()>=8,"截图采集没有返回画面");
let width=u32::from_le_bytes(output.stdout[0..4].try_into()?);
let height=u32::from_le_bytes(output.stdout[4..8].try_into()?);
ensure!(width>0&&height>0&&u64::from(width)*u64::from(height)<=9_331_200
&& output.stdout.len()==8+width as usize*height as usize*4,"截图采集数据长度无效");
let pixels=ImageBuffer::<Rgba<u8>,_>::from_raw(width,height,output.stdout[8..].to_vec()).context("截图像素无效")?;
Ok(DynamicImage::ImageRgba8(pixels))
}
fn screen(settings: &Settings, binaries: &Path, panel_socket: &Path) -> Result<DynamicImage> {
screen_image(screen_process(settings,binaries,panel_socket,false)?.wait_with_output()?)
}
pub struct PreparedScreen { child: Option<Child> }
impl PreparedScreen {
pub fn start(settings: &Settings, binaries: &Path, panel_socket: &Path) -> Result<Self> {
ensure!(settings.source==Source::Screen,"仅屏幕采集支持预热截图");
Ok(Self{child:Some(screen_process(settings,binaries,panel_socket,true)?)})
}
pub fn capture(mut self) -> Result<DynamicImage> {
self.child.as_mut().context("截图采集程序已退出")?.stdin.take().context("无法触发截图")?.write_all(&[1])?;
let child=self.child.take().unwrap();
screen_image(child.wait_with_output()?)
}
}
impl Drop for PreparedScreen {
fn drop(&mut self) {
if let Some(mut child)=self.child.take() {let _=child.kill();let _=child.wait();}
}
}
fn steamvr() -> Result<DynamicImage> {
let output=Command::new("ffmpeg").args(["-hide_banner","-loglevel","error","-nostdin","-f","v4l2","-i","/dev/video99","-frames:v","1","-f","image2pipe","-c:v","png","pipe:1"])
.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).output().context("无法启动 SteamVR 截图采集")?;
ensure!(output.status.success(),"SteamVR 截图失败:{}",String::from_utf8_lossy(&output.stderr).trim());
let image=image::load_from_memory_with_format(&output.stdout,ImageFormat::Png)?;
ensure!(image.width()>0&&image.height()>0&&u64::from(image.width())*u64::from(image.height())<=9_331_200,"SteamVR 截图尺寸无效");
Ok(image)
}
pub fn capture(settings: &Settings, binaries: &Path, panel_socket: Option<&Path>, crop: Option<Crop>) -> Result<DynamicImage> {
let image=match settings.source {
Source::Screen=>screen(settings,binaries,panel_socket.context("缺少屏幕采集助手连接")?)?,
Source::SteamVR=>steamvr()?,
};
match crop {Some(crop)=>crop.apply(image),None=>Ok(image)}
}
pub fn save(image: &DynamicImage, directory: &Path) -> Result<PathBuf> {
fs::create_dir_all(directory)?;
let timestamp=SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
let suffix=hex::encode(rand::random::<[u8;4]>());
let name=format!("framely-{timestamp}-{suffix}.png");
let path=directory.join(name);
let temporary=directory.join(format!(".framely-{timestamp}-{suffix}.part"));
let result=(|| -> Result<()> {
let file=fs::OpenOptions::new().write(true).create_new(true).open(&temporary)?;
let mut writer=BufWriter::new(file);
image.write_to(&mut writer,ImageFormat::Png)?;
use std::io::Write;
writer.flush()?;
writer.into_inner()?.sync_all()?;
fs::rename(&temporary,&path)?;
Ok(())
})();
if result.is_err(){let _=fs::remove_file(&temporary);}
result?;
Ok(path)
}
+16
View File
@@ -31,6 +31,14 @@ pub enum Codec {
H264,
H265,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum ScreenshotShortcut {
#[default]
LeftView,
LeftBumper,
RightBumper,
}
impl Codec {
pub fn name(self) -> &'static str { match self { Self::H264 => "h264", Self::H265 => "h265" } }
pub fn rtp(self) -> &'static str { match self { Self::H264 => "H264/90000", Self::H265 => "H265/90000" } }
@@ -58,6 +66,10 @@ pub struct Settings {
pub receiver_name: String,
pub recording_region_visible: bool,
pub recording_indicator_visible: bool,
#[serde(skip_serializing)]
pub screenshot_shortcut_enabled: bool,
#[serde(skip_serializing)]
pub screenshot_shortcut: ScreenshotShortcut,
}
impl Default for Settings {
fn default() -> Self {
@@ -80,6 +92,8 @@ impl Default for Settings {
receiver_name: "Framely".into(),
recording_region_visible: false,
recording_indicator_visible: true,
screenshot_shortcut_enabled: false,
screenshot_shortcut: ScreenshotShortcut::LeftView,
}
}
}
@@ -91,6 +105,8 @@ impl Settings {
capture.receiver_name.clone_from(&other.receiver_name);
capture.recording_region_visible = other.recording_region_visible;
capture.recording_indicator_visible = other.recording_indicator_visible;
capture.screenshot_shortcut_enabled = other.screenshot_shortcut_enabled;
capture.screenshot_shortcut = other.screenshot_shortcut;
capture == *other
}
pub fn validate(&self) -> Result<()> {
File diff suppressed because it is too large. Load diff
+1
View File
@@ -25,6 +25,7 @@ pub fn get_headers(
) -> Result<ureq::Response> {
get_with_proxy(initial, allow_http, timeout, headers, &Default::default())
}
#[cfg(test)]
pub fn get_with_proxy(
initial: &str,
allow_http: bool,
+58 -9
View File
@@ -47,17 +47,35 @@ pub(crate) fn local_truncated_server() -> (String, std::thread::JoinHandle<()>)
for body in [b"old".as_slice(), b"new-data".as_slice()] {
let deadline = Instant::now() + Duration::from_secs(5);
let mut stream = loop {
if let Ok((stream, _)) = listener.accept() {
break stream;
match listener.accept() {
Ok((stream, _)) => break stream,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) => panic!("local fallback server accept failed: {error}"),
}
assert!(Instant::now() < deadline, "local fallback server timed out");
std::thread::sleep(Duration::from_millis(5));
};
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut request = [0u8; 8192];
let _ = stream.read(&mut request).unwrap();
// Accepted sockets can inherit O_NONBLOCK on macOS. Wait for
// the complete request even when its headers arrive separately.
stream.set_nonblocking(false).unwrap();
let request_deadline = Instant::now() + Duration::from_secs(2);
let mut request = Vec::new();
let mut chunk = [0u8; 1024];
while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") {
let remaining = request_deadline
.checked_duration_since(Instant::now())
.expect("local fallback server request timed out");
stream.set_read_timeout(Some(remaining)).unwrap();
let received = match stream.read(&mut chunk) {
Ok(received) => received,
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => panic!("local fallback server read failed: {error}"),
};
assert!(received > 0, "local fallback server request ended before headers");
request.extend_from_slice(&chunk[..received]);
assert!(request.len() <= 8192, "local fallback server request too large");
}
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\n")
.unwrap();
@@ -84,8 +102,12 @@ pub fn eligible(initial: &str, headers: &[(&str, &str)]) -> bool {
&& url.port_or_known_default() == Some(443)
&& url.username().is_empty() && url.password().is_none()
&& url.fragment().is_none()
// Allow only known public API pagination parameters, never signed URLs.
&& url.query_pairs().all(|(key, value)| matches!(key.as_ref(), "page" | "per_page") && !value.is_empty() && value.bytes().all(|c|c.is_ascii_digit()))
// Only pagination and Framely's fixed public topic search, never arbitrary queries or signed asset addresses.
&& url.query_pairs().all(|(key, value)| {
if matches!(key.as_ref(), "page" | "per_page") { return !value.is_empty() && value.bytes().all(|c| c.is_ascii_digit()); }
url.host_str() == Some("api.github.com") && url.path() == "/search/repositories"
&& matches!((key.as_ref(), value.as_ref()), ("q", "topic:framely-plugin archived:false fork:false") | ("sort", "stars") | ("order", "desc"))
})
&& headers.iter().all(|(key, _)| matches!(key.to_ascii_lowercase().as_str(), "accept" | "accept-encoding" | "user-agent" | "if-none-match" | "if-modified-since" | "cache-control"))
}
fn certificate_error(error: &(dyn std::error::Error + 'static)) -> bool {
@@ -185,11 +207,38 @@ pub fn run<T>(
mod tests {
use super::*;
#[test]
fn local_fallback_server_waits_for_complete_fragmented_headers() {
use std::{io::{Read, Write}, net::TcpStream, time::Duration};
let (address, server) = local_truncated_server();
for expected in [b"old".as_slice(), b"new-data".as_slice()] {
let mut stream = TcpStream::connect(address.trim_start_matches("http://")).unwrap();
// Leave time for accept before sending a partial request. The
// server must wait for the final header delimiter before replying.
std::thread::sleep(Duration::from_millis(25));
stream.write_all(b"GET /asset HTTP/1.1\r\nHost: localhost\r\n").unwrap();
stream.set_read_timeout(Some(Duration::from_millis(50))).unwrap();
let mut byte = [0u8; 1];
let error = stream.read(&mut byte).expect_err("server replied before headers completed");
assert!(matches!(error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut));
stream.write_all(b"\r\n").unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let mut response = Vec::new();
stream.read_to_end(&mut response).unwrap();
let header_end = response.windows(4).position(|bytes| bytes == b"\r\n\r\n").unwrap() + 4;
assert_eq!(&response[header_end..], expected);
}
server.join().unwrap();
}
#[test]
fn github_fallback_only_public_exact_hosts_without_credentials() {
assert!(eligible(
"https://api.github.com/repos/a/b/releases?page=1&per_page=100",
&[]
));
assert!(eligible("https://api.github.com/search/repositories?q=topic%3Aframely-plugin+archived%3Afalse+fork%3Afalse&sort=stars&order=desc&per_page=100&page=1", &[]));
assert!(!eligible("https://api.github.com/search/repositories?q=secret", &[]));
assert!(!eligible("https://github.com/search/repositories?q=topic%3Aframely-plugin+archived%3Afalse+fork%3Afalse", &[]));
for u in [
"https://github.com.evil/a",
"https://foo.github.com/a",
+28 -2
View File
@@ -258,6 +258,28 @@ impl Jobs {
let key = id.clone();
std::thread::spawn(move || {
let result = (|| -> Result<Value> {
let mut request = request.clone();
let mut github_source = None;
let mut github_entry = None;
if let Some(repo) = request["githubRepository"].as_str() {
let mut source = crate::github_plugins::source(repo)?;
let status = ipc::call(&socket, "status", json!({}))?;
let saved: Vec<crate::model::Source> = serde_json::from_value(status["database"]["sources"].clone())?;
if let Some(existing) = saved.iter().find(|s| request["source"].as_str() == Some(s.id.as_str())) {
ensure!(existing.enabled && crate::github_plugins::repository(&existing).is_some_and(|r| r.eq_ignore_ascii_case(repo)), "GitHub source is disabled or changed");
source = existing.clone();
} else {
ensure!(!saved.iter().any(|s| crate::github_plugins::repository(s).is_some_and(|r|r.eq_ignore_ascii_case(repo))), "GitHub source identity changed; refresh the plugin library");
ensure!(request["source"].as_str() == Some(source.id.as_str()), "GitHub source identity mismatch");
}
let proxy = serde_json::from_value(status["database"]["proxy"].clone())?;
let client = crate::github_plugins::Client::system(&proxy, &cancel, false)?;
let entry = client.entries(repo)?.into_iter().find(|entry|
request["pluginId"] == entry.id && request["version"] == entry.version
&& request["sha256"] == entry.sha256 && request["url"] == entry.url).context("GitHub release selection changed or is invalid")?;
request["allowHttp"] = json!(false);
github_source = Some(source); github_entry = Some(entry);
}
jobs.patch(&key, json!({"phase":"downloading"}));
let bytes = if let Some(staged) = staged {
staged
@@ -297,6 +319,10 @@ impl Jobs {
ensure!(cancel.check().is_ok(), "下载已取消");
jobs.patch(&key, json!({"phase":"verifying"}));
let manifest = bytes.manifest()?;
if let Some(entry) = &github_entry {
ensure!(entry.sha256 == bytes.hash, "GitHub release package SHA256 mismatch");
crate::github_plugins::check_package(entry, &manifest)?;
}
if let Some(expected) = request["pluginId"].as_str() {
ensure!(manifest.id == expected, "插件包 ID 与目录不一致");
}
@@ -324,12 +350,12 @@ impl Jobs {
.cloned()
.unwrap_or_else(|| json!({}));
jobs.patch(&key, json!({"phase":"resolving"}));
let batch = match crate::planner::prepare_staged(
let batch = match crate::planner::prepare_staged_sources(
&database,
bytes.clone(),
request["source"].as_str().map(str::to_owned),
choices,
cancel.clone(),
cancel.clone(), github_source.into_iter().collect(),
) {
Ok(batch) => {
info["plan"] = json!(batch.plan);
+21 -1
View File
@@ -6,6 +6,7 @@ mod desktop;
mod diagnostics;
mod file_browser;
mod gamepad;
mod github_plugins;
mod http;
mod ipc;
mod jobs;
@@ -33,7 +34,7 @@ use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD, Engine};
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use std::{fs, path::PathBuf};
use std::{fs, path::{Path, PathBuf}};
#[derive(Parser)]
#[command(version, about = "Framely plugin manager")]
@@ -170,9 +171,27 @@ enum Cmd {
#[arg(long)]
output: PathBuf,
},
/// Generate release.json from finalized plugin packages.
ReleaseJson {
#[arg(long)]
packages: PathBuf,
#[arg(long)]
output: PathBuf,
},
}
fn run() -> Result<()> {
match Cli::parse().command {
Cmd::ReleaseJson { packages, output } => {
let value = github_plugins::release_json(&packages)?;
let parent = output.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
fs::create_dir_all(parent)?;
use std::io::Write;
let mut file = tempfile::NamedTempFile::new_in(parent)?;
file.write_all(&serde_json::to_vec_pretty(&value)?)?;
file.as_file().sync_all()?;
file.persist(output)?;
Ok(())
}
Cmd::ApkSteam { app, token } => apk::steam_wrapper(&app, &token),
Cmd::ApkNative {
app,
@@ -369,6 +388,7 @@ fn run() -> Result<()> {
let v = package::verify(&bytes)?;
let publish = v.manifest.publish.clone().unwrap_or_default();
entries.push(model::CatalogEntry {
prerelease: !semver::Version::parse(&v.manifest.version).map(|v| v.pre.is_empty()).unwrap_or(true),
run_as: Some(v.manifest.run_as()),
dependencies: v.manifest.dependencies.clone(),
optional_dependencies: v.manifest.optional_dependencies.clone(),
+7
View File
@@ -198,6 +198,8 @@ impl Default for FeedbackSettings {
pub struct LauncherSettings {
#[serde(default = "yes")]
pub prioritize_ui: bool,
#[serde(default)]
pub system_radial_menu: bool,
#[serde(default = "yes")]
pub primary_trigger: bool,
#[serde(default = "yes")]
@@ -264,6 +266,7 @@ impl Default for LauncherSettings {
fn default() -> Self {
Self {
prioritize_ui: true,
system_radial_menu: false,
primary_trigger: true,
menu_auto_close: true,
menu_timeout_seconds: 10,
@@ -865,6 +868,8 @@ pub struct Database {
#[serde(default)]
pub launcher: LauncherSettings,
#[serde(default)]
pub system_radial_default_off_applied: bool,
#[serde(default)]
pub gaze_calibration: Option<GazeCalibration>,
#[serde(default)]
pub steam_favorites: std::collections::BTreeSet<u32>,
@@ -1043,6 +1048,8 @@ pub struct Catalog {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogEntry {
#[serde(default)]
pub prerelease: bool,
pub id: String,
pub name: String,
pub version: String,
+19 -2
View File
@@ -212,6 +212,7 @@ impl Resolver<'_> {
if let Some(e) = &manifest.engines {
e.check()?;
}
if crate::github_plugins::repository(&source).is_some() { crate::github_plugins::check_package(entry, &manifest)?; }
ensure!(
manifest.id == entry.id && manifest.version == entry.version,
"依赖包 ID 或版本与目录不一致"
@@ -311,19 +312,35 @@ pub fn prepare_staged(
source: Option<String>,
choices: BTreeMap<String, String>,
cancel: crate::jobs::Cancellation,
) -> Result<Prepared> {
prepare_staged_sources(db, bytes, source, choices, cancel, vec![])
}
pub fn prepare_staged_sources(
db: &Database, bytes: std::sync::Arc<crate::package::Staged>, source: Option<String>,
choices: BTreeMap<String, String>, cancel: crate::jobs::Cancellation, additional: Vec<Source>,
) -> Result<Prepared> {
cancel.check()?;
let mut sources = db.sources.clone();
for candidate in additional {
candidate.validate()?;
if let Some(old) = sources.iter().find(|s| s.id == candidate.id) {
ensure!(old.enabled && old.url == candidate.url, "GitHub plugin source is disabled or changed");
} else {
ensure!(sources.len() < 200, "插件源数量超过 200");
sources.push(candidate);
}
}
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), "未知安装来源");
ensure!(sources.iter().any(|s| &s.id == source && s.enabled), "未知安装来源");
}
let mut resolver = Resolver {
db,
sources: db.sources.clone(),
sources,
catalogs: BTreeMap::new(),
histories: Default::default(),
selected: BTreeMap::new(),
+6 -1
View File
@@ -159,6 +159,11 @@ impl Service {
fs::rename(root.join("state.json.tmp"), root.join("state.json"))?;
fs::remove_file(root.join("install-transaction.json"))?;
}
let radial_default_migrated = !db.system_radial_default_off_applied;
if radial_default_migrated {
db.launcher.system_radial_menu = false;
db.system_radial_default_off_applied = true;
}
let initialized_defaults = crate::subscriptions::initialize_defaults(&mut db)?;
crate::subscriptions::initialize(&mut db)?;
// Rehydrate presentation after a state-schema downgrade from immutable payloads.
@@ -228,7 +233,7 @@ impl Service {
visibility_pending: BTreeSet::new(),
launch_contexts: BTreeMap::new(),
};
if initialized_defaults {
if initialized_defaults || radial_default_migrated {
service.save()?;
}
Ok(service)
+546 -46
View File
@@ -13,6 +13,27 @@ use std::{
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
static APK_STORAGE_API_GATE: Mutex<()> = Mutex::new(());
fn apply_system_menu_binding(helper: &Path, enabled: bool) -> Result<bool> {
ensure!(helper.is_file(), "缺少 Steam 键绑定助手");
let mut command=Command::new("python3");
command.arg(helper);
if !enabled {command.arg("--restore");}
let status=command.status().context("无法更新 Steam 键绑定")?;
ensure!(status.success()||status.code()==Some(10),"Steam 键绑定更新失败:{status}");
Ok(status.code()==Some(10))
}
fn schedule_steamvr_restart() -> Result<()> {
let unit=format!("framely-system-menu-{}",cast_random_key());
let output=Command::new("systemd-run")
.args(["--user","--quiet","--collect","--on-active=2s"])
.arg(format!("--unit={unit}"))
.args(["/usr/bin/systemctl","--user","restart","steamvr.service"])
.output().context("无法安排 SteamVR 重启")?;
ensure!(output.status.success(),"无法安排 SteamVR 重启:{}",String::from_utf8_lossy(&output.stderr).trim());
Ok(())
}
struct FrameAccess {
plugin: String,
version: String,
@@ -36,8 +57,10 @@ struct Agent {
terminals: crate::terminal::Terminals,
files: Arc<crate::manager_files::Files>,
catalog_cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
casting: Mutex<crate::cast::runtime::Casting>,
casting: Arc<Mutex<crate::cast::runtime::Casting>>,
screenshot: Arc<Mutex<ScreenshotState>>,
recording_overlay_cache: Mutex<Value>,
radial_last: Mutex<String>,
watch_access: Mutex<crate::cast::watch_access::Access>,
receivers: Mutex<crate::cast::receiver::Receivers>,
}
@@ -46,6 +69,53 @@ struct EventLog {
sequence: u64,
entries: VecDeque<Value>,
}
#[derive(Default)]
struct ScreenshotState {
id: String,
open: bool,
closed: bool,
settings: Option<crate::cast::settings::Settings>,
geometry: Value,
preview: Option<Vec<u8>>,
preview_aspect: Option<f64>,
hotkey_active: bool,
hotkey_preview: Value,
hotkey_settings: Option<crate::cast::settings::Settings>,
hotkey_thumbnail: Option<Vec<u8>>,
hotkey_warm_geometry: Option<(crate::cast::settings::Settings, Value)>,
hotkey_warm_pending: Option<crate::cast::settings::Settings>,
hotkey_warm_capture: Option<(crate::cast::settings::Settings, crate::cast::screenshot::PreparedScreen)>,
hotkey_warm_capture_pending: Option<crate::cast::settings::Settings>,
}
impl ScreenshotState {
fn status(&self) -> Value { json!({"id":self.id,"open":self.open}) }
fn editor(&self) -> Result<Value> {
ensure!(self.open,"截图取景窗口已关闭");
Ok(json!({"id":self.id,"settings":self.settings,"geometry":self.geometry,"spatial":self.geometry.is_object(),"preview":if self.preview.is_some(){Some(format!("/cast/screenshot/preview/{}",self.id))}else{None},"previewAspect":self.preview_aspect}))
}
fn acknowledge(&mut self,id:&str) {if self.id==id {self.closed=true;self.open=false;self.preview=None;}}
}
fn capture_hotkey_image(
settings: &crate::cast::settings::Settings,
binaries: &Path,
panel: Option<&Path>,
state: &Arc<Mutex<ScreenshotState>>,
) -> Result<image::DynamicImage> {
let deadline=std::time::Instant::now()+Duration::from_millis(1400);
let prepared=loop {
let mut state=state.lock().unwrap();
if state.hotkey_warm_capture.as_ref().is_some_and(|(old,_)|old==settings) {
break state.hotkey_warm_capture.take().map(|(_,capture)|capture);
}
if state.hotkey_warm_capture_pending.as_ref()!=Some(settings)||std::time::Instant::now()>=deadline {break None;}
drop(state);
std::thread::sleep(Duration::from_millis(10));
};
if let Some(prepared)=prepared {
if let Ok(image)=prepared.capture() {return Ok(image);}
}
crate::cast::screenshot::capture(settings,binaries,panel,None)
}
impl EventLog {
fn append(&mut self, mut event: Value) {
self.sequence += 1;
@@ -76,7 +146,47 @@ fn push(q: &Mutex<VecDeque<Value>>, v: Value) {
}
q.push_back(v);
}
fn radial_record(casting: Arc<Mutex<crate::cast::runtime::Casting>>, socket: PathBuf) {
std::thread::spawn(move || {
let result=(||->Result<()> {
let mut cast=casting.lock().unwrap();
if cast.recording_status()["active"]==true {
cast.stop_recording()?;
} else {
let status=ipc::call(&socket,"status",json!({}))?;
ensure!(status["agreement"]["accepted"]==true,"请先同意用户协议和隐私声明");
let settings:crate::cast::settings::Settings=serde_json::from_value(status["database"]["casting"].clone())?;
let panel=if settings.source==crate::cast::settings::Source::Screen {
let response=ipc::call(&socket,"cast.panel.start",json!({}))?;
Some(PathBuf::from(response["socket"].as_str().context("Missing panel socket")?))
}else{None};
cast.record(&settings,panel.as_deref(),&crate::steam::home()?.join("Videos/framely"))?;
}
Ok(())
})();
if let Err(error)=result {
let _=ipc::call(&socket,"cast.notification",json!({"notification":{"id":format!("cast.radial.{}",cast_random_key()),"title":"录屏失败","body":format!("{error:#}"),"durationMs":5000,"actions":[]}}));
}
});
}
impl Agent {
fn send_page(&self, r: Request, remote: bool, guest: bool, network_cookie: Option<String>) -> Result<()> {
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 data = if guest {
String::from_utf8(data)?.replacen("<head>", "<head><meta name=\"framely-cast-guest\" content=\"true\">", 1).into_bytes()
} else { data };
let mut response = Response::from_data(data)
.with_header(header("Content-Type", "text/html; charset=utf-8"))
.with_header(header("Cache-Control", "no-store"))
.with_header(header("Referrer-Policy", "no-referrer"))
.with_header(header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
if let Some(cookie) = network_cookie { response = response.with_header(header("Set-Cookie", &cookie)); }
r.respond(response)?;
Ok(())
}
fn watch_cookie(r: &Request) -> Option<String> {
r.headers().iter().filter(|h| h.field.equiv("Cookie"))
.flat_map(|h| h.value.as_str().split(';'))
@@ -283,18 +393,18 @@ impl Agent {
&& r.method() == &Method::Get
&& (path == "/" || path == "/manager" || path == "/cast/watch")
{
r.respond(
Response::empty(StatusCode(302))
.with_header(header("Location", if path == "/cast/watch" { "/cast/watch" } else { "/manager" }))
.with_header(header(
"Set-Cookie",
&format!(
"framely-network={}; HttpOnly; SameSite=Strict; Path=/",
self.network_key.lock().unwrap()
),
)),
)?;
return Ok(());
// A cross-site redirect chain may withhold Strict cookies on
// every hop. Commit a page instead of redirecting to itself;
// subsequent same-site asset/API requests carry the cookie.
let cookie = format!("framely-network={}; HttpOnly; SameSite=Strict; Path=/", self.network_key.lock().unwrap());
if path == "/" {
r.respond(Response::empty(StatusCode(302))
.with_header(header("Location", "/manager"))
.with_header(header("Set-Cookie", &cookie))
.with_header(header("Cache-Control", "no-store")))?;
return Ok(());
}
return self.send_page(r, true, false, Some(cookie));
}
if path == "/setup" && r.method() == &Method::Post {
ensure!(
@@ -313,7 +423,7 @@ impl Agent {
match self.core("network.password.setup", json!({"password":fields.get("password"),"confirmPassword":fields.get("confirmPassword")})) {
Ok(_) => {
*self.network_key.lock().unwrap() = random_key();
r.respond(Response::empty(StatusCode(303)).with_header(header("Location", "/manager")))?;
r.respond(Response::empty(StatusCode(303)).with_header(header("Location", "/manager")).with_header(header("Cache-Control", "no-store")))?;
}
Err(error) => {
r.respond(Response::from_string(format!("{error}。请返回重试。"))
@@ -368,6 +478,7 @@ impl Agent {
if verified && key_before == *self.network_key.lock().unwrap() {
r.respond(
Response::empty(StatusCode(302))
.with_header(header("Cache-Control", "no-store"))
.with_header(header("Location", if destination { "/cast/watch" } else { "/manager" }))
.with_header(header(
"Set-Cookie",
@@ -387,7 +498,7 @@ impl Agent {
return Ok(());
}
if r.method() == &Method::Get && (path == "/" || path == "/manager" || path == "/cast/watch") {
r.respond(Response::from_string(if configured { if path == "/cast/watch" { NETWORK_LOGIN.replace("<form method=\"post\" action=\"/login\">", "<form method=\"post\" action=\"/login\"><input type=\"hidden\" name=\"next\" value=\"/cast/watch\">") } else { NETWORK_LOGIN.into() } } else { NETWORK_SETUP.into() }).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'")))?;
r.respond(Response::from_string(if configured { if path == "/cast/watch" { NETWORK_LOGIN.replace("<form method=\"post\" action=\"/login\">", "<form method=\"post\" action=\"/login\"><input type=\"hidden\" name=\"next\" value=\"/cast/watch\">") } else { NETWORK_LOGIN.into() } } else { NETWORK_SETUP.into() }).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Cache-Control","no-store")).with_header(header("Content-Security-Policy","default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'")))?;
} else {
r.respond(
Response::empty(StatusCode(401))
@@ -398,17 +509,21 @@ impl Agent {
}
if remote && r.method() == &Method::Get && path == "/" {
r.respond(
Response::empty(StatusCode(302)).with_header(header("Location", "/manager")),
Response::empty(StatusCode(302)).with_header(header("Location", "/manager")).with_header(header("Cache-Control", "no-store")),
)?;
return Ok(());
}
if path == "/host/poll" {
ensure!(!remote && self.native(&r), "Native authentication failed");
let mut hotkey_event=String::new();
let mut radial_action=String::new();
if r.method() == &Method::Post {
let mut bytes = Vec::new();
r.as_reader().take(32769).read_to_end(&mut bytes)?;
ensure!(bytes.len() <= 32768, "Visibility report too large");
let views: Value = serde_json::from_slice(&bytes)?;
hotkey_event=views["screenshotHotkey"].as_str().unwrap_or("").to_owned();
radial_action=views["radialAction"].as_str().unwrap_or("").to_owned();
// Empty acknowledgements are sent on ordinary native polls.
// Do not wait for the casting lock while describe/start is
// running: the native HTTP timeout is intentionally short.
@@ -418,6 +533,9 @@ impl Agent {
if let Some(id)=views["framingClosed"].as_str().filter(|id|!id.is_empty()) {
self.casting.lock().unwrap().region.acknowledge_editor_closed(id);
}
if let Some(id)=views["screenshotClosed"].as_str().filter(|id|!id.is_empty()) {
self.screenshot.lock().unwrap().acknowledge(id);
}
if views["views"].is_object() {
self.core("host.ui.visibility",views["views"].clone())?;
if let Some(ids)=views["closedCasts"].as_array(){ensure!(ids.len()<=32,"Too many closed casts");for id in ids{if let Some(id)=id.as_str(){let _=self.receivers.lock().unwrap().control(id,"stop",json!({}));}}}
@@ -425,6 +543,10 @@ impl Agent {
self.core("host.ui.visibility", views)?;
}
}
if radial_action.starts_with("record:") && *self.radial_last.lock().unwrap()!=radial_action {
*self.radial_last.lock().unwrap()=radial_action.clone();
radial_record(Arc::clone(&self.casting),self.socket.clone());
}
let mut queue = self.commands.lock().unwrap();
let mut commands = Vec::new();
let mut size = 0;
@@ -445,9 +567,149 @@ impl Agent {
.ok()
.map(|v| v["database"].clone())
.unwrap_or(json!({}));
if !hotkey_event.is_empty() {let event=hotkey_event.as_str();
match event {
"radialPrepare" => {
let settings:Result<crate::cast::settings::Settings,_>=serde_json::from_value(database["casting"].clone());
if let Ok(settings)=settings {
if settings.source==crate::cast::settings::Source::Screen {
let mut state=self.screenshot.lock().unwrap();
let ready=state.hotkey_warm_capture.as_ref().is_some_and(|(old,_)|old==&settings);
let pending=state.hotkey_warm_capture_pending.as_ref()==Some(&settings);
if !ready&&!pending {
state.hotkey_warm_capture=None;
state.hotkey_warm_capture_pending=Some(settings.clone());
drop(state);
let panel=self.core("cast.panel.start",json!({})).ok().and_then(|value|value["socket"].as_str().map(PathBuf::from));
let binaries=self.casting.lock().unwrap().binaries.clone();
let state=Arc::clone(&self.screenshot);
let capture_settings=settings.clone();
std::thread::spawn(move||{
let capture=panel.as_deref().and_then(|path|crate::cast::screenshot::PreparedScreen::start(&capture_settings,&binaries,path).ok());
let mut state=state.lock().unwrap();
if state.hotkey_warm_capture_pending.as_ref()==Some(&capture_settings) {
state.hotkey_warm_capture_pending=None;
state.hotkey_warm_capture=capture.map(|capture|(capture_settings,capture));
}
});
}
}
if crate::cast::region::supported(&settings) {
let mut state=self.screenshot.lock().unwrap();
let pending=state.hotkey_warm_pending.as_ref()==Some(&settings);
if !pending {
state.hotkey_warm_geometry=None;
state.hotkey_warm_pending=Some(settings.clone());
drop(state);
let state=Arc::clone(&self.screenshot);
let binaries=self.casting.lock().unwrap().binaries.clone();
std::thread::spawn(move||{
let mut region=crate::cast::region::Region::default();region.editing=true;
let geometry=region.refresh(&settings,&binaries,None).and_then(|_|region.editor());
let mut state=state.lock().unwrap();
if state.hotkey_warm_pending.as_ref()==Some(&settings) {
state.hotkey_warm_pending=None;
if let Ok(geometry)=geometry {
if state.hotkey_active&&state.hotkey_settings.as_ref()==Some(&settings) {
state.hotkey_preview=json!({"visible":true,"view":geometry["view"],"settings":settings});
}
state.hotkey_warm_geometry=Some((settings,geometry));
}
}
});
}
}
}
},
"radialCancel" => {
let mut state=self.screenshot.lock().unwrap();
state.hotkey_warm_capture_pending=None;
state.hotkey_warm_capture=None;
},
"radialPress" | "radialCapture" => {
let settings:Result<crate::cast::settings::Settings,_>=serde_json::from_value(database["casting"].clone());
if let Ok(settings)=settings {
let mut state=self.screenshot.lock().unwrap();
if !state.open&&!state.hotkey_active {
state.hotkey_active=true;state.hotkey_settings=Some(settings.clone());state.hotkey_preview=Value::Null;state.hotkey_thumbnail=None;
drop(state);
if crate::cast::region::supported(&settings) {
let warm={let state=self.screenshot.lock().unwrap();state.hotkey_warm_geometry.as_ref().filter(|(old,_)|old==&settings).map(|(_,geometry)|geometry.clone())};
let warm_pending={self.screenshot.lock().unwrap().hotkey_warm_pending.as_ref()==Some(&settings)};
if let Some(geometry)=warm {
let mut state=self.screenshot.lock().unwrap();
if state.hotkey_active {state.hotkey_preview=json!({"visible":true,"view":geometry["view"],"settings":settings});}
} else if !warm_pending {
let state=Arc::clone(&self.screenshot);
let binaries=self.casting.lock().unwrap().binaries.clone();
std::thread::spawn(move||{
let mut region=crate::cast::region::Region::default();region.editing=true;
if region.refresh(&settings,&binaries,None).is_ok() {
if let Ok(geometry)=region.editor() {
let mut state=state.lock().unwrap();
if state.hotkey_active {state.hotkey_preview=json!({"visible":true,"view":geometry["view"],"settings":settings});}
}
}
});
}
} else {
let state=Arc::clone(&self.screenshot);
let queue=Arc::clone(&self.commands);
let binaries=self.casting.lock().unwrap().binaries.clone();
let panel=if settings.source==crate::cast::settings::Source::Screen {self.core("cast.panel.start",json!({})).ok().and_then(|value|value["socket"].as_str().map(PathBuf::from))}else{None};
std::thread::spawn(move||{
if let Ok(image)=crate::cast::screenshot::capture(&settings,&binaries,panel.as_deref(),None) {
let mut bytes=std::io::Cursor::new(Vec::new());
if image.thumbnail(960,540).write_to(&mut bytes,image::ImageFormat::Png).is_ok() {
let mut state=state.lock().unwrap();
if state.hotkey_active {state.hotkey_thumbnail=Some(bytes.into_inner());queue.lock().unwrap().push_back(json!({"kind":"cast.screenshot.shortcutPreview.open","aspect":image.width() as f64/image.height() as f64}));}
}
}
});
}
}
}
},
"release"|"cancel" => {
let settings={let mut state=self.screenshot.lock().unwrap();state.hotkey_preview=Value::Null;state.hotkey_thumbnail=None;let active=state.hotkey_active;state.hotkey_active=false;if event=="cancel" {state.hotkey_warm_capture_pending=None;state.hotkey_warm_capture=None;}if active&&event=="release" {state.hotkey_settings.take()}else{state.hotkey_settings=None;None}};
self.commands.lock().unwrap().push_back(json!({"kind":"cast.screenshot.shortcutPreview.close"}));
if let Some(settings)=settings {
let binaries=self.casting.lock().unwrap().binaries.clone();
let control=self.socket.clone();
let queue=Arc::clone(&self.commands);
let state=Arc::clone(&self.screenshot);
let panel=if settings.source==crate::cast::settings::Source::Screen {self.core("cast.panel.start",json!({})).ok().and_then(|value|value["socket"].as_str().map(PathBuf::from))}else{None};
std::thread::spawn(move||{
let result=(||->Result<PathBuf>{let image=capture_hotkey_image(&settings,&binaries,panel.as_deref(),&state)?;crate::cast::screenshot::save(&image,&crate::steam::home()?.join("Pictures/framely"))})();
if result.is_ok(){push(&queue,json!({"kind":"cast.screenshot.sound"}));}
let (title,body)=match result {Ok(path)=>("截图已保存",path.display().to_string()),Err(error)=>("截图失败",format!("{error:#}"))};
let _=ipc::call(&control,"cast.notification",json!({"notification":{"id":format!("cast.screenshot.{}",cast_random_key()),"title":title,"body":body,"durationMs":5000,"actions":[]}}));
});
}
},
_=>{}
}
if event=="radialCapture" {
// Selection and release may happen before the next native poll.
let settings={let mut state=self.screenshot.lock().unwrap();state.hotkey_preview=Value::Null;state.hotkey_thumbnail=None;state.hotkey_active=false;state.hotkey_settings.take()};
if let Some(settings)=settings {
let binaries=self.casting.lock().unwrap().binaries.clone();
let control=self.socket.clone();
let queue=Arc::clone(&self.commands);
let state=Arc::clone(&self.screenshot);
let panel=if settings.source==crate::cast::settings::Source::Screen {self.core("cast.panel.start",json!({})).ok().and_then(|value|value["socket"].as_str().map(PathBuf::from))}else{None};
std::thread::spawn(move||{
let result=(||->Result<PathBuf>{let image=capture_hotkey_image(&settings,&binaries,panel.as_deref(),&state)?;crate::cast::screenshot::save(&image,&crate::steam::home()?.join("Pictures/framely"))})();
if result.is_ok(){push(&queue,json!({"kind":"cast.screenshot.sound"}));}
let (title,body)=match result {Ok(path)=>("截图已保存",path.display().to_string()),Err(error)=>("截图失败",format!("{error:#}"))};
let _=ipc::call(&control,"cast.notification",json!({"notification":{"id":format!("cast.screenshot.{}",cast_random_key()),"title":title,"body":body,"durationMs":5000,"actions":[]}}));
});
}
}
}
return send_json(
r,
{let (mut recording,region,framing)=if let Ok(cast)=self.casting.try_lock() {let status=cast.recording_status();*self.recording_overlay_cache.lock().unwrap()=status.clone();(status,cast.region_status(),cast.region.editor_state())}else{(self.recording_overlay_cache.lock().unwrap().clone(),json!({"visible":false}),Value::Null)};recording["indicatorVisible"]=json!(database["casting"]["recordingIndicatorVisible"].as_bool().unwrap_or(true));json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"],"recording":recording,"recordingRegion":region,"framingEditor":framing})},
{let (mut recording,region,framing)=if let Ok(cast)=self.casting.try_lock() {let status=cast.recording_status();*self.recording_overlay_cache.lock().unwrap()=status.clone();(status,cast.region_status(),cast.region.editor_state())}else{(self.recording_overlay_cache.lock().unwrap().clone(),json!({"visible":false}),Value::Null)};recording["indicatorVisible"]=json!(database["casting"]["recordingIndicatorVisible"].as_bool().unwrap_or(true));let screenshot=self.screenshot.lock().unwrap();json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"],"recording":recording,"recordingRegion":region,"framingEditor":framing,"screenshotEditor":screenshot.status(),"screenshotShortcutPreview":screenshot.hotkey_preview})},
);
}
if let Some(rel) = path.strip_prefix("/plugin-assets/") {
@@ -577,6 +839,7 @@ impl Agent {
if let Some(key) = path.strip_prefix("/boot/") {
ensure!(key == self.web_key, "Invalid boot token");
let response = Response::empty(StatusCode(302))
.with_header(header("Cache-Control", "no-store"))
.with_header(header(
"Set-Cookie",
&format!(
@@ -586,7 +849,11 @@ impl Agent {
))
.with_header(header(
"Location",
if r.url().ends_with("?view=manager") {
if r.url().ends_with("?view=radial") {
"/system/radial"
} else if r.url().ends_with("?view=radial-progress") {
"/system/radial/progress"
} else if r.url().ends_with("?view=manager") {
"/manager"
} else if r.url().ends_with("?view=notifications") {
"/notifications"
@@ -946,9 +1213,32 @@ impl Agent {
};
return send_json(r, v);
}
if let Some(id)=path.strip_prefix("/cast/screenshot/preview/") {
ensure!(r.method()==&Method::Get,"Invalid screenshot preview method");
let state=self.screenshot.lock().unwrap();
ensure!(state.open&&state.id==id,"截图预览已失效");
let bytes=state.preview.as_ref().context("截图预览不可用")?.clone();
r.respond(Response::from_data(bytes).with_header(header("Content-Type","image/png")).with_header(header("Cache-Control","no-store")))?;
return Ok(());
}
if path == "/cast/screenshot/shortcut/preview.png" {
let state=self.screenshot.lock().unwrap();
ensure!(state.hotkey_active,"截图预览已失效");
let bytes=state.hotkey_thumbnail.as_ref().context("截图预览不可用")?.clone();
r.respond(Response::from_data(bytes).with_header(header("Content-Type","image/png")).with_header(header("Cache-Control","no-store")))?;
return Ok(());
}
if path == "/cast/screenshot/shortcut" {
r.respond(Response::from_string("<!doctype html><html><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><style>html,body{margin:0;width:100%;height:100%;background:#080b0e}body{display:grid;place-items:center}img{display:block;max-width:100%;max-height:100%;object-fit:contain}</style><img src='/cast/screenshot/shortcut/preview.png' alt='截图区域'></html>")
.with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Cache-Control","no-store")))?;
return Ok(());
}
if path == "/"
|| path == "/system/radial"
|| path == "/system/radial/progress"
|| path == "/cast/watch"
|| path == "/cast/framing"
|| path == "/cast/screenshot"
|| path.starts_with("/cast/window/")
|| path == "/apk/permission"
|| path == "/manager"
@@ -956,22 +1246,7 @@ impl Agent {
|| 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 data = if guest { String::from_utf8(data)?.replacen("<head>", "<head><meta name=\"framely-cast-guest\" 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("Cache-Control","no-store")).with_header(header("Referrer-Policy","no-referrer")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
r.respond(response)?;
return Ok(());
return self.send_page(r, remote, guest, None);
}
r.respond(Response::from_string("Not found").with_status_code(404))?;
Ok(())
@@ -1073,6 +1348,85 @@ impl Agent {
if let Err(error)=self.core("cast.region.visible.save",json!({"enabled":enabled})) {let _=cast.refresh_region(&previous);return Err(error);}
Ok(json!({"enabled":enabled}))
},
"cast.screenshot.open" => {
let settings:crate::cast::settings::Settings=serde_json::from_value(self.core("status",json!({}))?["database"]["casting"].clone())?;
{let state=self.screenshot.lock().unwrap();ensure!(!state.open&&!state.hotkey_active,"请先完成当前截图");}
let id=cast_random_key();
let spatial=crate::cast::region::supported(&settings);
let (geometry,preview,preview_aspect)=if spatial {
let binaries=self.casting.lock().unwrap().binaries.clone();
let mut region=crate::cast::region::Region::default();
region.editing=true;region.editor_id=id.clone();region.refresh(&settings,&binaries,None)?;
(region.editor()?,None,None)
} else {
let panel=if settings.source==crate::cast::settings::Source::Screen {
Some(PathBuf::from(self.core("cast.panel.start",json!({}))?["socket"].as_str().context("Missing panel socket")?))
}else{None};
let binaries=self.casting.lock().unwrap().binaries.clone();
let image=crate::cast::screenshot::capture(&settings,&binaries,panel.as_deref(),None)?;
let mut bytes=std::io::Cursor::new(Vec::new());
image.thumbnail(960,960).write_to(&mut bytes,image::ImageFormat::Png)?;
(Value::Null,Some(bytes.into_inner()),Some(image.width() as f64/image.height() as f64))
};
let mut state=self.screenshot.lock().unwrap();
ensure!(!state.open&&!state.hotkey_active,"请先完成当前截图");
state.id=id.clone();state.open=true;state.closed=false;state.settings=Some(settings);state.geometry=geometry.clone();state.preview=preview;
state.preview_aspect=preview_aspect;
self.commands.lock().unwrap().push_back(json!({"kind":"cast.screenshot.open","id":id,"geometry":geometry,"spatial":spatial}));
state.editor()
},
"cast.screenshot.get" => self.screenshot.lock().unwrap().editor(),
"cast.screenshot.cancel" => {
let id=p["id"].as_str().context("Missing screenshot session")?;
let mut state=self.screenshot.lock().unwrap();
ensure!(state.open&&state.id==id,"截图取景窗口已关闭");
state.open=false;state.preview=None;
self.commands.lock().unwrap().push_back(json!({"kind":"cast.screenshot.close","id":id}));
Ok(json!(true))
},
"cast.screenshot.capture" => {
let id=p["id"].as_str().context("Missing screenshot session")?.to_owned();
let settings={
let mut state=self.screenshot.lock().unwrap();
ensure!(state.open&&state.id==id,"截图取景窗口已关闭");
let mut settings=state.settings.clone().context("Missing screenshot settings")?;
if crate::cast::region::supported(&settings) {
let aspect=p["aspect"].as_f64().context("Missing screenshot aspect")?;
ensure!(aspect.is_finite()&&(0.25..=4.).contains(&aspect),"截图比例无效");
settings.width=1920;
settings.height=(((1920.0/aspect).round() as u32).clamp(480,4096)+1)&!1;
settings.horizontal_fov=Some(p["horizontalFov"].as_f64().context("Missing screenshot FOV")?);
settings.center_x=p["centerX"].as_f64().context("Missing screenshot center")?;
settings.center_y=p["centerY"].as_f64().context("Missing screenshot center")?;
settings.validate()?;
}
state.open=false;state.preview=None;
settings
};
self.commands.lock().unwrap().push_back(json!({"kind":"cast.screenshot.close","id":id}));
let deadline=std::time::Instant::now()+Duration::from_secs(2);
loop {
if self.screenshot.lock().unwrap().closed {break;}
ensure!(std::time::Instant::now()<deadline,"截图窗口未能及时关闭");
std::thread::sleep(Duration::from_millis(20));
}
let crop=if crate::cast::region::supported(&settings){None}else{Some(serde_json::from_value(p["crop"].clone())?)};
let panel=if settings.source==crate::cast::settings::Source::Screen {
Some(PathBuf::from(self.core("cast.panel.start",json!({}))?["socket"].as_str().context("Missing panel socket")?))
}else{None};
let binaries=self.casting.lock().unwrap().binaries.clone();
let result=(||->Result<Value>{
let image=crate::cast::screenshot::capture(&settings,&binaries,panel.as_deref(),crop)?;
let path=crate::cast::screenshot::save(&image,&crate::steam::home()?.join("Pictures/framely"))?;
Ok(json!({"path":path,"width":image.width(),"height":image.height()}))
})();
if let Err(error)=&result {
let _=self.core("cast.notification",json!({"notification":{"id":format!("cast.screenshot.{}",cast_random_key()),"title":"截图失败","body":error.to_string(),"durationMs":5000,"actions":[]}}));
} else {
push(&self.commands,json!({"kind":"cast.screenshot.sound"}));
}
result
},
"cast.framing.open" => {
let settings:crate::cast::settings::Settings=serde_json::from_value(self.core("status",json!({}))?["database"]["casting"].clone())?;
ensure!(crate::cast::region::supported(&settings),"画幅调整仅支持屏幕采集的单眼画面");
@@ -1438,6 +1792,7 @@ impl Agent {
}
"catalog" => self.catalog(),
"catalog.start" => {
let refresh = p["refresh"].as_bool().unwrap_or(false);
let status = self.core("status", json!({}))?;
let sources: Vec<Source> =
serde_json::from_value(status["database"]["sources"].clone())?;
@@ -1446,11 +1801,11 @@ impl Agent {
let kind = format!(
"catalog:{}",
crate::package::digest(&serde_json::to_vec(
&json!({"sources":sources,"proxy":proxy})
&json!({"sources":sources,"proxy":proxy,"refresh":refresh})
)?)
);
self.jobs.task_progress(&kind, move |cancel, progress| {
fetch_catalogs_progress(sources, proxy, cache, &cancel, progress.as_ref())
fetch_library(sources, proxy, cache, &cancel, progress.as_ref(), refresh)
})
}
"catalog.versions.start" => {
@@ -1463,10 +1818,16 @@ impl Agent {
let status = self.core("status", json!({}))?;
let sources: Vec<Source> =
serde_json::from_value(status["database"]["sources"].clone())?;
ensure!(!sources.iter().any(|s| s.id == source_id && !s.enabled), "插件源不存在或已停用");
let source = sources
.into_iter()
.find(|s| s.id == source_id && s.enabled)
.context("插件源不存在或已停用")?;
.map(Ok).unwrap_or_else(|| {
let repo = p["githubRepository"].as_str().context("插件源不存在或已停用")?;
let source = crate::github_plugins::source(repo)?;
ensure!(source.id == source_id, "GitHub source identity mismatch");
Ok::<_, anyhow::Error>(source)
})?;
let proxy = self.proxy()?;
self.jobs.task(
&format!("versions:{}:{}", source.id, plugin_id),
@@ -1674,6 +2035,26 @@ impl Agent {
}
Ok(json!(true))
}
"launcher.settings.save" => {
let next: LauncherSettings=serde_json::from_value(p.clone())?;
next.validate()?;
let previous: LauncherSettings=serde_json::from_value(self.core("status",json!({}))?["database"]["launcher"].clone())?;
if next.system_radial_menu==previous.system_radial_menu {return self.core(method,p);}
let helper=self.assets.parent().unwrap_or(&self.assets).join("input/steam-system-menu.py");
let changed=apply_system_menu_binding(&helper,next.system_radial_menu)?;
if let Err(error)=self.core(method,p) {
if changed {let _=apply_system_menu_binding(&helper,previous.system_radial_menu);}
return Err(error);
}
if changed {
if let Err(error)=schedule_steamvr_restart() {
let _=self.core(method,serde_json::to_value(&previous)?);
let _=apply_system_menu_binding(&helper,previous.system_radial_menu);
return Err(error);
}
}
Ok(json!(true))
}
"status"
| "storage.plugin.status"
| "storage.plugin.start"
@@ -1695,7 +2076,6 @@ impl Agent {
| "plugin.launch"
| "plugin.launcher.action"
| "feedback.settings.save"
| "launcher.settings.save"
| "launcher.gaze.calibration.get"
| "launcher.gaze.calibration.save"
| "launcher.order.save"
@@ -1740,11 +2120,11 @@ impl Agent {
fn catalog(&self) -> Result<Value> {
let status = self.core("status", json!({}))?;
let sources: Vec<Source> = serde_json::from_value(status["database"]["sources"].clone())?;
Ok(fetch_catalogs_proxy(
fetch_library(
sources,
serde_json::from_value(status["database"]["proxy"].clone())?,
self.catalog_cache.clone(),
))
self.catalog_cache.clone(), &Default::default(), &|_| {}, false,
)
}
}
pub(crate) fn fetch_catalog_proxy_cancel(
@@ -1754,6 +2134,10 @@ pub(crate) fn fetch_catalog_proxy_cancel(
) -> Result<Catalog> {
cancel.check()?;
source.validate()?;
if let Some(repo) = crate::github_plugins::repository(source) {
let client = crate::github_plugins::Client::system(proxy, cancel, false)?;
return Ok(crate::github_plugins::catalog(&source.name, &client.entries(&repo)?));
}
if proxy.github_auto_fallback {
return crate::http::with_github_fallback(&source.url, &[], proxy, |route| fetch_catalog_proxy_cancel(source, route, cancel));
}
@@ -1794,6 +2178,10 @@ pub(crate) fn fetch_plugin_versions_cancel(
) -> Result<Vec<CatalogEntry>> {
source.validate()?;
valid_id(plugin_id)?;
if let Some(repo) = crate::github_plugins::repository(source) {
let client = crate::github_plugins::Client::system(proxy, cancel, false)?;
return Ok(client.entries(&repo)?.into_iter().filter(|p| p.id == plugin_id).collect());
}
let url = url::Url::parse(&source.url)?.join(&format!("plugins/{plugin_id}/versions.json"))?;
if proxy.github_auto_fallback {
return crate::http::with_github_fallback(url.as_str(), &[], proxy, |route| fetch_plugin_versions_cancel(source, plugin_id, route, cancel));
@@ -1877,6 +2265,7 @@ fn fetch_catalogs(
) -> Value {
fetch_catalogs_proxy(sources, Default::default(), cache)
}
#[cfg(test)]
fn fetch_catalogs_proxy(
sources: Vec<Source>,
proxy: crate::model::ProxySettings,
@@ -1893,6 +2282,49 @@ fn fetch_catalogs_cancel(
) -> Result<Value> {
fetch_catalogs_progress(sources, proxy, cache, cancel, &|_| {})
}
fn fetch_library(
sources: Vec<Source>, proxy: ProxySettings,
cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>, cancel: &crate::jobs::Cancellation,
progress: &(dyn Fn(Value) + Send + Sync), refresh: bool,
) -> Result<Value> {
let client = crate::github_plugins::Client::system(&proxy, cancel, refresh)?;
let (mut result, discovered) = std::thread::scope(|scope| {
let searching = scope.spawn(|| client.discover());
let normal = sources.iter().filter(|s| crate::github_plugins::repository(s).is_none()).cloned().collect();
let result = fetch_catalogs_progress(normal, proxy.clone(), cache.clone(), cancel, progress);
(result, searching.join().unwrap_or_else(|_| Err(anyhow::anyhow!("GitHub discovery failed"))))
});
cancel.check()?;
let mut repositories: Vec<_> = sources.iter().filter(|s| s.enabled && crate::github_plugins::repository(s).is_some()).cloned().collect();
match discovered {
Ok(discovered) => repositories.extend(discovered.into_iter().filter(|s| !sources.iter().any(|old|
old.id == s.id || crate::github_plugins::repository(old).is_some_and(|repo|
crate::github_plugins::repository(s).is_some_and(|new|repo.eq_ignore_ascii_case(&new)))))),
Err(error) => client.warnings.lock().unwrap().push(crate::http::github_fallback::diagnostic(&error)),
}
{
let discovered = repositories;
let values = result.as_mut().map_err(|e| anyhow::anyhow!("{e:#}"))?.as_array_mut().context("Invalid catalog results")?;
for chunk in discovered.chunks(6) {
cancel.check()?;
let batch = std::thread::scope(|scope| chunk.iter().map(|source| {
let client = &client;
scope.spawn(move || match client.entries(&crate::github_plugins::repository(source).unwrap()) {
Ok(entries) => json!({"source":source,"catalog":crate::github_plugins::catalog(&source.name,&entries),"versions":entries,"fetchedAt":crate::service::now_ms()}),
Err(error) => json!({"source":source,"error":crate::http::github_fallback::diagnostic(&error)}),
})
}).collect::<Vec<_>>().into_iter().map(|h| h.join().unwrap_or_else(|_| json!({"error":"GitHub release worker failed"}))).collect::<Vec<_>>());
values.extend(batch);
progress(json!({"completedSources":values.len(),"totalSources":sources.iter().filter(|s|s.enabled && crate::github_plugins::repository(s).is_none()).count()+discovered.len(),"partial":values}));
}
}
let warnings = client.warnings.lock().unwrap();
if !warnings.is_empty() {
result.as_mut().map_err(|e| anyhow::anyhow!("{e:#}"))?.as_array_mut().unwrap().push(json!({"source":{"id":"github-discovery","name":"GitHub","url":"https://github.com/topics/framely-plugin","enabled":true,"allowHttp":false},
"warning":warnings.iter().take(20).cloned().collect::<Vec<_>>().join("\n")}));
}
result
}
fn fetch_catalogs_progress(
sources: Vec<Source>,
proxy: crate::model::ProxySettings,
@@ -2097,6 +2529,7 @@ pub fn serve(
.context("Missing HTTP address")?
.port();
let origin = format!("http://127.0.0.1:{port}");
let system_menu_binding = assets.parent().unwrap_or(&assets).join("input/steam-system-menu.py");
let web_key = random_key();
let native_key = random_key();
let runtime = std::env::var_os("XDG_RUNTIME_DIR")
@@ -2111,8 +2544,10 @@ pub fn serve(
token: ipc::call(&socket, "cast.watchAccess.get", json!({}))?["token"].as_str().map(str::to_owned),
..Default::default()
}),
casting: Mutex::new(crate::cast::runtime::Casting::new(runtime.join("cast"), crate::cast::runtime::binary_directory(&assets))),
recording_overlay_cache: Mutex::new(json!({"active":false})),
casting: Arc::new(Mutex::new(crate::cast::runtime::Casting::new(runtime.join("cast"), crate::cast::runtime::binary_directory(&assets)))),
screenshot: Arc::default(),
recording_overlay_cache: Mutex::new(json!({"active":false})),
radial_last: Mutex::new(String::new()),
receivers: Mutex::new(crate::cast::receiver::Receivers::new(runtime.join("receive"), crate::cast::runtime::binary_directory(&assets))),
socket,
state,
@@ -2245,8 +2680,24 @@ pub fn serve(
if !http_only {
let run = runtime.clone();
let url = format!("{origin}/boot/{web_key}");
let binding_socket=agent.socket.clone();
std::thread::spawn(move || loop {
wait_for_steam_session();
let mut system_menu_ready = false;
if system_menu_binding.exists() {
let enabled=ipc::call(&binding_socket,"status",json!({})).ok()
.and_then(|status|status["database"]["launcher"]["systemRadialMenu"].as_bool()).unwrap_or(false);
match apply_system_menu_binding(&system_menu_binding,enabled) {
Ok(true) => {
if Command::new("systemctl").args(["--user", "restart", "steamvr.service"]).status().is_ok_and(|s|s.success()) {
std::thread::sleep(Duration::from_secs(2));
continue;
}
}
Ok(false) => system_menu_ready = enabled,
Err(error) => eprintln!("SteamVR system menu binding failed: {error:#}"),
}
}
let result = Command::new(&native)
.arg("--url")
.arg(&url)
@@ -2263,6 +2714,7 @@ pub fn serve(
"--change-stack-guard-on-fork=disable",
])
.env("FRAMELY_NATIVE_TOKEN", &native_key)
.env("FRAMELY_STEAM_MENU_ENABLED", if system_menu_ready {"1"} else {"0"})
.env("FRAMELY_STEAM_ROOT", {
let home = PathBuf::from(std::env::var_os("HOME").unwrap_or_default());
let steam = home.join(".local/share/Steam");
@@ -2283,7 +2735,9 @@ pub fn serve(
for r in server.incoming_requests() {
let a = agent.clone();
std::thread::spawn(move || {
let _ = a.handle(r);
if let Err(error) = a.handle(r) {
eprintln!("UI request failed: {error:#}");
}
});
}
Ok(())
@@ -2646,8 +3100,10 @@ mod tests {
fn agent(server: &Server, root: &Path) -> Arc<Agent> {
Arc::new(Agent {
watch_access: Mutex::default(),
casting: Mutex::new(crate::cast::runtime::Casting::new(root.join("cast"), root.join("media"))),
recording_overlay_cache: Mutex::new(json!({"active":false})),
casting: Arc::new(Mutex::new(crate::cast::runtime::Casting::new(root.join("cast"), root.join("media")))),
screenshot: Arc::default(),
recording_overlay_cache: Mutex::new(json!({"active":false})),
radial_last: Mutex::new(String::new()),
receivers: Mutex::new(crate::cast::receiver::Receivers::new(root.join("receive"), root.join("media"))),
socket: root.join("control.sock"),
state: root.join("state"),
@@ -3456,6 +3912,50 @@ mod tests {
.set("Origin", "http://other.example")
.send_json(json!({"method":"ui.events"}))
.is_err());
agent.core("agreement.accept", json!({"version":crate::model::AGREEMENT_VERSION,"userAgreement":true,"privacyStatement":true})).unwrap();
agent.core("network.save", json!({"enabled":true,"port":15915,"passwordEnabled":false})).unwrap();
// Reproduce a cross-site navigation whose redirect chain never sends
// Strict cookies. Each direct landing must terminate without a hop.
for path in ["/manager", "/cast/watch"] {
for _ in 0..3 {
let page = client.get(&format!("{origin}{path}"))
.set("Sec-Fetch-Site", "cross-site")
.set("Sec-Fetch-Mode", "navigate")
.call().unwrap();
assert_eq!(page.status(), 200);
assert!(page.header("Location").is_none());
assert_eq!(page.header("Cache-Control"), Some("no-store"));
assert_eq!(page.header("Referrer-Policy"), Some("no-referrer"));
let grant = page.header("Set-Cookie").unwrap();
assert!(grant.contains("HttpOnly") && grant.contains("SameSite=Strict") && grant.contains("Path=/"));
let html = page.into_string().unwrap();
assert!(html.contains("framely-remote"));
assert!(!html.contains("framely-cast-guest"));
}
}
let landing = client.get(&origin).call().unwrap();
assert_eq!(landing.status(), 302);
assert_eq!(landing.header("Location"), Some("/manager"));
assert_eq!(landing.header("Cache-Control"), Some("no-store"));
// Only the landing grants a cookie; it does not authorize other
// cookie-less routes or bypass same-origin checks on later requests.
for path in ["/api", "/manager-api/files"] {
assert!(matches!(client.post(&format!("{origin}{path}"))
.set("Origin", &origin).send_json(json!({"method":"ui.events"})),
Err(ureq::Error::Status(401, _))));
}
assert!(client.post(&format!("{origin}/api"))
.set("Cookie", cookie).set("Origin", "http://other.example")
.send_json(json!({"method":"ui.events"})).is_err());
assert!(client.post(&format!("{origin}/api"))
.set("Cookie", cookie).set("Origin", &origin)
.send_json(json!({"method":"ui.events"})).is_ok());
agent.core("network.save", json!({"enabled":true,"port":15915,"passwordEnabled":true})).unwrap();
let login = client.get(&format!("{origin}/manager")).call().unwrap();
assert_eq!(login.status(), 200);
assert!(login.header("Set-Cookie").is_none());
assert_eq!(login.header("Cache-Control"), Some("no-store"));
assert!(login.into_string().unwrap().contains("action=\"/login\""));
stop.store(true, Ordering::Relaxed);
worker.join().unwrap();
core_worker.join().unwrap();
+24
View File
@@ -2165,6 +2165,30 @@ fn launcher_trigger_defaults_preserve_explicit_preferences() {
assert!(!restored.primary_trigger);
}
#[test]
fn radial_menu_defaults_off_and_legacy_setting_migrates_once() {
assert!(!LauncherSettings::default().system_radial_menu);
let missing: LauncherSettings = serde_json::from_value(json!({})).unwrap();
assert!(!missing.system_radial_menu);
let root = tempfile::tempdir().unwrap();
let state = root.path().join("state");
std::fs::create_dir_all(&state).unwrap();
std::fs::write(
state.join("state.json"),
serde_json::to_vec(&json!({"plugins":{},"sources":[],"safeMode":false,"launcher":{"systemRadialMenu":true}})).unwrap(),
).unwrap();
let uid = unsafe { libc::geteuid() };
let mut core = Service::load(&state, uid).unwrap();
assert!(!core.db.launcher.system_radial_menu);
assert!(core.db.system_radial_default_off_applied);
core.handle("agreement.accept", json!({"version":AGREEMENT_VERSION,"userAgreement":true,"privacyStatement":true})).unwrap();
core.handle("launcher.settings.save", json!({"systemRadialMenu":true})).unwrap();
let loaded = Service::load(&state, uid).unwrap();
assert!(loaded.db.launcher.system_radial_menu);
}
#[test]
fn launcher_gaze_calibration_validates_persists_and_clears() {
let root = tempfile::tempdir().unwrap();
+2
View File
@@ -34,6 +34,8 @@ The template and bundled SDK use `AGPL-3.0-only`. See Framely's `docs/developer-
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.
For automatic GitHub discovery, add topic `framely-plugin` and upload `release.json` alongside each Release's final packages and images. Generate it with `framely release-json --packages ./release --output ./release/release.json`; include all generated files in your publishing workflow. No npm publication or catalog is needed. See Framely's `docs/github-plugin-discovery.md` for metadata and old-release compatibility.
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`,详见中英双语启动台开发文档。
+2
View File
@@ -33,3 +33,5 @@ cargo run --manifest-path "$FRAMELY_REPO/Cargo.toml" --locked -- verify my-plugi
模板及随附 SDK 使用 `AGPL-3.0-only`。完整开发到发布说明见 Framely 的 `docs/zh-CN/developer-guide/README.md`;模板的生命周期回调不会删除用户数据。
用于社区 GitHub Release 登记时,源码可省略 `downloadUrl`,默认附件名为 `<id>-<version>.framely`。发布包要用包含自动生成地址的临时清单打包;完整命令、Release digest 校验、自定义地址和数据库窗口尺寸校验规则见 Framely 的 `docs/zh-CN/developer-guide/publishing.md`。上面的直接打包命令用于本体测试,不代表社区登记已经通过。
需要 GitHub 自动发现时,为仓库添加 `framely-plugin` topic,每个 Release 上传最终包及 `release.json`。使用 `framely release-json --packages ./release --output ./release/release.json` 自动生成元数据和发布图片,将发行目录内所有文件加入自己的发布流程;无需 npm 发布或目录文件。格式及旧 Release 兼容见 Framely 的 `docs/zh-CN/github-plugin-discovery.md`。
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {Store,catalogChoices,githubRepository,Entry,Source,CatalogResult} from '../ui/src/store';
import {configureLanguage} from '../ui/src/i18n';
import '../ui/src/style.css';
configureLanguage('zh-CN',[]);
const source:Source={id:'community',name:'社区插件源',url:'https://example.org/catalog.json',enabled:true,allowHttp:false};
const github:Source={id:'github-fixture',name:'example/plugin',url:'https://github.com/example/plugin',enabled:true,allowHttp:false};
const plugin=(version:string,sha='a',compatible=true):Entry=>({id:'example.test',name:'示例插件',author:'Example',description:'从 GitHub Release 自动发现,与现有插件库合并显示。',version,apiVersion:1,compatible,url:`https://github.com/example/plugin/releases/download/v${version}/plugin.framely`,sha256:sha.repeat(64),runAs:'steamos'});
const versions=[plugin('2.0.0','d',false),plugin('1.1.0-preview.10','b'),plugin('1.1.0-preview.10','c'),plugin('1.0.1','e')];
const data:CatalogResult[]=[{source,catalog:{name:source.name,plugins:[plugin('1.0.0')]}},{source:github,catalog:{name:github.name,plugins:[versions[1]]},versions}];
const requests:any[]=[],inspected:any[]=[];
window.fetch=async(_url,init)=>{
const {method,params}=JSON.parse(String(init?.body));requests.push({method,params});let result:any=true;
if(method==='catalog.start')result={job:'library'};
if(method==='catalog.versions.start')result={job:`history-${params.source}`};
if(method==='job.status')result=params.job==='library'?{id:params.job,phase:'done',result:data}:params.job==='history-community'?{id:params.job,phase:'failed',error:'历史版本请求失败'}:{id:params.job,phase:'done',result:versions};
return new Response(JSON.stringify({result}));
};
const root=createRoot(document.getElementById('root')!);
root.render(<div className="app manager"><header className="shell-header"><strong>framely</strong></header><div className="workspace"><nav><button>已安装</button><button className="active">插件库</button><button>插件源</button></nav><main className="content"><Store sources={[source]} plugins={{'example.test':{source:source.id,manifest:{version:'1.0.0'}}}} busy={false} onSources={()=>{}} onInspect={async request=>{inspected.push(request);}}/></main></div></div>);
const wait=(ms=50)=>new Promise(resolve=>setTimeout(resolve,ms));
async function until(test:()=>any){for(let i=0;i<100;i++){if(test())return;await wait();}throw Error('Timed out: '+document.body.innerText.slice(0,250));}
const click=(selector:string)=>{const button=document.querySelector<HTMLButtonElement>(selector);if(!button)throw Error('Missing '+selector);button.click();};
async function select(label:string,option:string){click(`[aria-label="${label}"]`);await until(()=>!!document.querySelector('[role=option]'));const button=[...document.querySelectorAll<HTMLButtonElement>('[role=option]')].find(b=>b.textContent?.includes(option));if(!button)throw Error('Missing option '+option);button.click();await wait();}
(window as any).runInstallReviewChecks=async()=>{try{
if(catalogChoices(data).length!==5)throw Error('Different hashes were deduplicated');
if(catalogChoices([...data,{source:{...github,id:'duplicate'},catalog:{name:'Duplicate',plugins:[versions[1]]}}]).length!==5)throw Error('Identical package was duplicated');
if(catalogChoices(data,'all','stable')[1].entry.version!=='1.0.1')throw Error('Stable versions were lost');
if(githubRepository({...github,url:'https://github.com.evil/example/plugin'}))throw Error('Similar host accepted');
await until(()=>document.querySelectorAll('.store-card').length===1);
if(document.querySelector('.store-version')?.textContent!=='1.1.0-preview.10')throw Error('Highest compatible version not recommended');
await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_GITHUB_STORE');
await select('版本通道','仅正式版');
if(document.querySelector('.store-version')?.textContent!=='1.0.1')throw Error('Stable filter incorrect');
await select('版本通道','所有版本');click('.store-title');
await until(()=>document.body.textContent?.includes('历史版本暂时不可用'));
click('[aria-label="可选安装版本"]');await wait();
const options=[...document.querySelectorAll<HTMLElement>('[role=option]')];
const duplicates=options.filter(o=>o.textContent?.includes('1.1.0-preview.10'));
if(duplicates.length!==2||duplicates[0].textContent===duplicates[1].textContent)throw Error('Same-version hash choices are ambiguous');
const menu=document.querySelector<HTMLElement>('[role=listbox]')!;menu.scrollIntoView({block:'nearest'});await wait();
const content=document.querySelector<HTMLElement>('.content')!;
if(content.scrollWidth>content.clientWidth+1||menu.scrollWidth>menu.clientWidth+1)throw Error('Version menu overflows horizontally');
await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_GITHUB_STORE_VERSIONS');
options.find(o=>o.textContent?.startsWith('1.0.1'))!.click();await wait();click('.detail-heading button');
if(inspected[0]?.githubRepository!=='example/plugin'||inspected[0]?.sha256!=='e'.repeat(64))throw Error('Install origin/hash not preserved');
click('.back-link');await wait();
const refresh=[...document.querySelectorAll<HTMLButtonElement>('.list-heading button')].find(b=>b.textContent==='刷新')!;refresh.click();
await until(()=>requests.some(r=>r.method==='catalog.start'&&r.params.refresh===true));
if(requests.filter(r=>r.method==='catalog.versions.start').some(r=>r.params.source===github.id&&r.params.githubRepository!=='example/plugin'))throw Error('History origin missing');
if(document.documentElement.scrollWidth>innerWidth+1)throw Error('Page overflows viewport');
console.log('FRAMELY_BRIDGE_PASS');
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}};
until(()=>!!document.querySelector('.store-card')).then(()=>console.log('FRAMELY_VIEW_READY'));
+31
View File
@@ -0,0 +1,31 @@
#include "system_radial.h"
#include <cassert>
#include <iostream>
int main(){
using Clock=SystemRadial::Clock;
auto start=Clock::now();
SystemRadial menu;
assert(menu.update(0,true,true,0,0,start)==SystemRadialEvent::Begin);
assert(menu.update(0,true,false,0,0,start+std::chrono::milliseconds(120))==SystemRadialEvent::Cancel);
assert(menu.update(1,true,true,0,0,start+std::chrono::seconds(2))==SystemRadialEvent::Begin);
assert(menu.update(1,true,true,0,0,start+std::chrono::seconds(3))==SystemRadialEvent::Open);
assert(menu.update(1,true,true,0,.15f,start+std::chrono::milliseconds(3100))==SystemRadialEvent::Change);
assert(menu.direction==SystemRadialDirection::Up);
assert(menu.update(1,true,false,0,.15f,start+std::chrono::milliseconds(3200))==SystemRadialEvent::Commit);
assert(SystemRadial::select(.2f,0)==SystemRadialDirection::Right);
assert(SystemRadial::select(-.2f,0)==SystemRadialDirection::Left);
assert(SystemRadial::select(0,-.2f)==SystemRadialDirection::Down);
assert(SystemRadial::select(.03f,.01f)==SystemRadialDirection::Center);
assert(SystemRadial::hit(475,319)==SystemRadialDirection::Right);
assert(SystemRadial::hit(599,401)==SystemRadialDirection::Right);
assert(SystemRadial::hit(360,183)==SystemRadialDirection::Up);
assert(SystemRadial::hit(183,360)==SystemRadialDirection::Left);
assert(SystemRadial::hit(360,537)==SystemRadialDirection::Down);
assert(!SystemRadial::hit(470,315));
SystemRadial pointer;
assert(pointer.update(0,true,true,0,0,start)==SystemRadialEvent::Begin);
assert(pointer.update(0,true,true,0,0,start+std::chrono::seconds(1))==SystemRadialEvent::Open);
assert(pointer.update(0,true,true,0,0,start+std::chrono::milliseconds(1050),SystemRadial::hit(475,319))==SystemRadialEvent::Change);
assert(pointer.direction==SystemRadialDirection::Right);
std::cout<<"system radial: passed\n";
}
+51
View File
@@ -7,6 +7,8 @@ import pathlib
import tarfile
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import mock_open, patch
spec = importlib.util.spec_from_file_location('bootstrap', pathlib.Path(__file__).parents[1] / 'tools/bootstrap.py')
bootstrap = importlib.util.module_from_spec(spec)
@@ -14,6 +16,55 @@ spec.loader.exec_module(bootstrap)
class BootstrapTests(unittest.TestCase):
def test_explicit_install_and_update_skip_confirmation(self):
with tempfile.TemporaryDirectory() as tmp:
archive = pathlib.Path(tmp) / 'package.tar.gz'
checksums = pathlib.Path(tmp) / 'SHA256SUMS'
archive.touch()
checksums.touch()
with patch.object(bootstrap.os, 'uname', return_value=SimpleNamespace(machine='aarch64')), \
patch.object(bootstrap.os, 'geteuid', return_value=0), \
patch.object(bootstrap, 'confirm', side_effect=AssertionError('unexpected prompt')), \
patch.object(bootstrap, 'verify_archive'), \
patch.object(bootstrap, 'root_operation') as operation:
for action in ('install', 'update'):
bootstrap.main([action, '--archive', str(archive), '--checksums', str(checksums)])
self.assertEqual(operation.call_count, 2)
def test_github_proxy_option_reaches_engine_downloads(self):
with tempfile.TemporaryDirectory() as tmp:
archive = pathlib.Path(tmp) / 'package.tar.gz'
checksums = pathlib.Path(tmp) / 'SHA256SUMS'
archive.touch()
checksums.touch()
with patch.dict(bootstrap.os.environ, {}, clear=True), \
patch.object(bootstrap.os, 'uname', return_value=SimpleNamespace(machine='aarch64')), \
patch.object(bootstrap.os, 'geteuid', return_value=0), \
patch.object(bootstrap, 'verify_archive'), \
patch.object(bootstrap, 'root_operation'):
bootstrap.main(['install', '--archive', str(archive), '--checksums', str(checksums),
'--github-proxy', 'https://gh-proxy.com/'])
self.assertEqual(bootstrap.configured_github_proxy(), 'https://gh-proxy.com')
def test_explicit_uninstall_still_requires_confirmation(self):
with patch.object(bootstrap.os, 'uname', return_value=SimpleNamespace(machine='aarch64')), \
patch.object(bootstrap, 'confirm', return_value=False) as confirmation:
with self.assertRaisesRegex(ValueError, 'Cancelled'):
bootstrap.main(['uninstall'])
confirmation.assert_called_once()
def test_confirmation_distinguishes_no_terminal_from_decline(self):
with patch('builtins.open', side_effect=OSError('no tty')):
with self.assertRaisesRegex(ValueError, 'No interactive terminal.*--yes'):
bootstrap.confirm('install')
with patch('builtins.open', mock_open(read_data='')):
with self.assertRaisesRegex(ValueError, 'No confirmation input.*--yes'):
bootstrap.confirm('install')
with patch('builtins.open', mock_open(read_data='n\n')):
self.assertFalse(bootstrap.confirm('install'))
with patch('builtins.open', mock_open(read_data='Y\n')):
self.assertTrue(bootstrap.confirm('install'))
def test_external_checksums_match_exact_name_and_reject_tampering(self):
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
+17
View File
@@ -21,6 +21,23 @@ spec.loader.exec_module(network)
class FallbackTests(unittest.TestCase):
def test_explicit_proxy_routes_github_requests_without_second_proxy(self):
class Opener:
def open(self, request, timeout):
return request.full_url
with patch.dict(os.environ, {'FRAMELY_GITHUB_PROXY':'https://gh-proxy.com/'}, clear=False), \
patch.object(network.urllib.request, 'build_opener', return_value=Opener()):
self.assertEqual(network.fetch_once('https://api.github.com/repos/a/b/releases/latest'),
'https://gh-proxy.com/https://api.github.com/repos/a/b/releases/latest')
self.assertEqual(network.fetch_once('https://example.org/file'), 'https://example.org/file')
calls = []
with self.assertRaises(ConnectionResetError):
network.github_attempt('https://github.com/a', lambda fallback: calls.append(fallback) or (_ for _ in ()).throw(ConnectionResetError()))
self.assertEqual(calls, [False])
for invalid in ('http://gh-proxy.com', 'https://gh-proxy.com/../bad', 'https://gh-proxy.com?x=1'):
with self.assertRaises(ValueError):
network.github_proxy_url(invalid)
def test_unreachable_network_retries_once_but_local_io_errors_do_not(self):
url = 'https://github.com/a/b/releases/download/v1/archive'
for code in (errno.ENETDOWN, errno.ENETUNREACH, errno.EHOSTUNREACH):
+15 -1
View File
@@ -27,6 +27,19 @@ class InstallEntryTests(unittest.TestCase):
self.assertNotEqual(result.returncode, 0)
self.assertEqual(calls, {})
def test_explicit_github_proxy_covers_bootstrap_and_engine(self):
args = ['install', '--github-proxy', 'https://gh-proxy.com/']
result, calls = self.run_entry(args)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(calls['url'], 'https://gh-proxy.com/https://github.com/TestOwner/framely/releases/latest/download/bootstrap.py')
self.assertEqual(calls['proxy'], 'https://gh-proxy.com')
self.assertEqual(calls['args'], ['--repo', 'TestOwner/framely', *args])
for invalid in ['http://gh-proxy.com', 'https://gh-proxy.com/../bad', 'https://gh-proxy.com?key=1', '']:
with self.subTest(invalid=invalid):
result, calls = self.run_entry(['install', '--github-proxy', invalid])
self.assertNotEqual(result.returncode, 0)
self.assertEqual(calls, {})
def run_entry(self, args):
with tempfile.TemporaryDirectory() as temp:
root = pathlib.Path(temp)
@@ -35,12 +48,13 @@ class InstallEntryTests(unittest.TestCase):
curl.write_text('''#!/usr/bin/env python3
import json, os, pathlib, sys
args = sys.argv[1:]
pathlib.Path(os.environ['FRAMELY_ENTRY_LOG']).write_text(json.dumps({'url': args[-1]}))
pathlib.Path(os.environ['FRAMELY_ENTRY_LOG']).write_text(json.dumps({'url': args[-1], 'proxy': os.environ.get('FRAMELY_GITHUB_PROXY', '')}))
pathlib.Path(args[args.index('--output') + 1]).write_text("import json,os,pathlib,sys; p=pathlib.Path(os.environ['FRAMELY_ENTRY_LOG']); d=json.loads(p.read_text()); d['args']=sys.argv[1:]; p.write_text(json.dumps(d))")
''')
curl.chmod(0o755)
env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'],
FRAMELY_REPOSITORY='TestOwner/framely', FRAMELY_ENTRY_LOG=str(log))
env.pop('FRAMELY_GITHUB_PROXY', None)
result = subprocess.run(['bash', str(pathlib.Path(__file__).parents[1] / 'install.sh'), *args],
env=env, text=True, capture_output=True, timeout=10)
return result, json.loads(log.read_text()) if log.exists() else {}
+37
View File
@@ -0,0 +1,37 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location(
"steam_system_menu",
Path(__file__).parents[1] / "native/input/steam-system-menu.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class BindingTests(unittest.TestCase):
def test_removes_only_system_long_recenter(self):
binding = {
"bindings": {
"/actions/system": {"sources": [{
"path": "/user/hand/left/input/system",
"inputs": {"click": {"output": "dashboard"}, "double": {"output": "room"}},
}]},
"/actions/quickrecenter": {"sources": [
{"path": "/user/hand/left/input/system", "inputs": {"long": {"output": "recenter"}}},
{"path": "/user/hand/right/input/system", "inputs": {"long": {"output": "recenter"}}},
{"path": "/user/hand/left/input/grip", "inputs": {"long": {"output": "recenter"}}},
]},
}
}
patched, changed = module.without_recenter(binding)
self.assertTrue(changed)
self.assertEqual(len(patched["bindings"]["/actions/quickrecenter"]["sources"]), 1)
self.assertEqual(patched["bindings"]["/actions/system"], binding["bindings"]["/actions/system"])
self.assertEqual(len(binding["bindings"]["/actions/quickrecenter"]["sources"]), 3)
if __name__ == "__main__":
unittest.main()
+6 -6
View File
@@ -14,11 +14,11 @@ static int loads=0, writes=0, streams=0, cleared=0;
extern "C" {
bool SDL_InitSubSystem(uint32_t flags){assert(flags==0x10);return true;}
void SDL_QuitSubSystem(uint32_t flags){assert(flags==0x10);assert(streams==0);}
bool SDL_LoadWAV(const char* path,void* spec,uint8_t** data,uint32_t* len){assert(strstr(path,"/steamui/sounds/deck_ui_typing.wav"));++loads;*data=(uint8_t*)malloc(4);*len=4;return true;}
bool SDL_LoadWAV(const char* path,void* spec,uint8_t** data,uint32_t* len){assert(strstr(path,"/steamui/sounds/deck_ui_typing.wav")||strstr(path,"/steamui/sounds/camera1.wav"));++loads;*data=(uint8_t*)malloc(4);*len=4;return true;}
void* SDL_OpenAudioDeviceStream(uint32_t id,const void*,void* callback,void* userdata){assert(id==0xffffffffu&&!callback&&!userdata);++streams;return malloc(1);}
bool SDL_SetAudioStreamGain(void*,float gain){assert(gain==.30f||gain==.45f);return true;}
bool SDL_SetAudioStreamGain(void*,float gain){assert(gain==.30f||gain==.45f||gain==.55f);return true;}
bool SDL_ResumeAudioStreamDevice(void*){return true;}
bool SDL_PutAudioStreamData(void*,const void*,int len){assert(loads==2&&len==4);++writes;return true;}
bool SDL_PutAudioStreamData(void*,const void*,int len){assert(loads==3&&len==4);++writes;return true;}
bool SDL_ClearAudioStream(void*){++cleared;return true;}
void SDL_DestroyAudioStream(void* stream){--streams;free(stream);}
void SDL_free(void* data){free(data);}
@@ -36,9 +36,9 @@ int main(int argc,char** argv){
assert(sounds.prepare(argv[1]));
auto lib=dlopen((std::string(argv[1])+"/steamrtarm64/libSDL3.so.0").c_str(),RTLD_NOW);
auto counts=reinterpret_cast<int(*)(int)>(dlsym(lib,"counts"));
sounds.play();sounds.play(true);sounds.silence();
assert(counts(0)==2&&counts(1)==2&&counts(2)==6);
sounds.shutdown();sounds.play();assert(counts(1)==2);dlclose(lib);
sounds.play();sounds.play(true);sounds.screenshot();sounds.silence();
assert(counts(0)==3&&counts(1)==3&&counts(2)==7);
sounds.shutdown();sounds.play();assert(counts(1)==3);dlclose(lib);
}
''')
subprocess.run(['g++','-std=c++17','-I'+str(root/'native'),str(test),'-ldl','-o',str(tmp/'test')],check=True)
+34 -8
View File
@@ -57,6 +57,27 @@ def fallback_enabled():
return device_proxy_settings().get('githubAutoFallback', True)
def github_proxy_url(value):
value = value.strip().rstrip('/')
parsed = urllib.parse.urlsplit(value)
if (not value or any(ord(char) <= 32 for char in value)
or parsed.scheme != 'https' or not parsed.hostname
or parsed.username or parsed.password or parsed.query or parsed.fragment
or '..' in parsed.path.split('/')):
raise ValueError('GitHub proxy must be an HTTPS base URL')
try:
parsed.port
except ValueError as error:
raise ValueError('Invalid GitHub proxy port') from error
return value
def configured_github_proxy():
settings = device_proxy_settings()
value = os.environ.get('FRAMELY_GITHUB_PROXY', settings.get('github', '') if settings.get('githubEnabled') else '')
return github_proxy_url(value) if value.strip() else ''
def public_github(url):
parsed = urllib.parse.urlsplit(url)
return (parsed.scheme == 'https' and parsed.hostname in GITHUB_HOSTS
@@ -95,7 +116,7 @@ def github_attempt(url, operation, enabled=None):
try:
return operation(False)
except Exception as primary:
if not (fallback_enabled() if enabled is None else enabled) or not public_github(url) or not retryable(primary):
if configured_github_proxy() or not (fallback_enabled() if enabled is None else enabled) or not public_github(url) or not retryable(primary):
raise
print('GitHub connection failed; trying gh-proxy.com once', file=sys.stderr, flush=True)
try:
@@ -109,12 +130,10 @@ def fetch_once(url, fallback=False):
if urllib.parse.urlparse(url).scheme != 'https':
raise ValueError('HTTPS is required')
settings = device_proxy_settings()
manual = os.environ.get('FRAMELY_GITHUB_PROXY', settings.get('github', '') if settings.get('githubEnabled') else '').strip().rstrip('/')
manual = configured_github_proxy()
if fallback:
url = GITHUB_FALLBACK + url
elif manual and public_github(url):
if urllib.parse.urlsplit(manual).scheme != 'https':
raise ValueError('GitHub proxy must use HTTPS')
url = manual + '/' + url
handlers = [HTTPSRedirect()]
if 'FRAMELY_HTTP_PROXY' in os.environ or settings.get('httpEnabled'):
@@ -280,9 +299,12 @@ def confirm(message):
with open('/dev/tty', 'r+') as terminal:
terminal.write(message + ' [y/N]: ')
terminal.flush()
return terminal.readline().strip().lower() == 'y'
except OSError:
return False
answer = terminal.readline()
except OSError as error:
raise ValueError('No interactive terminal for confirmation; run in a terminal or pass --yes') from error
if not answer:
raise ValueError('No confirmation input; run in a terminal or pass --yes')
return answer.strip().lower() == 'y'
def root_operation(args):
@@ -363,14 +385,18 @@ def main(argv=None):
parser.add_argument('--checksums', type=pathlib.Path, help='External SHA256SUMS for the archive')
parser.add_argument('--user', default=os.environ.get('SUDO_USER', 'steamos'))
parser.add_argument('--yes', action='store_true', help='Confirm the selected operation')
parser.add_argument('--github-proxy', help='HTTPS URL prefix for GitHub downloads, such as https://gh-proxy.com')
parser.add_argument('--progress-json', action='store_true', help=argparse.SUPPRESS)
args = parser.parse_args(argv)
if args.github_proxy:
os.environ['FRAMELY_GITHUB_PROXY'] = github_proxy_url(args.github_proxy)
if not re.fullmatch(r'[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+', args.repo):
parser.error('Invalid repository')
if not re.fullmatch(r'[a-z_][a-zA-Z0-9_-]*', args.user) or args.user == 'root':
parser.error('Invalid Steam user')
if sys.platform != 'linux' or os.uname().machine != 'aarch64':
parser.error('This installation engine requires Linux ARM64 Steam Frame')
explicit_action = args.action is not None
if args.action is None:
with open('/dev/tty', 'r+') as terminal:
terminal.write('1 Install 2 Update 3 Repair 4 Rollback 5 Uninstall\nChoose [1-5]: ')
@@ -379,7 +405,7 @@ def main(argv=None):
args.action = choices.get(terminal.readline().strip())
if args.action is None:
parser.error('Invalid action')
if not args.yes and not confirm(f'{args.action}: ' + ('disable and uninstall ALL plugins, then remove Framely (saved data retained)' if args.action == 'uninstall' else 'continue with Framely maintenance')):
if not args.yes and not (explicit_action and args.action in ('install', 'update')) and not confirm(f'{args.action}: ' + ('disable and uninstall ALL plugins, then remove Framely (saved data retained)' if args.action == 'uninstall' else 'continue with Framely maintenance')):
raise ValueError('Cancelled')
cache = pathlib.Path.home() / '.cache' / 'framely-installer' if args.action in ('install', 'update') and not args.archive else None
if cache is not None:
+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 gamepad_mapping gamepad_steam gamepad_direct entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority recording_indicator recording_region framing_lifecycle recording_region_depth; do
for test in gamepad_mapping gamepad_steam gamepad_direct entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority recording_indicator recording_region framing_lifecycle recording_region_depth system_radial; do
"${CXX:-g++}" -std=c++17 -O2 -pthread -Inative -Inative/vendor/openvr "tests/$test.cpp" -ldl -o "target/tests/$test"
"target/tests/$test"
done
+5
View File
@@ -3,6 +3,11 @@
"locale": "en-US",
"name": "English",
"messages": {
"浏览插件源及 GitHub Releases 中的插件。": "Browse plugins from sources and GitHub Releases.",
"版本通道": "Version channel",
"所有版本": "All versions",
"仅正式版": "Stable only",
"部分 GitHub 版本暂时不可用。": "Some GitHub releases are temporarily unavailable.",
"以文本编辑": "Edit as text",
"文本编辑器": "Text editor",
"纯文本": "Plain text",
+5
View File
@@ -3,6 +3,11 @@
"locale": "zh-CN",
"name": "简体中文",
"messages": {
"浏览插件源及 GitHub Releases 中的插件。": "浏览插件源及 GitHub Releases 中的插件。",
"版本通道": "版本通道",
"所有版本": "所有版本",
"仅正式版": "仅正式版",
"部分 GitHub 版本暂时不可用。": "部分 GitHub 版本暂时不可用。",
"以文本编辑": "以文本编辑",
"文本编辑器": "文本编辑器",
"纯文本": "纯文本",
+3 -2
View File
@@ -2,8 +2,9 @@ import {t} from './i18n';
import {installKeyboard} from '../../sdk/src/keyboard';
import {installHoverFeedback} from '../../sdk/src/hover';
export async function api<T=any>(method:string,params:unknown={},signal?:AbortSignal):Promise<T>{const r=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method,params}),signal});if(r.status===401&&r.headers.get('X-Framely-Reauthenticate')==='1')location.replace('/manager');if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));return v.result;}
export function viewKey(){const p=location.pathname.split('/');return p[1]==='cast'&&p[2]==='framing'?'framely.framing':p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='cast'&&p[2]==='window'?`framely.cast.${p[3]}`:p[1]==='apk'&&p[2]==='permission'?'framely.apk.permission':p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';}
installKeyboard(params=>api('host.keyboard',{view:viewKey(),...params}));
export function viewKey(){const p=location.pathname.split('/');return p[1]==='cast'&&p[2]==='framing'?'framely.framing':p[1]==='cast'&&p[2]==='screenshot'?'framely.screenshot':p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='cast'&&p[2]==='window'?`framely.cast.${p[3]}`:p[1]==='apk'&&p[2]==='permission'?'framely.apk.permission':p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';}
export const nativeKeyboardHost=['localhost','127.0.0.1','[::1]'].includes(location.hostname);
installKeyboard(params=>nativeKeyboardHost?api('host.keyboard',{view:viewKey(),...params}):Promise.resolve(),(method,params)=>nativeKeyboardHost?api('plugin.call',{plugin:'tooru.frame-keyboard',method,params:{...params,view:viewKey()}}):Promise.resolve({ok:false}),{requestOnFocus:viewKey()!=='launcher'});
installHoverFeedback(feedback=>{void api('host.haptic',{view:viewKey(),feedback}).catch(console.error);});
+3 -3
View File
@@ -8,7 +8,7 @@ import {t} from './i18n';
import {FrameGesture,fitMinimumFrame,minimumFrameWidth,resizeFrameCentered,type MinimumFrame} from './cast-framing-gesture';
import type {FrameLimiter} from './cast-region';
export function FramingEditor({settings,view,onChange,onError,spatial=false,minimumSize={width:0,height:0},limit,moveHandle}:{settings:CastSettings;view:ReferenceView|null;onChange:(patch:Partial<CastSettings>)=>void;onError:(error:string)=>void;spatial?:boolean;minimumSize?:MinimumFrame;limit?:FrameLimiter;moveHandle?:React.RefObject<HTMLElement|null>}){
export function FramingEditor({settings,view,onChange,onError,spatial=false,previewUrl,minimumSize={width:0,height:0},limit,moveHandle}:{settings:CastSettings;view:ReferenceView|null;onChange:(patch:Partial<CastSettings>)=>void;onError:(error:string)=>void;spatial?:boolean;previewUrl?:string;minimumSize?:MinimumFrame;limit?:FrameLimiter;moveHandle?:React.RefObject<HTMLElement|null>}){
const stage=useRef<HTMLDivElement>(null),box=useRef<HTMLDivElement>(null),zoom=useRef<HTMLInputElement>(null),readout=useRef<HTMLOutputElement>(null);
const current=useRef<FrameRect>({x:0,y:0,width:1,height:1}),gesture=useRef(new FrameGesture()),pointer=useRef<number|null>(null),release=useRef<(id?:number)=>void>(()=>{});
const capture=useRef<HTMLElement|null>(null),external=useRef({down:(_e:PointerEvent)=>{},move:(_e:PointerEvent)=>{}});
@@ -41,8 +41,8 @@ export function FramingEditor({settings,view,onChange,onError,spatial=false,mini
function end(e:React.PointerEvent<HTMLDivElement>){finish(e.pointerId);}
function keyboard(e:React.KeyboardEvent<HTMLDivElement>){if(!view)return;const delta=e.shiftKey?.05:.01,r=current.current;const changes:Record<string,Partial<FrameRect>>={ArrowLeft:{x:r.x-delta},ArrowRight:{x:r.x+delta},ArrowUp:{y:r.y-delta},ArrowDown:{y:r.y+delta}};if(changes[e.key]){e.preventDefault();draw(fitFrame({...r,...changes[e.key]},aspect,view.aspect));commit();}}
return <div className="cast-framing-editor">
<div className="cast-framing-stage" ref={stage}>
{!spatial&&<CastVideo active controls={false} aspectRatio={1} onError={onError}/>}
<div className="cast-framing-stage" ref={stage} style={previewUrl&&view?{aspectRatio:String(view.aspect)}:undefined}>
{previewUrl?<img src={previewUrl} alt={t('截图取景预览')} draggable={false} className="cast-screenshot-preview-image"/>:!spatial&&<CastVideo active controls={false} aspectRatio={1} onError={onError}/>}
{view&&<div ref={box} className="cast-crop-box" tabIndex={0} role="group" aria-label={t('取景框,使用方向键移动')} onKeyDown={keyboard} onPointerDown={down} onPointerMove={move} onPointerUp={end} onPointerCancel={end} onLostPointerCapture={end}>
<div className="cast-crop-grid" aria-hidden="true"/>
{['nw','ne','sw','se'].map(corner=><span key={corner} data-corner={corner} data-framely-interactive="" className={`cast-crop-handle ${corner}`} aria-hidden="true"/>)}
+2 -2
View File
@@ -1,5 +1,5 @@
import React,{useEffect,useState} from 'react';
import {IconCast,IconPlayerPlay,IconPlayerStop,IconArrowUpRight,IconDeviceVisionPro,IconCrop} from '@tabler/icons-react';
import {IconCast,IconPlayerPlay,IconPlayerStop,IconArrowUpRight,IconDeviceVisionPro,IconCrop,IconCamera} from '@tabler/icons-react';
import {api} from './api';
import {t} from './i18n';
import {Switch,SwitchRow} from './switch';
@@ -17,7 +17,7 @@ export function CastQuick({config,refresh}:{config:CastSettings;refresh:()=>Prom
<div className={`cast-quick-stream${running?' is-live':''}`} aria-busy={!runtime}>
<div className="cast-quick-source"><div><h2>{t('头显画面')}</h2><span className="cast-quick-state" role="status">{!runtime?t('正在连接'):running?t('正在串流'):t('未开始串流')}</span></div><IconDeviceVisionPro size={36} stroke={1.5}/></div>
<p className={`cast-quick-specs${!runtime?' is-loading':''}`} title={t('投屏设置')}><span>{config.width} × {config.height}</span><span>{config.codec==='h265'?'H.265':'H.264'}</span><span>{config.fps} FPS</span></p>
<div className="cast-stream-actions"><button className={running?'':'primary'} disabled={busy||!runtime} onClick={()=>void run(()=>api(running?'cast.stop':'cast.start'))}>{running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void run(()=>api(runtime?.recording?.active?'cast.record.stop':'cast.record.start'))}/></div>
<div className="cast-stream-actions"><button className={running?'':'primary'} disabled={busy||!runtime} onClick={()=>void run(()=>api(running?'cast.stop':'cast.start'))}>{running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void run(()=>api(runtime?.recording?.active?'cast.record.stop':'cast.record.start'))}/><button disabled={busy||!runtime} onClick={()=>void run(()=>api('cast.screenshot.open'))}><IconCamera size={21}/>{t('截图')}</button></div>
</div>
{runtime?.recording?.error&&<p className="error" role="alert">{runtime.recording.error}</p>}
<div className="cast-quick-framing"><div className="cast-quick-framing-row"><button disabled={busy||!runtime||!!runtime.recording?.active||!!runtime.preview||!!runtime.recordingRegion?.editing||config.source!=='screen'||config.output!=='eye'} onClick={()=>void run(()=>api('cast.framing.open'))}><IconCrop size={21}/>{t('画幅调整')}</button>
+11
View File
@@ -0,0 +1,11 @@
.cast-screenshot-spatial .cast-framing-stage{position:absolute;inset:0;width:100%;height:100%;max-width:none}
.cast-screenshot-flat{min-height:100dvh;display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:24px;align-items:center;padding:26px;background:#111315;color:#f2f5f7}
.cast-screenshot-flat-stage{min-width:0;display:grid;place-items:center;max-height:calc(100dvh - 52px);overflow:hidden}
.cast-screenshot-flat-stage>img{display:none}
.cast-screenshot-flat-stage .cast-framing-editor{width:100%;max-width:min(100%,1100px)}
.cast-screenshot-flat-stage .cast-framing-stage{max-width:100%;max-height:calc(100dvh - 180px)}
.cast-screenshot-preview-image{width:100%;height:100%;object-fit:fill;display:block}
.cast-screenshot-flat-controls{padding:24px;border:1px solid #333b42;border-radius:14px;background:#1d2226;box-shadow:0 20px 50px #0005}
.cast-screenshot-flat-controls h1{font-size:24px;margin:0 0 12px}.cast-screenshot-flat-controls p{color:#b8c5cd;font-size:14px;line-height:1.6}
.cast-screenshot-flat-controls footer{display:flex;gap:10px;margin-top:24px}.cast-screenshot-flat-controls footer button{flex:1}
@media(max-width:760px){.cast-screenshot-flat{grid-template-columns:1fr;padding:16px;gap:8px}.cast-screenshot-flat-stage .cast-framing-stage{max-height:56dvh}.cast-screenshot-flat-controls{padding:16px}}
+46
View File
@@ -0,0 +1,46 @@
import React,{useEffect,useMemo,useRef,useState} from 'react';
import {IconArrowsMove,IconCamera} from '@tabler/icons-react';
import {api} from './api';
import {t} from './i18n';
import {Select} from './localized-select';
import {FramingEditor} from './cast-framing-editor';
import {frameFromSettings,frameRatios,type ReferenceView} from './cast-framing';
import {normalizeRegion} from './cast-region';
import {FramingLimits,framingMenuScale,type VisibilityGrid} from './cast-framing-limits';
import type {CastSettings} from './casting';
import './cast-spatial-framing.css';
import './cast-screenshot.css';
type Editor={id:string;settings:CastSettings;geometry:null|{referenceTan:number;view:ReferenceView&{visibility:VisibilityGrid;eye:0|1}};spatial:boolean;preview:string|null;previewAspect:number|null};
export function ScreenshotFraming(){
const [editor,setEditor]=useState<Editor|null>(null),[draft,setDraft]=useState<CastSettings|null>(null),[sourceAspect,setSourceAspect]=useState(1),[error,setError]=useState(''),[busy,setBusy]=useState(false),[closed,setClosed]=useState(false);
const root=useRef<HTMLElement>(null),menu=useRef<HTMLElement>(null),moveHandle=useRef<HTMLButtonElement>(null),finishing=useRef(false),[minimum,setMinimum]=useState({width:0,height:0,referenceTan:0});
useEffect(()=>{let alive=true;void api<Editor>('cast.screenshot.get').then(value=>{if(!alive)return;setEditor(value);if(value.previewAspect)setSourceAspect(value.previewAspect);setDraft(value.spatial&&value.geometry?{...value.settings,horizontalFov:2*Math.atan(value.geometry.view.tanHalfHorizontal)*180/Math.PI,centerX:Math.atan(value.geometry.view.centerTanX)*180/Math.PI,centerY:Math.atan(value.geometry.view.centerTanY)*180/Math.PI}:{...value.settings,width:1920,height:Math.round(1920/(value.previewAspect??1)),horizontalFov:null,centerX:0,centerY:0});}).catch(e=>{if(alive)setError(String(e.message??e));});return()=>{alive=false;};},[]);
useEffect(()=>{const measure=()=>{const area=root.current?.getBoundingClientRect(),panel=menu.current?.getBoundingClientRect();if(area?.width&&area.height&&panel)setMinimum({width:(panel.width+32)/area.width,height:(panel.height+32)/area.height,referenceTan:editor?.geometry?.referenceTan??1});};measure();const observer=typeof ResizeObserver==='undefined'?null:new ResizeObserver(measure);if(menu.current)observer?.observe(menu.current);if(root.current)observer?.observe(root.current);window.addEventListener('resize',measure);return()=>{observer?.disconnect();window.removeEventListener('resize',measure);};},[editor?.id]);
const reference:ReferenceView=editor?.spatial?{tanHalfHorizontal:editor.geometry?.referenceTan??1,centerTanX:0,centerTanY:0,aspect:1}:{tanHalfHorizontal:1,centerTanX:0,centerTanY:0,aspect:sourceAspect};
const limits=useMemo(()=>editor?.spatial&&editor.geometry?new FramingLimits(editor.geometry.view.visibility,editor.geometry.referenceTan):null,[editor]);
const limit=useMemo(()=>limits?.fit.bind(limits),[limits]);
const ready=!!editor&&!!draft&&(!editor.spatial||minimum.referenceTan===editor.geometry?.referenceTan);
function change(patch:Partial<CastSettings>){if(!draft||!editor||busy||closed)return;try{setDraft(editor.spatial?normalizeRegion({...draft,...patch},reference,minimum,limit,draft):{...draft,...patch});setError('');}catch(e){setError(t((e as Error).message));}}
function close(){setClosed(true);setEditor(null);setDraft(null);}
async function finish(capture:boolean){if(!editor||!draft||finishing.current)return;finishing.current=true;setBusy(true);setError('');try{
let params:Record<string,unknown>={id:editor.id};
if(capture){if(editor.spatial){const valid=normalizeRegion(draft,reference,minimum,limit,draft);params={...params,aspect:valid.width/valid.height,horizontalFov:valid.horizontalFov,centerX:valid.centerX,centerY:valid.centerY};}
else{const frame=frameFromSettings(draft,draft.width/draft.height,reference);params={...params,crop:frame};}}
await api(capture?'cast.screenshot.capture':'cast.screenshot.cancel',params);close();
}catch(e){setError(String((e as Error).message??e));try{await api('cast.screenshot.get');}catch{close();}}finally{setBusy(false);finishing.current=false;}}
useEffect(()=>{const back=()=>void finish(false);window.addEventListener('framely.back',back);return()=>window.removeEventListener('framely.back',back);},[editor,draft]);
if(closed)return null;
const aspect=draft?draft.width/draft.height:1;
return <main ref={root} className={editor?.spatial?'cast-native-framing cast-screenshot-spatial':'cast-screenshot-flat'} aria-label={t('截图取景')} inert={busy}>
{ready&&draft&&editor?.spatial&&<FramingEditor spatial moveHandle={moveHandle} settings={draft} view={reference} minimumSize={minimum} limit={limit} onChange={change} onError={setError}/>}
{ready&&draft&&!editor?.spatial&&<div className="cast-screenshot-flat-stage"><img src={editor?.preview??''} alt={t('截图画面')} onLoad={event=>{const img=event.currentTarget;if(img.naturalWidth&&img.naturalHeight)setSourceAspect(img.naturalWidth/img.naturalHeight);}}/><FramingEditor key={sourceAspect} settings={draft} view={reference} previewUrl={editor?.preview??undefined} onChange={change} onError={setError}/></div>}
<section ref={menu} className={editor?.spatial?'cast-native-framing-controls':'cast-screenshot-flat-controls'} style={editor?.spatial?{'--framing-menu-scale':framingMenuScale(reference.tanHalfHorizontal)} as React.CSSProperties:undefined}>
<h1>{editor?.spatial?<button ref={moveHandle} className="cast-framing-move" disabled={!ready||busy}>{t('截图取景')}<IconArrowsMove size={22}/></button>:t('截图取景')}</h1>
<p>{t('拖动取景框移动画面,拖动四角调整范围。截图使用原始可用像素。')}</p>
{draft&&<div className="cast-native-framing-field"><span>{t('画面比例')}</span><Select label={t('画面比例')} value={String(frameRatios.find(r=>Math.abs(r.value-aspect)<.004)?.value??aspect)} disabled={busy} onChange={value=>{const ratio=Number(value),edge=Math.max(draft.width,draft.height);change(ratio>=1?{width:edge,height:Math.round(edge/ratio)}:{width:Math.round(edge*ratio),height:edge});}} options={[...(!frameRatios.some(r=>Math.abs(r.value-aspect)<.004)?[{value:String(aspect),label:t('当前比例')}]:[]),...frameRatios.map(r=>({value:String(r.value),label:r.label}))]}/></div>}
{error&&<p role="alert" className="error">{error}</p>}
<footer><button disabled={busy||!editor} onClick={()=>void finish(false)}>{t('取消')}</button><button className="primary" disabled={busy||!ready} onClick={()=>void finish(true)}><IconCamera size={19}/>{t('截图')}</button></footer>
</section>
</main>;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import React,{useEffect,useRef,useState} from 'react';
import {IconPlayerPlay,IconPlayerStop,IconExternalLink,IconCopy,IconCheck,IconDeviceVisionPro,IconDeviceDesktop,IconCrop,IconEye,IconDeviceTv,IconRefresh,IconCast,IconMusic,IconVideo,IconWifi,IconArrowRight} from '@tabler/icons-react';
import {IconPlayerPlay,IconPlayerStop,IconExternalLink,IconCopy,IconCheck,IconDeviceVisionPro,IconDeviceDesktop,IconCrop,IconEye,IconDeviceTv,IconRefresh,IconCast,IconMusic,IconVideo,IconWifi,IconArrowRight,IconCamera} from '@tabler/icons-react';
import {type NetworkPanel} from './network';
import {api} from './api';
import {Switch,SwitchRow} from './switch';
@@ -49,7 +49,7 @@ export function Casting({config,refresh,network,page='casting'}:{config:CastSett
{errorMessage&&<div className="cast-error" role="alert"><strong>{t('操作未完成')}</strong><p>{errorMessage}</p></div>}
{page==='casting'&&<>
<div className="cast-monitor"><CastVideo key={epoch} active={!!runtime?.running&&!runtime.preview} onError={setError} onStreamInfo={setStreamInfo}/>{!runtime?.running&&<div className="cast-monitor-empty"><IconDeviceVisionPro size={44} stroke={1.5}/><h2>{t('分享你看到的画面')}</h2><p>{t('启动后,手机和电脑可以通过网页实时观看。')}</p></div>}</div>
<div className="cast-stream-bar"><div><strong>{t(config.source==='screen'?'屏幕采集':'SteamVR')}</strong><span>{config.width} × {config.height}<i/> {config.fps} fps</span></div><div className="cast-stream-actions"><button className={runtime?.running?'':'primary'} disabled={busy||!runtime} onClick={()=>void action('start',async()=>{if(runtime?.running)await api('cast.stop');else{if(dirty)await apply();await api('cast.start');}setEpoch(v=>v+1);})}>{runtime?.running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{pending==='start'?t('正在启动…'):runtime?.running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void action('record',async()=>{if(!runtime?.recording?.active&&!runtime?.running&&dirty)await apply();await api(runtime?.recording?.active?'cast.record.stop':'cast.record.start');setEpoch(v=>v+1);})}/></div></div>
<div className="cast-stream-bar"><div><strong>{t(config.source==='screen'?'屏幕采集':'SteamVR')}</strong><span>{config.width} × {config.height}<i/> {config.fps} fps</span></div><div className="cast-stream-actions"><button className={runtime?.running?'':'primary'} disabled={busy||!runtime} onClick={()=>void action('start',async()=>{if(runtime?.running)await api('cast.stop');else{if(dirty)await apply();await api('cast.start');}setEpoch(v=>v+1);})}>{runtime?.running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{pending==='start'?t('正在启动…'):runtime?.running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void action('record',async()=>{if(!runtime?.recording?.active&&!runtime?.running&&dirty)await apply();await api(runtime?.recording?.active?'cast.record.stop':'cast.record.start');setEpoch(v=>v+1);})}/><button disabled={busy||!runtime} onClick={()=>void action('screenshot',()=>api('cast.screenshot.open'))}><IconCamera size={21}/>{t('截图')}</button></div></div>
<SwitchRow label={t('显示录制指示器')} description={t('关闭后仅隐藏右上角录制提示,不会停止录制。')} checked={config.recordingIndicatorVisible??true} disabled={busy} onChange={enabled=>void action('indicator',async()=>{await api('cast.recordingIndicator.visible.save',{enabled});await refresh();})}/><p className="cast-record-path">{t('录制保存到 /home/steamos/Videos/framely')}{runtime?.recording?.path&&<> · <span>{runtime.recording.path.split('/').pop()}</span></>}</p>
{runtime?.recording?.error&&<p className="error" role="alert">{runtime.recording.error}</p>}
<section className="cast-share"><div><IconWifi size={24}/><div><h2>{t('在其他设备上观看')}</h2><p>{t('连接同一网络,打开观看地址。')}</p></div></div><SwitchRow label={t('免验证')} checked={runtime?.watchAccessEnabled??false} disabled={busy||!runtime} onChange={enabled=>void action('watch-access',async()=>{await api('cast.watchAccess.save',{enabled});setCopied(false);})}/><p>{t('持有此链接的人无需密码即可观看。')}</p>{runtime&&<div className="cast-share-link"><code>{watchAddress}</code><button disabled={busy} onClick={()=>void action('copy',copy)} aria-label={t('复制观看地址')}>{copied?<IconCheck size={20}/>:<IconCopy size={20}/>}<span>{copied?t('已复制'):t('复制')}</span></button><a className="cast-icon-link" href={watchAddress} aria-label={t('打开观看网页')}><IconExternalLink size={21}/></a></div>}</section>
+3 -3
View File
@@ -12,8 +12,8 @@ import './launcher.css';
import {installLauncherGaze} from './launcher-gaze';
import {LauncherCalibration} from './launcher-calibration';
import {matchesSearch,type SearchIndex} from './launcher-search';
export type LauncherConfig={prioritizeUi?:boolean;primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
const defaults:LauncherConfig={prioritizeUi:true,primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
export type LauncherConfig={prioritizeUi?:boolean;systemRadialMenu?:boolean;primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
const defaults:LauncherConfig={prioritizeUi:true,systemRadialMenu:false,primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
type SteamApp={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name:string}[];id:number;name:string;icon?:string;iconFit?:string;kind?:string};
function launcherCells(total:number){
const counts=[Math.min(Math.max(total-5,0),4),Math.min(total,5),Math.max(total-9,0)];
@@ -30,7 +30,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
const[busy,setBusy]=useState(false),[error,setError]=useState(''),[seconds,setSeconds]=useState(config.menuTimeoutSeconds);
useEffect(()=>setSeconds(config.menuTimeoutSeconds),[config.menuTimeoutSeconds]);
async function save(p:Partial<LauncherConfig>){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><SwitchRow label={t('长按 Steam 键打开 Framely 菜单')} description={t('关闭后恢复 Steam 原生长按行为。切换时 SteamVR 会重新启动。')} checked={config.systemRadialMenu??false} disabled={busy} onChange={systemRadialMenu=>void save({systemRadialMenu})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
}
type LocalApp={steamAppId?:number|null;id:string;name:string;kind:'desktop'|'lepton';icon?:string;launchUnavailable?:string|null};
type LaunchItem={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name:string}[];key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
+5
View File
@@ -1,5 +1,7 @@
import {CastQuick} from './cast-quick';
import {SystemRadialMenu} from './system-radial-menu';
import {SpatialFraming} from './cast-spatial-framing';
import {ScreenshotFraming} from './cast-screenshot';
import {CastPlayer} from './cast-player';
import {Casting,type CastSettings,type CastPage} from './casting';
import {CastViewer} from './cast-viewer';
@@ -39,7 +41,10 @@ const managerMode=location.pathname==='/manager';
const empty:Status={version:'0.4.0',database:{plugins:{},sources:[],safeMode:false},notifications:[],running:[]};
function App(){
if(location.pathname==='/system/radial')return <SystemRadialMenu/>;
if(location.pathname==='/system/radial/progress')return <SystemRadialMenu progressOnly/>;
if(location.pathname==='/cast/framing')return <SpatialFraming/>;
if(location.pathname==='/cast/screenshot')return <ScreenshotFraming/>;
if(location.pathname==='/apk/permission')return <ApkPermission/>;
if(location.pathname==='/cast/watch')return <CastViewer/>;
if(location.pathname.startsWith('/cast/window/'))return <CastPlayer/>;
+2 -2
View File
@@ -1,5 +1,5 @@
import React,{useEffect,useRef,useState} from 'react';
import {api,viewKey} from './api';
import {api,viewKey,nativeKeyboardHost} from './api';
import {t,currentLanguage} from './i18n';
export let pluginLanguagePreference='auto';
export function setPluginLanguagePreference(value:string){pluginLanguagePreference=value;}
@@ -27,7 +27,7 @@ export function PluginFrame({plugin,entry='quick',actionContext,onActionDone}:{p
}
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(),feedback:params?.feedback});
else if(op==='keyboard')result=await api('host.keyboard',{...params,view:viewKey()});
else if(op==='keyboard'){if(!nativeKeyboardHost)result={ok:false};else if(params?.bridge){const method=params.bridge.method;if(!['webInputOpen','webInputPoll','webInputClaim','webInputAck','webInputClose'].includes(method))throw Error('Invalid keyboard bridge operation');result=await api('plugin.call',{plugin:'tooru.frame-keyboard',method,params:{...params.bridge.params,view:viewKey()}});}else 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)},'*');}
+22 -14
View File
@@ -9,8 +9,15 @@ import {Icon} from './icons';
import {AppPlaceholder} from './app-placeholder';
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;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 Entry=Presentation&{id:string;name:string;author:string;description:string;version:string;apiVersion:number;compatible?:boolean;prerelease?:boolean;engines?:{framely?:string};url:string;sha256:string;runAs?:RuntimeUser|null};
export type CatalogResult={source:Source;catalog?:{name:string;plugins:Entry[]};versions?:Entry[];error?:string;warning?:string;cached?:boolean;fetchedAt?:number};
export type CatalogChoice={entry:Entry;source:Source};
export function githubRepository(s:Source){try{const u=new URL(s.url);return u.protocol==='https:'&&u.hostname==='github.com'&&!u.username&&!u.password&&(!u.port||u.port==='443')&&/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/?$/.test(u.pathname)&&!u.search&&!u.hash?u.pathname.replace(/^\/|\/$/g,''):undefined;}catch{return undefined;}}
export function catalogChoices(data:CatalogResult[],source='all',channel='all',installedSource?:string){
const choices=data.flatMap(c=>(c.versions??c.catalog?.plugins??[]).map(entry=>({entry,source:c.source}))).filter(c=>(source==='all'||c.source.id===source)&&(channel!=='stable'||!c.entry.prerelease&&!versionParts(c.entry.version)?.pre));
choices.sort((a,b)=>(compareVersions(b.entry.version,a.entry.version)??0)||Number(b.source.id===installedSource)-Number(a.source.id===installedSource)||a.source.id.localeCompare(b.source.id));
const seen=new Set<string>();return choices.filter(c=>{const key=JSON.stringify([c.entry.id,c.entry.version,c.entry.sha256]);if(seen.has(key))return false;seen.add(key);return true;});
}
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}>{
const started=await api<{job:string}>(method,params);
@@ -38,25 +45,26 @@ 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||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'};}
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'};const diff=compareVersions(p.version,installed.manifest.version);if(diff===0&&installed.source&&installed.source!==source.id)return{label:t("其他来源"),kind:'other'};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);
async function load(){const sequence=++loadSequence.current;setLoading(true);setError('');setLoadJob(null);try{const v=await runJob('catalog.start',{},job=>{if(sequence!==loadSequence.current)return;setLoadJob(job);if(Array.isArray(job.partial)&&job.partial.length)setData(job.partial);});if(sequence===loadSequence.current)setData(v.result);}catch(e){if(sequence===loadSequence.current)setError(String(e));}finally{if(sequence===loadSequence.current)setLoading(false);}}
const[loadJob,setLoadJob]=useState<Job|null>(null);const loadSequence=useRef(0);const[channel,setChannel]=useState('all');
async function load(refresh=false){const sequence=++loadSequence.current;setLoading(true);setError('');setLoadJob(null);try{const v=await runJob('catalog.start',{refresh},job=>{if(sequence!==loadSequence.current)return;setLoadJob(job);if(Array.isArray(job.partial)&&job.partial.length)setData(job.partial);});if(sequence===loadSequence.current)setData(v.result);}catch(e){if(sequence===loadSequence.current)setError(String(e));}finally{if(sequence===loadSequence.current)setLoading(false);}}
async function cancelLoad(){if(!loadJob)return;try{await api('job.cancel',{job:loadJob.id});++loadSequence.current;setLoading(false);setLoadJob(null);}catch(e){setError(String(e));}}
useEffect(()=>{void load();},[JSON.stringify(sources)]);
const all=useMemo(()=>data.flatMap(c=>(c.catalog?.plugins??[]).map(entry=>({entry,source:c.source,state:stateFor(entry,plugins[entry.id],c.source)}))),[data,plugins]);
const[history,setHistory]=useState<{key:string;entries:Entry[];loading:boolean;error:string}>({key:'',entries:[],loading:false,error:''});
const all=useMemo(()=>catalogChoices(data,source,channel).map(({entry,source})=>({entry,source,state:stateFor(entry,plugins[entry.id],source)})),[data,plugins,source,channel]);
const[history,setHistory]=useState<{key:string;entries:CatalogChoice[];loading:boolean;error:string}>({key:'',entries:[],loading:false,error:''});
const[historyRetry,setHistoryRetry]=useState(0);
const historyKey=selected?`${selected.source.url}:${selected.entry.id}`:'';
useEffect(()=>{if(!selected)return;let active=true;const key=historyKey;setHistory({key,entries:[],loading:true,error:''});void runJob('catalog.versions.start',{source:selected.source.id,pluginId:selected.entry.id}).then(result=>{if(active)setHistory({key,entries:result.result,loading:false,error:''});}).catch(e=>{if(active)setHistory({key,entries:[],loading:false,error:String(e)});});return()=>{active=false;};},[historyKey,historyRetry]);
const historyKey=selected?`${selected.entry.id}:${data.map(c=>c.source.id).join(',')}`:'';
useEffect(()=>{if(!selected)return;let active=true;const key=historyKey;setHistory({key,entries:[],loading:true,error:''});const candidates=data.filter(c=>c.catalog?.plugins.some(p=>p.id===selected.entry.id));void(async()=>{const entries:CatalogChoice[]=[],errors:string[]=[];for(const c of candidates){if(!active)return;try{const result=await runJob('catalog.versions.start',{source:c.source.id,pluginId:selected.entry.id,githubRepository:githubRepository(c.source)});entries.push(...(result.result as Entry[]).map(entry=>({entry,source:c.source})));}catch(error){errors.push(String(error));}}if(active)setHistory({key,entries,loading:false,error:errors.join('; ')});})();return()=>{active=false;};},[historyKey,historyRetry]);
const focused=useRef<string|null>(null);
useEffect(()=>{if(!target||loading||!data.length||focused.current===JSON.stringify(target))return;focused.current=JSON.stringify(target);const matches=all.filter(p=>p.entry.id===target.plugin);const own=matches.find(p=>p.source.id===target.source);if(own||matches.length===1){const p=own??matches[0];setSelected({entry:p.entry,source:p.source});}else{setQuery(plugins[target.plugin]?.manifest.name??target.plugin);setError(matches.length?t("请选择插件来源,再选择安装版本。"):t("当前插件源未提供此插件的版本,可刷新来源或导入插件包。"));}},[data,loading,target]);
useEffect(()=>{if(!target||loading||!data.length||focused.current===JSON.stringify(target))return;focused.current=JSON.stringify(target);const matches=all.filter(p=>p.entry.id===target.plugin);const own=matches.find(p=>p.source.id===target.source);if(matches.length){const p=matches.find(p=>p.state.kind!=='incompatible')??own??matches[0];setSelected({entry:p.entry,source:p.source});}else{setQuery(plugins[target.plugin]?.manifest.name??target.plugin);setError(matches.length?t("请选择插件来源,再选择安装版本。"):t("当前插件源未提供此插件的版本,可刷新来源或导入插件包。"));}},[data,loading,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.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></>}</>;
const recommended=new Map<string,typeof all[number]>();for(const item of all){const old=recommended.get(item.entry.id);if(!old||old.state.kind==='incompatible'&&item.state.kind!=='incompatible')recommended.set(item.entry.id,item);}
const shown=[...recommended.values()].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)))&&(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,githubRepository:githubRepository(s)});
if(selected){const p=selected.entry,s=selected.source,installed=plugins[p.id],state=stateFor(p,installed,s);const choices=catalogChoices([...data,...(history.key===historyKey?history.entries:[]).map(v=>({source:v.source,catalog:{name:v.source.name,plugins:[v.entry]}}))],source,channel,installed?.source).filter(v=>v.entry.id===p.id);if(!choices.some(v=>v.entry.version===p.version&&v.entry.sha256===p.sha256&&v.source.id===s.id))choices.push({entry:p,source:s});const choiceKey=(v:CatalogChoice)=>`${v.source.id}:${v.entry.version}:${v.entry.sha256}`;const versions=choices;const latest=versions.find(v=>v.entry.compatible!==false)?.entry??p;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={choiceKey({entry:p,source:s})} onChange={key=>{const choice=versions.find(v=>choiceKey(v)===key);if(choice)setSelected(choice);}} options={versions.map(v=>({value:choiceKey(v),label:`${v.entry.version} · ${v.source.name}${versions.some(other=>other!==v&&other.entry.version===v.entry.version&&other.entry.sha256!==v.entry.sha256)?` · ${v.entry.sha256.slice(0,8)}`:''}`+(v.entry.version===installed?.manifest.version?t(" · 已安装"):v.entry.compatible===false?t(" · 不兼容"):v.entry===latest?t(" · 推荐版本"):"")}))}/></section><div className="badges"><span>{state.label}</span><span>{s.name}</span>{(p.tags??[]).map(t=><span key={t}>{t}</span>)}</div>{installed?.source&&installed.source!==s.id&&<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("浏览插件源及 GitHub Releases 中的插件。")}</p></div><div className="row"><button onClick={onSources}><Icon name="link"/>{t("管理插件源")}</button><button disabled={loading} onClick={()=>void load(true)}><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("所有来源")},...data.filter(c=>c.catalog).map(c=>({value:c.source.id,label:c.source.name}))]}/><Select label={t("版本通道")} value={channel} onChange={setChannel} options={[{value:"all",label:t("所有版本")},{value:"stable",label:t("仅正式版")}]}/><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(true)}>{t("重试")}</button></p>}{data.filter(c=>c.warning).map(c=><p className="banner" role="status" key={c.source.id}>{t('部分 GitHub 版本暂时不可用。')} {c.warning}</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(true)}>{t("重新连接")}</button></div>)}{loading&&!all.length?<div className="empty-state"><h2>{t("正在加载插件目录…")}</h2><p>{t("已安装插件仍可正常使用。")}</p></div>:!data.some(c=>c.catalog)&&!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');setChannel('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(job.phase==='downloading'?99:100,Math.floor(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>}
export function Sources({sources,save,origins}:{origins?:Record<string,{manual:boolean;subscriptions:Record<string,string>}>;sources:Source[];save:(s:Source[])=>Promise<unknown>}){const[name,setName]=useState(''),[url,setUrl]=useState(''),[http,setHttp]=useState(false),[edit,setEdit]=useState<string|null>(null),[error,setError]=useState(''),[probe,setProbe]=useState(''),[testing,setTesting]=useState(false),[busy,setBusy]=useState(false),[deleting,setDeleting]=useState<Source|null>(null),[formOpen,setFormOpen]=useState(false);
+1
View File
@@ -69,6 +69,7 @@ a:focus-visible{outline:3px solid var(--blue);outline-offset:2px}
button{overflow-wrap:anywhere}
.shell-header .primary,.store-filters .framely-select>button{white-space:nowrap}
.store-filters .framely-select{max-width:100%;flex-shrink:0}
.store-detail .framely-select [role=listbox]{width:100%!important;min-width:0!important;max-width:100%!important;left:0!important}
.store-title{max-width:100%;overflow-wrap:anywhere}
.manage-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.language-import{margin:0;min-height:52px}
+14
View File
@@ -0,0 +1,14 @@
html:has(.system-radial),body:has(.system-radial),#root:has(.system-radial){width:100%;height:100%;margin:0;background:transparent!important;overflow:hidden}
.system-radial{position:relative;display:grid;place-items:center;width:100%;height:100%;color:#fff;font-family:system-ui,"Noto Sans CJK SC",sans-serif;user-select:none;pointer-events:none}
.system-radial-charge{position:relative;width:86px;height:86px;border-radius:50%;background:rgba(0,0,0,.58)}
.system-radial-charge-ring{position:absolute;inset:0;width:86px;height:86px;fill:none;stroke-width:3;transform:rotate(-90deg)}
.system-radial-charge-track{stroke:rgba(255,255,255,.24)}
.system-radial-charge-progress{stroke:#fff;stroke-linecap:round}
.system-radial-face{position:relative;width:500px;height:500px}
.system-radial-slot{--radius:177px;position:absolute;left:50%;top:50%;width:124px;height:82px;transform:translate(-50%,-50%) rotate(var(--angle)) translateY(calc(-1 * var(--radius))) rotate(calc(-1 * var(--angle)));display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:1px solid transparent;border-radius:18px;background:transparent;box-sizing:border-box;opacity:.7;text-shadow:0 2px 8px rgba(0,0,0,.9);transition:opacity .12s,background .12s,border-color .12s}
.system-radial-slot span{font-size:14px;font-weight:590;white-space:nowrap}
.system-radial-slot.is-selected{opacity:1;background:rgba(0,0,0,.62);border-color:rgba(255,255,255,.65)}
.system-radial-center{position:absolute;left:50%;top:50%;width:112px;height:112px;transform:translate(-50%,-50%);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:7px;border-radius:50%;border:1px solid rgba(255,255,255,.22);background:rgba(0,0,0,.5);color:#fff;box-sizing:border-box;text-shadow:0 2px 8px rgba(0,0,0,.75);opacity:.68}
.system-radial-center.is-selected{border-color:rgba(255,255,255,.8);background:rgba(0,0,0,.7);opacity:1}
.system-radial-center>span{font-size:13px;font-weight:580;white-space:nowrap}
@media (prefers-reduced-motion:reduce){.system-radial-slot{transition:none}}
+44
View File
@@ -0,0 +1,44 @@
import {useEffect,useState} from 'react';
import {IconApps,IconCamera,IconDeviceDesktop,IconPlayerRecord,IconRefresh} from '@tabler/icons-react';
import './system-radial-menu.css';
type State={progress:number;open:boolean;direction:string;recording:boolean};
const initial:State={progress:0,open:false,direction:'center',recording:false};
const slots=[
{direction:'up',label:'启动台',icon:IconApps},
{direction:'upRight',label:'',icon:null},
{direction:'right',label:'截图',icon:IconCamera},
{direction:'downRight',label:'',icon:null},
{direction:'down',label:'管理面板',icon:IconDeviceDesktop},
{direction:'downLeft',label:'',icon:null},
{direction:'left',label:'录屏',icon:IconPlayerRecord},
{direction:'upLeft',label:'',icon:null},
];
export function SystemRadialMenu({progressOnly=false}:{progressOnly?:boolean}){
const[state,setState]=useState<State>(initial);
useEffect(()=>{
const update=(event:Event)=>setState((event as CustomEvent<State>).detail);
window.addEventListener('framely.systemMenu',update);
return()=>window.removeEventListener('framely.systemMenu',update);
},[]);
return <main className={'system-radial '+(progressOnly?'is-charging':'is-open')} aria-label="Framely 快捷菜单">
{progressOnly?<div className="system-radial-charge">
<svg className="system-radial-charge-ring" viewBox="0 0 86 86" aria-hidden="true">
<circle className="system-radial-charge-track" cx="43" cy="43" r="31"/>
<circle className="system-radial-charge-progress" cx="43" cy="43" r="31" pathLength="100" strokeDasharray={`${Math.max(0,Math.min(1,state.progress))*100} 100`}/>
</svg>
</div>:<div className="system-radial-face">
{slots.map((slot,index)=>{
const Icon=slot.icon;
if(!Icon)return null;
return <div key={slot.direction} className={'system-radial-slot'+(state.direction===slot.direction?' is-selected':'')} style={{'--angle':`${index*45}deg`} as React.CSSProperties}>
<Icon size={29} stroke={1.7}/><span>{slot.direction==='left'&&state.recording?'结束录屏':slot.direction==='left'?'开始录屏':slot.label}</span>
</div>;
})}
<div className={'system-radial-center'+(state.direction==='center'?' is-selected':'')}>
<IconRefresh size={24} stroke={1.6}/><span>重置视角</span>
</div>
</div>}
</main>;
}