Compare commits

...
17 Commits
Author SHA1 Message Date
toorux 7d9b5a23f4 release: 0.8.2 2026-10-16 00:42:32 +08:00
toorux b885b8f56a test(cast): add opt-in wearer framing visibility diagnostic 2026-10-16 00:40:27 +08:00
toorux 53ddf07f18 fix(cast): compensate stereo framing with live eye and UI depth 2026-10-16 00:40:10 +08:00
toorux b3c692e4a2 fix(ui): vibrate when hovering framing corner handles 2026-10-16 00:39:48 +08:00
toorux cbd13e7db2 fix(cast): restart streams and close framing editors reliably 2026-10-16 00:39:13 +08:00
toorux 9cf05cde09 feat(cast): add calibrated recording frame editor and region guide
Constrain frames to capture-eye visibility, restore menu sizing and add reset. Fix centered resize and trigger release handling, and prevent large native poll responses from losing framing commands.
2026-10-15 23:32:43 +08:00
toorux b23b39fc00 Record MP4 independently of casting with shared 4:3 defaults 2026-10-15 20:26:41 +08:00
toorux 2b7fd3098a Fix progressing download timeouts and release 0.8.2-preview.2 2026-10-14 11:21:15 +08:00
toorux 2f9471c3e4 Add casting recording controls and prepare 0.8.2-preview.1 2026-10-14 00:16:36 +08:00
toorux 1b847f9305 Apply Steam Frame factory panel orientation to capture 2026-10-13 23:06:03 +08:00
toorux d229ad2b09 Add revocable watch-only casting links and fix signed framing arguments 2026-10-13 21:48:39 +08:00
toorux 69831e474e Merge Quest app compatibility into main 2026-10-13 20:56:25 +08:00
toorux a078e8bfb1 Fix casting audio and pacing and show actual stream stats 2026-10-13 20:54:57 +08:00
toorux 926ee38b96 Fix casting audio and pacing and show actual stream stats 2026-10-13 20:54:47 +08:00
toorux 1e62eefb85 chore: prepare 0.8.0 preview.2 release 2026-10-12 22:45:46 +08:00
toorux d5a039dad9 feat(q2f): integrate FrameBridge into Quest conversion runtime 2026-10-12 22:35:08 +08:00
toorux fd3666f931 Defer Quest APK conversion until installation 2026-10-12 20:14:56 +08:00
72 changed files with 4002 additions and 156 deletions

No files matched your search

+33
View File
@@ -10,7 +10,33 @@ concurrency:
group: framely-release-${{ github.ref }}
cancel-in-progress: false
jobs:
framebridge:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Build pinned FrameBridge adapter from source
run: |
mkdir -p target/q2f-framebridge/source target/q2f-framebridge/artifacts/{arm64-v8a,armeabi-v7a}
curl -fsSL --retry 3 -o target/q2f-framebridge/source.tar.gz https://github.com/spoopyghosty0/frameport/archive/80ef9ca6a77eed1ca6b2cc6f746d9897e4178b4e.tar.gz
echo 'ea90ea8dd2f0fe49d54d2df5cc61c1876c9562e55fb40fce2651b1d5594dab7c target/q2f-framebridge/source.tar.gz' | sha256sum -c
tar -xzf target/q2f-framebridge/source.tar.gz -C target/q2f-framebridge/source --strip-components=1
FRAMEPORT_ARTIFACTS_OUT="$PWD/target/q2f-framebridge/artifacts" python3 target/q2f-framebridge/source/native/build.py --only adapter
for abi in arm64-v8a armeabi-v7a; do
test -s "target/q2f-framebridge/artifacts/$abi/libopenxr_loader_generic.so"
readelf -W --dyn-syms "target/q2f-framebridge/artifacts/$abi/libopenxr_loader_generic.so" | grep xrGetInstanceProcAddr
done
mkdir -p target/framebridge-upload/artifacts/{arm64-v8a,armeabi-v7a}
cp target/q2f-framebridge/source.tar.gz target/framebridge-upload/
for abi in arm64-v8a armeabi-v7a; do
cp "target/q2f-framebridge/artifacts/$abi/libopenxr_loader_generic.so" "target/framebridge-upload/artifacts/$abi/"
done
- uses: actions/upload-artifact@v4
with:
name: framely-framebridge
path: target/framebridge-upload/
if-no-files-found: error
frame:
needs: [framebridge]
runs-on: ubuntu-24.04-arm
env:
CARGO_HOME: ${{ github.workspace }}/target/framely-cargo
@@ -59,6 +85,10 @@ jobs:
distribution: temurin
java-version: '25'
architecture: aarch64
- uses: actions/download-artifact@v4
with:
name: framely-framebridge
path: target/q2f-framebridge
- name: Prepare pinned q2f inputs
run: |
mkdir -p target/q2f-input
@@ -99,6 +129,9 @@ jobs:
Q2F_LIBRARIES_ZIP: target/q2f-input/libraries.zip
Q2F_LICENSE: target/q2f-input/LICENSE
Q2F_SOURCE_ARCHIVE: target/q2f-input/source.tar.gz
Q2F_FRAMEBRIDGE_SOURCE: target/q2f-framebridge/source.tar.gz
Q2F_FRAMEBRIDGE_ARM64: target/q2f-framebridge/artifacts/arm64-v8a/libopenxr_loader_generic.so
Q2F_FRAMEBRIDGE_ARM32: target/q2f-framebridge/artifacts/armeabi-v7a/libopenxr_loader_generic.so
run: |
bash tools/package-release.sh target/cef
mkdir -p release-assets
Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.8.0-preview.1"
version = "0.8.2"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.8.0-preview.1"
version = "0.8.2"
edition = "2021"
license = "AGPL-3.0-only"
+3 -3
View File
@@ -93,8 +93,8 @@ python3 -m unittest discover -s tests -p 'test_*.py'
See [release maintenance](docs/releases.md) for CEF builds, integration tests, and publishing commands. Plugins support `steamos` and `root`; hash verification is not a security review, so check the source and runtime user.
## License
## License and acknowledgments
Original Framely code, including the core, installer, SDK, template, and repository examples, uses **AGPL-3.0-only**; see [LICENSE](LICENSE). Third-party dependencies and vendored files retain their respective licenses.
Framely is licensed under **AGPL-3.0-only**; see [LICENSE](LICENSE). Third-party projects retain their own licenses.
AGPL allows commercial use. Distribution requires complete corresponding source under the license; modified software that interacts with users over a network must also offer source to those users. Release source corresponds to the repository's `v<VERSION>` or `installer-v<VERSION>` tags. Redistributors must provide modifications and build scripts matching their binaries.
Thanks to Lepton for the Android runtime, [OVRPort](https://github.com/ovrport/app) for Quest APK conversion, and [FramePort's FrameBridge](https://github.com/spoopyghosty0/frameport) for OpenXR graphics and controller support. The optional q2f package includes the OVRPort and FrameBridge licenses and corresponding source.
+3 -3
View File
@@ -93,8 +93,8 @@ python3 -m unittest discover -s tests -p 'test_*.py'
CEF 构建、集成测试和发布命令见[发行维护](docs/zh-CN/releases.md)。插件支持 `steamos` 和 `root`;哈希校验不代表安全审核,应核对来源和运行用户。
## 许可证
## 许可证与致谢
Framely 原创代码,包括本体、安装器、SDK、模板和仓库示例,采用 **AGPL-3.0-only**,全文见 [LICENSE](LICENSE)。第三方依赖和 vendored 文件保留各自许可证。
Framely 采用 **AGPL-3.0-only**,详见 [LICENSE](LICENSE)。第三方项目保留各自的许可证。
AGPL 允许商业使用;分发需按许可提供完整对应源码,修改后通过网络与用户交互还须向这些用户提供源码。发行源码对应本仓库的 `v<版本>` 或 `installer-v<版本>` 标签;重新分发者须提供与自己二进制匹配的修改和构建脚本。
感谢 Lepton 提供安卓运行环境、[OVRPort](https://github.com/ovrport/app) 提供 Quest APK 转换工具,以及 [FramePort 的 FrameBridge](https://github.com/spoopyghosty0/frameport) 提供 OpenXR 画面与手柄适配。可选 q2f 包附有 OVRPort 和 FrameBridge 的许可证及对应源码。
+30
View File
@@ -4,6 +4,12 @@
## 采集与网页观看
“网页观看”和快捷面板的串流卡片均有 **开始/停止录制** 按钮,状态同步。录制与串流共用画面来源、取景、分辨率、帧率、码率、视频编码和声音设置;默认输出为 1920×1440(4:3),已有自定义设置保持不变。录制复用原始 H.264/H.265 视频,不进行二次视频编码,声音转为 AAC。文件保存到 Steam 用户的 `Videos/framely`(头显默认 `/home/steamos/Videos/framely`),使用唯一文件名和分片 MP4 容器,不覆盖已有视频。
录制与网页串流独立启停:点击录制直接启动本地采集和录制,不开启网页观看或 HLS 服务;之后开启串流会复用正在录制的采集编码,不重启录制。停止串流不停止录制,停止录制不停止串流,两者都停止时释放采集资源。录制期间不能修改采集参数或调整取景;接收投屏设置不受影响。
录制期间头显右上角显示红点、计时和停止图标。指向指示器并点击可停止当前录制;显示指示器不请求场景输入焦点或开启全局激光模式。录制进程异常退出时取消指示并在面板显示错误,已经写入的视频保留。正常关闭会话时尝试完成文件收尾;突然断电无法保证最后一段数据完整。
**屏幕采集**读取头显实际扫描输出,包括透视、Steam 菜单和空间窗口。核心 root 服务只启动固定的 `framely-panel-grab` 助手,通过受 Steam 用户身份检查的私人 Unix socket 导出 DRM framebuffer 的 DMA-BUF。其余采集、Vulkan 畸变校正、取景及 Iris H.264/H.265 编码均以 Steam 用户运行;不设置应用 capability,也不依赖 Framecorder 插件。
Framecorder 原插件的名称是“面板画面”和“SteamVR 备用画面”(CLI 为 `panel` / `headset`)。Framely 使用“屏幕采集”和“SteamVR”,避免把参考实现的应用名当作采集方式。
@@ -16,6 +22,8 @@ Framecorder 原插件的名称是“面板画面”和“SteamVR 备用画面”
网页优先协商用户选择的 H.264 或 H.265。当前官方 CEF 运行包不能解码所选编码时,头显自带 FFmpeg 以 libvpx 提供 VP8 + Opus 兼容输出;所有需要 VP8 的观看者共享一个额外的软件编码进程。采集来源不变。支持所选编码的观看者直接接收原串流,DLNA 仍使用 H.264。VP8 增加 CPU 消耗;兼容观看结束后收回该进程。Framely 不安装、调用或依赖外部 Chrome。
在“在其他设备上观看”中开启 **免验证**,显示和复制的观看地址会附带随机凭证。持有该链接的人无需管理密码即可观看,但不能访问管理面板、文件或终端。链接在停止/重新开始串流及重启后仍有效;关闭开关会撤销链接并断开免验证观看,已登录观看不受影响。再次开启生成新链接。网络访问总开关仍需开启。
声音使用小片段和独立的有界输入队列,避免音频阻塞拖慢视频;编码使用无 B 帧和低延迟参数。SteamVR 的 H.265 是软件编码,会增加 CPU 负载;屏幕采集使用 Iris 硬件编码。
## 接收与发送
@@ -71,4 +79,26 @@ DISPLAY=:0 FRAMELY_MEDIA_BIN=/绝对路径/media/bin \
自动通过的协议/编解码测试不能替代真实发送端兼容性、头显内视觉比例和用户感知延迟验收。
快捷面板的录屏模块提供 **画幅调整**:透明空间窗口中的框可以移动、等比例缩放,并修改画幅比例(包括 3:4)及录制分辨率。应用前使用实际采集 LUT 检查镜片可见范围,超出范围时拒绝保存;录制中不能修改取景。**录制区域显示** 独立持久保存,只在实际录制或串流时显示无交互的淡色虚线框,停止后隐藏;空闲时开启开关不要求 SteamVR 已启动。框的内边缘对应录制边界,颜色读取 SteamVR 边界颜色设置。
画幅调整窗口和虚线框保持双眼可见,但几何范围由实际录制眼决定;跟随主力眼时同样使用采集进程实际报告的眼睛。双眼显示不是双眼录制,边界检查需分别覆盖左右眼。
调整窗口以实际录制眼的完整 render bounds 为固定参考,不能按当前取景大小缩小参考范围,否则缩小后重新打开会无法恢复放大。拖动四角以矩形中心等比例缩放;最小尺寸由中间菜单的实际布局尺寸决定,保存和改变比例时同样执行该限制。网页在松开、取消、失去指针捕获、窗口失焦时结束拖拽;原生层保留移动事件的按下标志,并使用现有物理扳机状态在丢失松开事件时补发释放,避免悬停继续缩放。采集标记对齐不能替代佩戴时的可见范围验收。
菜单的视觉角尺寸独立于参考画布,扩大画布时不能同步放大菜单。采集 LUT 输出由同一个 `shown` 判定生成的有效区域网格,前端使用该网格实时限制拖动、缩放和比例变化,不仅在应用时拒绝越界。网格检查保留一格外侧余量,使外绘边线不会跨入无效区;这个保守上限不改变取景边界与实际录制画面的投影对应关系。**重置大小** 保留画幅比例和分辨率,恢复居中的最大有效框;后端应用时仍做精确有效区域检查。元数据读取必须并发排空 stdout,不能等待进程退出后再读取,以免有效区域数据塞满较小的管道。
提交调整时携带打开窗口时的实际 `expectedEye`,后端重新读取采集几何后进行核对;主力眼在编辑期间改变时拒绝应用,要求重新打开,不能用旧眼的有效范围验证新眼。
应用时以当前串流运行状态为准:正在串流则用保存后的取景重启,未串流则不自动开启。保存成功即结束编辑会话;后续重启失败仍保持窗口关闭并明确报告“取景已保存”,不能让过期窗口恢复编辑。原生宿主通过每次轮询返回的 `framingEditor` 状态补偿可能丢失的关闭命令;后台忙时返回 `null`,宿主保留当前状态。普通轮询的空关闭/停止回执不得等待串流锁。四角悬停复用界面的振动设置。
空间框与采集共用工厂面板旋转、相机内参及 `grow_for_undistort` 投影;不能用通用 OpenVR render frustum 替代该投影。运行中的框只接受当前采集进程输出的几何信息,缺少有效校准时隐藏并报错,不显示近似框。
可见范围同时检查工厂畸变 UV 中的面板覆盖和 OpenVR render projection 中的 HiddenAreaMesh;两套坐标不可混用,也不能跟随面板扫描方向翻转隐藏网格。左右眼分别检查,边界标记测试覆盖最外侧像素。正式框和校准标记使用相同的前景层级,避免被 Steam 空间窗口遮挡。
上述检查证明的是软件渲染/采样有效范围,不是当前佩戴者的完整光学可见范围。OpenVR 的 HiddenAreaMesh 保证被遮区域不可见,但补集并不保证在所有瞳位、眼距下可见;不得把标记录制对齐测试当作鼻侧可见问题已经通过验收。鼻侧边界需要闭另一眼进行真实佩戴验证,不能通过任意翻转坐标或猜测裁剪半径来宣称修复。
双眼辅助框采用实时 `GetEyeToHeadTransform` 的实际选眼原点(含当前瞳距),而不是固定或上次启动时的眼距。保持取景角度不变,按中心射线与当前可见 Steam/Framely 窗口的真实交点计算参考深度,框宽度同步为 `2 × depth × tanHalfFov`。因此录制眼里的边界不变,另一眼的视差由实测窗口距离决定,减少原固定 2 米平面对近处窗口的错位。拖动时冻结深度以保持激光交互稳定;暂时移出窗口时保留最近实测深度。首次没有可测窗口时仍使用 2 米近似,不能宣称读取了任意游戏的场景深度;倾斜/曲面窗口在中心处匹配,整个边框仍是平面近似。改变参考深度不会扩大录制眼的光学可见范围。
仅在用户同意短暂显示校准标记后,运行 `cast::region_device_test::recording_region_device_alignment` 忽略测试。指定 `FRAMELY_REGION_TEST_MEDIA` 和 `FRAMELY_REGION_TEST_MARKER`,后者由 `tests/recording_region_device.cpp` 编译。测试覆盖左右眼、4:3、16:9、竖屏、偏移及高分辨率,以实际录制帧内四角和中心标记的位置检查空间框与输出是否一致;使用隔离临时目录,不替换正式安装文件。
`tests/cast_frame.cpp` 验证原生视频与透明控件的预乘 Alpha 合成、控件纹理保留和收起后的完整画面,需要 GLX 环境。
+104 -11
View File
@@ -5,6 +5,7 @@
use anyhow::{bail, Result};
use framely_capture::openvr::OpenVr;
use crate::panel_geometry::PanelGeometry;
/// Output pixels between grid points. The mapping is smooth, 8 is plenty.
const STEP: u32 = 8;
@@ -26,6 +27,10 @@ const PANELS_ROTATED: bool = true;
pub struct Lut {
pub view: Option<(f64, f64, f64)>,
pub capture_to_head: Option<[[f64; 4]; 3]>,
pub region_fully_visible: bool,
pub editor_reference_tan: Option<f64>,
pub visibility: Option<serde_json::Value>,
pub width: u32,
pub height: u32,
pub step: f32,
@@ -48,6 +53,10 @@ impl Lut {
let height = out_h.div_ceil(STEP) + 1;
Self {
view: None,
capture_to_head: None,
region_fully_visible: false,
editor_reference_tan: None,
visibility: None,
width,
height,
step: STEP as f32,
@@ -113,6 +122,7 @@ struct EyeModel {
t: f64,
b: f64,
roll: f64,
panel_geometry: Option<PanelGeometry>,
/// Panel -> render target, sampled on a grid, per channel.
forward: Vec<[[f64; 2]; 3]>,
/// Render target spots the compositor never draws.
@@ -125,6 +135,20 @@ impl EyeModel {
fn new(vr: &OpenVr, eye: usize) -> Result<Self> {
let proj = vr.projection(eye);
let roll = vr.eye_roll(eye);
// Parallel render cameras report identity even though the physical
// Frame panels are clocked. Do not apply both transforms twice.
let panel_geometry = if roll.abs() < 1e-5 {
match PanelGeometry::load(eye) {
Ok(geometry) => {
log::info!("eye {eye}: experimental factory panel orientation correction enabled");
Some(geometry)
}
Err(error) => {
log::warn!("eye {eye}: factory panel orientation unavailable, retaining OpenVR mapping: {error}");
None
}
}
} else { None };
log::info!(
"eye {eye} projection: {proj:?}, render target {:?}, roll {:.2}°",
vr.render_target_size(),
@@ -139,12 +163,17 @@ impl EyeModel {
for j in 0..n {
for i in 0..n {
let (u, v) = (i as f32 / (n - 1) as f32, j as f32 / (n - 1) as f32);
if let Some(d) = vr.distortion(eye, u, v) {
forward[j * n + i] = d.map(|c| [c[0] as f64, c[1] as f64]);
let (du, dv) = if panel_geometry.is_some() { (1. - u, 1. - v) } else { (u, v) };
if let Some(d) = vr.distortion(eye, du, dv) {
forward[j * n + i] = d.map(|c| if panel_geometry.is_some() {
[1. - c[0] as f64, 1. - c[1] as f64]
} else { [c[0] as f64, c[1] as f64] });
}
}
}
// HiddenAreaMesh belongs to the OpenVR render projection, not the
// factory distortion UV space. Never rotate it with panel scanout.
let triangles = vr.hidden_area(eye);
let hidden = rasterize(&triangles);
if log::log_enabled!(log::Level::Debug) {
@@ -168,27 +197,36 @@ impl EyeModel {
t: proj.top as f64,
b: proj.bottom as f64,
roll,
panel_geometry,
forward,
hidden,
covered,
})
}
fn render_target(&self, tx:f64, ty:f64) -> [f64;2] {
let (s,c)=(-self.roll).sin_cos();
[(c*tx-s*ty-self.l)/(self.r-self.l),(s*tx+c*ty-self.t)/(self.b-self.t)]
}
/// Render target UV for a view direction given as tangents, with the
/// eye's roll undone so the video stays level with the head.
fn target(&self, tx: f64, ty: f64) -> [f64; 2] {
let (s, c) = (-self.roll).sin_cos();
let (rx, ry) = (tx * c - ty * s, tx * s + ty * c);
[(rx - self.l) / (self.r - self.l), (ry - self.t) / (self.b - self.t)]
if let Some(geometry)=self.panel_geometry {return geometry.render_uv(tx,ty);}
self.render_target(tx,ty)
}
/// Whether a render target spot is on the panel and actually drawn.
fn shown(&self, uv: [f64; 2]) -> bool {
fn shown(&self, tx:f64, ty:f64) -> bool {
let uv=self.target(tx,ty);
if !(0.0..1.0).contains(&uv[0]) || !(0.0..1.0).contains(&uv[1]) {
return false;
}
let i = (uv[1] * MASK_SIZE as f64) as usize * MASK_SIZE + (uv[0] * MASK_SIZE as f64) as usize;
self.covered[i] && !self.hidden[i]
if !self.covered[i] {return false;}
let render=self.render_target(tx,ty);
if !(0.0..1.0).contains(&render[0]) || !(0.0..1.0).contains(&render[1]) {return false;}
let i=(render[1]*MASK_SIZE as f64) as usize*MASK_SIZE+(render[0]*MASK_SIZE as f64) as usize;
!self.hidden[i]
}
/// Panel position (eye local) that shows this render target spot in channel `c`.
@@ -204,8 +242,7 @@ impl EyeModel {
/// horizontal half-angle tangent plus a center offset (also tangents).
/// The center may drift a few degrees off straight ahead when that buys
/// a noticeably wider view, since the lens isn't symmetric.
fn widest(&self, aspect: f64) -> (f64, f64, f64) {
let fits = |tan_h: f64, cx: f64, cy: f64| {
fn region_fits(&self, tan_h: f64, cx: f64, cy: f64, aspect: f64) -> bool {
let tan_v = tan_h * aspect;
(0..=EDGE_SAMPLES).all(|k| {
let f = k as f64 / EDGE_SAMPLES as f64 * 2.0 - 1.0;
@@ -216,9 +253,27 @@ impl EyeModel {
(cx + tan_h, cy + f * tan_v),
]
.iter()
.all(|&(x, y)| self.shown(self.target(x, y)))
.all(|&(x, y)| self.shown(x, y))
})
};
}
fn visibility_grid(&self, extent:f64) -> serde_json::Value {
const N:usize=256;
let mut cells=String::with_capacity(N*N/4);
let mut nibble=0u8;
for i in 0..N*N {
let x=i%N;let y=i/N;
let visible=[(0.,0.),(1.,0.),(0.,1.),(1.,1.),(0.5,0.5)].iter().all(|&(dx,dy)| {
self.shown(((x as f64+dx)/N as f64*2.-1.)*extent,((y as f64+dy)/N as f64*2.-1.)*extent)
});
if visible {nibble|=1<<(i%4);}
if i%4==3 {cells.push(char::from_digit(nibble as u32,16).unwrap());nibble=0;}
}
serde_json::json!({"size":N,"extent":extent,"cells":cells})
}
fn widest(&self, aspect: f64) -> (f64, f64, f64) {
let fits = |tan_h, cx, cy| self.region_fits(tan_h, cx, cy, aspect);
let limit = (-self.l).max(self.r).max((-self.t).max(self.b) / aspect);
let search = |cx: f64, cy: f64| {
let (mut lo, mut hi) = (0.0, limit);
@@ -351,6 +406,23 @@ pub fn undistorted(vr: &OpenVr, eye: usize, fov_deg: Option<f64>, out_w: u32, ou
let mut lut = Lut::new(out_w, out_h);
let eye_offset = model.eye as f64 * 0.5;
lut.view = Some((tan_h, cx, cy));
// The editor canvas is the eye's full render bounds, never a multiple of
// the current crop: reopening a smaller crop must not lower its zoom limit.
lut.editor_reference_tan = Some((-model.l).max(model.r).max((-model.t).max(model.b)));
lut.visibility = Some(model.visibility_grid(lut.editor_reference_tan.unwrap()));
lut.region_fully_visible = model.region_fits(tan_h, cx, cy, aspect);
// The verified factory mapping consumes head-relative output rays.
// Without calibration, keep capture working but do not claim an accurate
// spatial guide: the generic OpenVR projection is not equivalent.
let eye = vr.eye_to_head(eye);
// With factory projection, output rays are already head-relative. The
// compositor applies panel orientation itself; applying it again to the
// guide would double the correction.
lut.capture_to_head = if model.panel_geometry.is_some() {
eye.map(|eye|std::array::from_fn(|i|std::array::from_fn(|j| {
if j == 3 {eye[i][3]} else {if i==j {1.} else {0.}}
})))
} else {None};
let mut guess = [[0.5f64, 0.5]; 3];
// Widest color fringe, measured in output pixels.
let mut max_split = 0.0f64;
@@ -465,3 +537,24 @@ fn nearest(forward: &[[[f64; 2]; 3]], n: usize, c: usize, target: [f64; 2]) -> O
.min_by(|a, b| a.1.total_cmp(&b.1))?;
Some([(best % n) as f64 / (n - 1) as f64, (best / n) as f64 / (n - 1) as f64])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recording_region_checks_hidden_mesh_in_render_not_distortion_space() {
let geometry=PanelGeometry::parse(&serde_json::json!({"model_number":"Steam Frame","tracking_to_eye_transform":[{"eye_to_head":[[-1,0,0],[0,-1,0],[0,0,1]],"intrinsics":[[1,0,0],[0,1,0],[0,0,-1]],"grow_for_undistort":0}]}).to_string(),0).unwrap();
let mut model=EyeModel {eye:0,l:-2.,r:1.,t:-2.,b:1.,roll:0.,panel_geometry:Some(geometry),forward:vec![],hidden:vec![false;MASK_SIZE*MASK_SIZE],covered:vec![true;MASK_SIZE*MASK_SIZE]};
let index=|uv:[f64;2]|(uv[1]*MASK_SIZE as f64) as usize*MASK_SIZE+(uv[0]*MASK_SIZE as f64) as usize;
let render=index(model.render_target(0.,0.));
let distortion=index(model.target(0.,0.));
assert_ne!(render,distortion);
model.hidden[render]=true;
assert!(!model.shown(0.,0.));
model.hidden[render]=false;
model.hidden[distortion]=true;
assert!(model.shown(0.,0.));
model.covered[distortion]=false;
assert!(!model.shown(0.,0.));
}
}
+11 -5
View File
@@ -4,6 +4,7 @@ mod encoder;
mod gpu;
mod kms;
mod lut;
mod panel_geometry;
use anyhow::{Context, Result};
use clap::Parser;
@@ -13,11 +14,12 @@ use std::{io::{self, Write}, os::fd::AsRawFd, path::Path, time::{Duration, Insta
#[derive(Parser)]
struct Args {
#[arg(long, default_value_t = 1920)] width: u32,
#[arg(long, default_value_t = 1080)] height: u32,
#[arg(long, default_value_t = 1440)] height: u32,
#[arg(long, default_value_t = 30)] fps: u32,
#[arg(long, default_value_t = 8)] bitrate: u32,
#[arg(long, default_value_t = 0)] eye: usize,
#[arg(long)] raw: bool,
#[arg(long)] describe_view: 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,
@@ -31,14 +33,18 @@ fn run(a: Args) -> Result<()> {
"Invalid output dimensions, rate or eye");
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");
let kms = kms::Kms::open(Path::new("/dev/dri/card0"))?;
let height = if a.raw { ((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!(!a.describe_view || !a.raw, "Raw view has no single-eye region");
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");
let map = if a.raw { lut::raw(a.width, height, kms.mode_size.0, kms.mode_size.1) }
let map = if a.raw { let kms=kms.as_ref().unwrap(); lut::raw(a.width, height, kms.mode_size.0, kms.mode_size.1) }
else { lut::undistorted(&OpenVr::connect()?, a.eye, a.fov, a.width, height, a.center_x, a.center_y)? };
if let Some((tan_h, cx, cy)) = map.view {
eprintln!("{}", serde_json::json!({"event":"view","tanHalfHorizontal":tan_h,"centerTanX":cx,"centerTanY":cy,"aspect":a.width as f64/height as f64}));
let description=serde_json::json!({"event":"view","tanHalfHorizontal":tan_h,"centerTanX":cx,"centerTanY":cy,"aspect":a.width as f64/height as f64,"captureToHead":map.capture_to_head,"eye":a.eye,"projection":"factory-panel-v1","regionFullyVisible":map.region_fully_visible,"editorReferenceTan":map.editor_reference_tan,"visibility":map.visibility});
if a.describe_view { anyhow::ensure!(map.capture_to_head.is_some(),"SteamVR cannot provide an accurate eye transform");println!("{description}"); return Ok(()); }
eprintln!("{description}");
}
let kms=kms.unwrap();
let mut enc = encoder::Encoder::open(&encoder::find_device()?, encoder::Config {
codec: a.codec, width: a.width, height, fps: a.fps,
bitrate: a.bitrate * 1_000_000, qp: None,
+8
View File
@@ -201,6 +201,14 @@ impl OpenVr {
ok.then_some([out.red, out.green, out.blue])
}
pub fn eye_to_head(&self, eye: usize) -> Option<[[f64; 4]; 3]> {
if !self.full_layout { return None; }
let t = unsafe {
let f: unsafe extern "C" fn(i32) -> HmdMatrix34 = self.slot(SLOT_EYE_TO_HEAD);
f(eye as i32)
};
Some(t.m.map(|row|row.map(f64::from)))
}
/// How far the eye's view is rolled relative to the head, in radians.
/// Canted displays show up here.
pub fn eye_roll(&self, eye: usize) -> f64 {
+137
View File
@@ -0,0 +1,137 @@
//! Physical panel orientation is distinct from OpenVR's parallel render cameras.
//! Steam Frame exposes the former in its per-device factory calibration.
use anyhow::{ensure, Context, Result};
#[derive(Clone, Copy)]
pub struct PanelGeometry {
eye_to_head: [[f64; 3]; 3],
intrinsics: [f64; 4],
render_scale: f64,
}
impl PanelGeometry {
/// ComputeDistortion's render UVs use the factory camera, expanded by
/// grow_for_undistort, not the parallel GetProjectionRaw bounding frustum.
pub fn render_uv(&self, tx: f64, ty: f64) -> [f64; 2] {
let [x,y]=self.tangents(tx,ty);
let [fx,fy,cx,cy]=self.intrinsics;
[0.5+(fx*x+cx)/(2.*self.render_scale),0.5+(fy*y-cy)/(2.*self.render_scale)]
}
pub fn load(eye: usize) -> Result<Self> {
let path = "/persist/device_config.json";
ensure!(std::fs::metadata(path)?.len() <= 1024 * 1024, "oversized panel calibration");
Self::parse(&std::fs::read_to_string(path)?, eye)
}
pub(crate) fn parse(json: &str, eye: usize) -> Result<Self> {
let config: serde_json::Value = serde_json::from_str(json)?;
// Factory JSON retains the internal model name on retail devices.
ensure!(matches!(config["model_number"].as_str(), Some("Steam Frame" | "Deckard MP")), "not Steam Frame calibration");
let matrix = &config["tracking_to_eye_transform"][eye]["eye_to_head"];
let mut rotation = [[0.; 3]; 3];
for (i, row) in rotation.iter_mut().enumerate() {
for (j, value) in row.iter_mut().enumerate() {
let raw = matrix[i][j].as_f64().context("missing eye rotation")?;
ensure!(raw.is_finite(), "non-finite eye rotation");
// The shader already rotates each physical panel by 180°.
*value = raw * if j < 2 { -1. } else { 1. };
}
}
for i in 0..3 {
for j in 0..3 {
let dot: f64 = (0..3).map(|k| rotation[i][k] * rotation[j][k]).sum();
ensure!((dot - if i == j { 1. } else { 0. }).abs() < 0.01, "invalid eye rotation");
}
}
let determinant = rotation[0][0] * (rotation[1][1] * rotation[2][2] - rotation[1][2] * rotation[2][1])
- rotation[0][1] * (rotation[1][0] * rotation[2][2] - rotation[1][2] * rotation[2][0])
+ rotation[0][2] * (rotation[1][0] * rotation[2][1] - rotation[1][1] * rotation[2][0]);
ensure!((determinant - 1.).abs() < 0.01 && rotation[2][2] > 0.5, "invalid panel basis");
let calibration=&config["tracking_to_eye_transform"][eye];
let k=&calibration["intrinsics"];
let intrinsics=[k[0][0].as_f64(),k[1][1].as_f64(),k[0][2].as_f64(),k[1][2].as_f64()];
ensure!(intrinsics.iter().all(|v|v.is_some_and(f64::is_finite)),"missing or invalid panel intrinsics");
let intrinsics=intrinsics.map(Option::unwrap);
let render_scale=1.+calibration["grow_for_undistort"].as_f64().context("missing overscan calibration")?;
ensure!(intrinsics[0]>0. && intrinsics[1]>0. && render_scale.is_finite() && (1.0..=4.0).contains(&render_scale),"invalid panel projection");
Ok(Self { eye_to_head: rotation,intrinsics,render_scale })
}
pub fn tangents(&self, tx: f64, ty: f64) -> [f64; 2] {
// Head ray -> physical eye ray, using the transpose of its rotation.
// OpenVR looks along -Z, while image Y increases downwards.
let ray = [tx, -ty, -1.];
let q: [f64; 3] = std::array::from_fn(|j| {
(0..3).map(|i| self.eye_to_head[i][j] * ray[i]).sum()
});
if q[2] >= -1e-6 {
return [f64::NAN; 2];
}
[q[0] / -q[2], q[1] / q[2]]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(matrix: serde_json::Value) -> String {
serde_json::json!({"model_number":"Steam Frame", "tracking_to_eye_transform":[{"eye_to_head":matrix,"intrinsics":[[1,0,0],[0,1,0],[0,0,-1]],"grow_for_undistort":0}]}).to_string()
}
#[test]
fn panel_geometry_removes_scanout_rotation() {
let p = PanelGeometry::parse(&config(serde_json::json!([[-1,0,0],[0,-1,0],[0,0,1]])), 0).unwrap();
assert_eq!(p.tangents(0.2, -0.3), [0.2, -0.3]);
assert_eq!(p.render_uv(0.2,-0.3),[0.6,0.35]);
}
#[test]
fn panel_geometry_preserves_factory_roll_direction() {
let angle: f64 = -10_f64.to_radians();
let (s,c) = angle.sin_cos();
let p = PanelGeometry::parse(&config(serde_json::json!([[-c,s,0],[-s,-c,0],[0,0,1]])), 0).unwrap();
let t = p.tangents(0.5, 0.);
assert!((t[0] - 0.5*c).abs() < 1e-12);
assert!((t[1] - 0.5*s).abs() < 1e-12);
}
#[test]
fn panel_geometry_projects_with_intrinsics_and_overscan() {
let mut json:serde_json::Value=serde_json::from_str(&config(serde_json::json!([[-1,0,0],[0,-1,0],[0,0,1]]))).unwrap();
json["tracking_to_eye_transform"][0]["intrinsics"]=serde_json::json!([[1.2,0,0.04],[0,1.1,-0.02],[0,0,-1]]);
json["tracking_to_eye_transform"][0]["grow_for_undistort"]=serde_json::json!(0.6);
let geometry=PanelGeometry::parse(&json.to_string(),0).unwrap();
let uv=geometry.render_uv(0.2,-0.3);
assert!((uv[0]-(0.5+(1.2*0.2+0.04)/3.2)).abs()<1e-12);
assert!((uv[1]-(0.5+(-1.1*0.3+0.02)/3.2)).abs()<1e-12);
json["tracking_to_eye_transform"][0]["grow_for_undistort"]=serde_json::json!(-1.);
assert!(PanelGeometry::parse(&json.to_string(),0).is_err());
}
#[test]
fn panel_geometry_rejects_missing_and_invalid_calibration() {
for matrix in [serde_json::json!(null), serde_json::json!([[0,0,0],[0,0,0],[0,0,0]]), serde_json::json!([[1,0,0],[0,-1,0],[0,0,1]])] {
assert!(PanelGeometry::parse(&config(matrix), 0).is_err());
}
assert!(PanelGeometry::parse("{}", 0).is_err());
}
#[test]
fn panel_geometry_accepts_retail_internal_model_name() {
let json = config(serde_json::json!([[-1,0,0],[0,-1,0],[0,0,1]]));
assert!(PanelGeometry::parse(&json.replace("Steam Frame", "Deckard MP"), 0).is_ok());
assert!(PanelGeometry::parse(&json.replace("Steam Frame", "Other HMD"), 0).is_err());
}
#[test]
fn panel_geometry_device_fixture() {
let Ok(path) = std::env::var("FRAMELY_PANEL_TEST_FIXTURE") else { return };
let json = std::fs::read_to_string(path).unwrap();
for eye in 0..2 {
let geometry = PanelGeometry::parse(&json, eye).unwrap();
let ray = geometry.tangents(0.25, 0.25);
assert!(ray.into_iter().all(f64::is_finite));
}
}
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "vendor/json.hpp"
#include <string>
// Native close commands are an optimization; this durable state also repairs
// a missed command without letting an old close affect a newly opened editor.
inline bool framing_editor_should_close(const nlohmann::json& state,const std::string& editor_id){
if(!state.is_object()||!state.contains("open")||!state["open"].is_boolean()||!state.contains("id")||!state["id"].is_string())return false;
auto id=state["id"].get<std::string>();
return state["open"].get<bool>()?id!=editor_id:id==editor_id;
}
+124 -16
View File
@@ -31,6 +31,10 @@
#include "brand_mask.h"
#include "ui_visibility.h"
#include "ui_feedback.h"
#include "recording_indicator.h"
#include "recording_region.h"
#include "recording_region_depth.h"
#include "framing_lifecycle.h"
#include "plugin_navigation.h"
#include <GL/gl.h>
#include <GL/glx.h>
@@ -103,6 +107,7 @@ static int gaze_feedback_hand=1;
static std::chrono::steady_clock::time_point gaze_sent{},gaze_feedback_at{};
static EntryTrigger entry_buttons[2][2];
static EntryAnalogTrigger entry_analog[2];
static bool framing_trigger_known[2]{},framing_trigger_down[2]{};
static EntryPointer entry_pointer;
static bool menu_open=false,keyboard_open=false,keyboard_close_pending=false;
static float notification_curve_radius=1.3f;
@@ -126,7 +131,8 @@ template<class T> struct Handler {
#include "cast_frame.h"
struct View {
CastFrame cast_frame;
std::string key,plugin,title;int width=600,height=840;float render_scale=1.f;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false;
json region_geometry=json::object();
std::string key,plugin,title,region_editor_id;int width=600,height=840;float render_scale=1.f;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false,framing_left_down=false;
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_at{},activation_at{};
cef_mouse_event_t pointer{};bool pointer_valid=false,stick_scrolling=false;vr::TrackedDeviceIndex_t pointer_device=vr::k_unTrackedDeviceIndexInvalid;std::chrono::steady_clock::time_point pointer_at{};
cef_file_dialog_callback_t* file_dialog=nullptr;std::string file_dialog_id;bool file_multiple=false,file_directory=false;
@@ -167,7 +173,15 @@ static std::vector<uint8_t> icon(bool hover,bool active){
}
static std::vector<std::string> closed_casts;
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::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();payload=json{{"views",visible_views},{"closedCasts",closed_casts}}.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);closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
// Protected by visibility_mutex (outgoing request) / queue_mutex (status).
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 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;}
// 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
@@ -214,6 +228,71 @@ static vr::VROverlayHandle_t active_main_window(){
overlays->FindOverlay("valve.steam.gamepadui.main",&selected);
return selected;
}
struct RecordingRegionPresentation {json view;double distance=2.;};
static RecordingRegionPresentation recording_region_presentation(const json& original,bool freeze_depth=false){
RecordingRegionPresentation result{original,2.};
// This changes stereo presentation only, never the recorded angular crop.
// A center-ray hit measures real UI depth, including tilted/curved windows.
static auto sampled=std::chrono::steady_clock::time_point{};
static vr::HmdMatrix34_t left{},right{};static bool eyes_valid=false,measured=false;
static int previous_eye=-1;static double distance=2.;
try{
int eye=original.at("eye");if(eye<0||eye>1)return result;
vr::HmdMatrix34_t capture{};
for(int i=0;i<3;i++)for(int j=0;j<4;j++)capture.m[i][j]=original.at("captureToHead").at(i).at(j).get<float>();
auto now=std::chrono::steady_clock::now();bool probe=now-sampled>=std::chrono::milliseconds(60);
if(probe){left=vr_system->GetEyeToHeadTransform(vr::Eye_Left);right=vr_system->GetEyeToHeadTransform(vr::Eye_Right);eyes_valid=true;sampled=now;}
if(eyes_valid&&recording_region_refresh_eye_origin(static_cast<vr::EVREye>(eye),left,right,capture)){
for(int i=0;i<3;i++)result.view["captureToHead"][i][3]=capture.m[i][3];
}
if(eye!=previous_eye){previous_eye=eye;distance=2.;measured=false;probe=true;}
if(probe&&!freeze_depth){
vr::TrackedDevicePose_t poses[1]{};
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,1);
if(poses[0].bPoseIsValid&&poses[0].bDeviceIsConnected){
using namespace recording_region_depth_detail;
const auto& head=poses[0].mDeviceToAbsoluteTracking;
const double cx=original.at("centerTanX"),cy=original.at("centerTanY");
auto origin=add(translation(head),rotate(head,translation(capture)));
auto direction=rotate(head,rotate(capture,{cx,-cy,-1.}));
double length=std::sqrt(dot(direction,direction));
if(std::isfinite(length)&&length>1e-6){
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;
for(int i=0;i<3;i++){ray.vSource.v[i]=origin[i];ray.vDirection.v[i]=direction[i]/length;}
std::set<vr::VROverlayHandle_t> candidates;
for(const char* key:{"valve.steam.gamepadui.main","valve.steam.gamepadui.fallback"}){vr::VROverlayHandle_t handle=0;if(!overlays->FindOverlay(key,&handle))candidates.insert(handle);}
for(const auto& key:dashboard_keys)if(key.rfind("valve.steam.",0)==0&&key!="valve.steam.gamepadui.bar"){vr::VROverlayHandle_t handle=0;if(!overlays->FindOverlay(key.c_str(),&handle))candidates.insert(handle);}
for(const auto& [key,v]:views)if(!v->closing&&v->shown&&(v->dashboard||key.rfind("framely.window.",0)==0||key.rfind("framely.cast.",0)==0)&&key!="launcher")candidates.insert(v->overlay);
std::optional<double> nearest;
for(auto handle:candidates){
if(!overlays->IsOverlayVisible(handle))continue;
vr::VROverlayIntersectionResults_t hit{};
if(!overlays->ComputeOverlayIntersection(handle,&ray,&hit)||!std::isfinite(hit.vUVs.v[0])||!std::isfinite(hit.vUVs.v[1])||hit.vUVs.v[0]<0||hit.vUVs.v[0]>1||hit.vUVs.v[1]<0||hit.vUVs.v[1]>1)continue;
auto depth=recording_region_hit_depth(head,capture,cx,cy,hit.vPoint);
if(depth&&(!nearest||*depth<*nearest))nearest=depth;
}
if(nearest){
if(!measured){std::cout<<"Recording frame measured UI depth "<<*nearest<<" m, IPD "<<std::sqrt(dot(subtract(translation(right),translation(left)),subtract(translation(right),translation(left))))*1000<<" mm\n";distance=*nearest;measured=true;}
else distance+=(*nearest-distance)*.4;
}
// If a center ray temporarily leaves a window, retain its last measured
// depth rather than snapping back to 2 m. Without any UI measurement,
// 2 m remains an explicit approximation for arbitrary game scenes.
}
}
}
result.distance=distance;
}catch(...){return {original,2.};}
return result;
}
static void update_framing_placement(View& v){
if(v.closing||!v.region_geometry.is_object()||!v.region_geometry.contains("view"))return;
auto presentation=recording_region_presentation(v.region_geometry["view"],v.framing_left_down);
RecordingRegionPlane plane;
if(!recording_region_plane(presentation.view,v.region_geometry.at("referenceTan"),0,0,1,plane,presentation.distance))return;
overlays->SetOverlayWidthInMeters(v.overlay,plane.width);
overlays->SetOverlayTransformTrackedDeviceRelative(v.overlay,vr::k_unTrackedDeviceIndex_Hmd,&plane.transform);
}
static void hover_haptic(vr::VROverlayHandle_t overlay,std::chrono::steady_clock::time_point& last,bool activation=false){
auto now=std::chrono::steady_clock::now();if(now-last<std::chrono::milliseconds(80)||!overlays->IsOverlayVisible(overlay)||!overlays->IsHoverTargetOverlay(overlay))return;
last=now;if(ui_sounds)ui_audio.play(activation);
@@ -270,7 +349,7 @@ 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));}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(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 anchor_to_head(View& v,float width,float x=0,float y=0,float z=-1.35f){vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);if(!poses[0].bPoseIsValid)return;auto m=poses[0].mDeviceToAbsoluteTracking;for(int r=0;r<3;r++)m.m[r][3]+=m.m[r][0]*x+m.m[r][1]*y+m.m[r][2]*z;overlays->SetOverlayWidthInMeters(v.overlay,width);overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&m);}
static void anchor_permission(View& v){
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
@@ -307,9 +386,9 @@ static View& create_view(const std::string& key,const std::string& title,const s
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")?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")?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.apk.permission")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true);
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);
if(key=="framely.apk.permission"){
vr::VROverlayIntersectionMaskPrimitive_t mask{};
mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;
@@ -501,11 +580,13 @@ static void launcher_gaze(vr::IVRInput* input,bool allowed,const bool (&connecte
gaze_active=true;
}
static void entry_actions(vr::VROverlayHandle_t button,bool available){
if(!entry_actions_ready)return;auto* input=vr::VRInput();vr::VRActiveActionSet_t sets[2]{};sets[0].ulActionSet=entry_set;sets[1].ulActionSet=gaze_set;
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();
if(!entry_actions_ready){recording_indicator.cancel_input();return;}auto* input=vr::VRInput();vr::VRActiveActionSet_t sets[2]{};sets[0].ulActionSet=entry_set;sets[1].ulActionSet=gaze_set;
// Only the eye action participates in overlay priority. Controller bindings
// keep their existing priority so Steam laser and Dock controls are preserved.
if(launcher_gaze_allowed()&&!entry_hold.pressed&&!(available&&(entry_pointer.inside||overlays->IsHoverTargetOverlay(button))))sets[1].nPriority=vr::k_nActionSetOverlayGlobalPriorityMin;
if(input->UpdateActionState(sets,sizeof(sets[0]),gaze_set?2:1)!=vr::VRInputError_None)return;
if(input->UpdateActionState(sets,sizeof(sets[0]),gaze_set?2:1)!=vr::VRInputError_None){recording_indicator.cancel_input();return;}
auto now=std::chrono::steady_clock::now();
// Dock ownership must be checked before reading confirm edges. Gaze focus
// alone does not own a trigger aimed at the entry, even before mouse-down
@@ -514,8 +595,8 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
float stick_x=0,stick_y=0;bool stick_active=false;vr::TrackedDeviceIndex_t stick_device=vr::k_unTrackedDeviceIndexInvalid;
for(int hand=0;hand<2;hand++){
vr::InputPoseActionData_t pose{};auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);bool hit=available&&entry_pointer.hit(device);
bool pose_ok=available&&input->GetPoseActionDataRelativeToNow(aim_action,vr::TrackingUniverseStanding,0,&pose,sizeof(pose),hands[hand])==vr::VRInputError_None&&pose.bActive&&pose.pose.bPoseIsValid;
if(available&&!pose_ok&&device!=vr::k_unTrackedDeviceIndexInvalid){
bool pose_ok=pointer_available&&input->GetPoseActionDataRelativeToNow(aim_action,vr::TrackingUniverseStanding,0,&pose,sizeof(pose),hands[hand])==vr::VRInputError_None&&pose.bActive&&pose.pose.bPoseIsValid;
if(pointer_available&&!pose_ok&&device!=vr::k_unTrackedDeviceIndexInvalid){
vr::TrackedDevicePose_t tracked[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,tracked,vr::k_unMaxTrackedDeviceCount);
char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));
vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t tip{};
@@ -523,7 +604,7 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
pose.pose.mDeviceToAbsoluteTracking=entry_tip_pose(tracked[device].mDeviceToAbsoluteTracking,tip.mTrackingToComponentLocal);pose_ok=true;
}
}
if(pose_ok){auto& m=pose.pose.mDeviceToAbsoluteTracking;vr::VROverlayIntersectionParams_t params{};params.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){params.vSource.v[r]=m.m[r][3];params.vDirection.v[r]=-m.m[r][2];}vr::VROverlayIntersectionResults_t result{};hit=hit||(overlays->IsHoverTargetOverlay(button)&&overlays->ComputeOverlayIntersection(button,&params,&result)&&result.vUVs.v[0]>=float(dock_icon_left)/dock_canvas_width&&result.vUVs.v[0]<=float(dock_icon_left+128)/dock_canvas_width);}
if(available&&pose_ok){auto& m=pose.pose.mDeviceToAbsoluteTracking;vr::VROverlayIntersectionParams_t params{};params.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){params.vSource.v[r]=m.m[r][3];params.vDirection.v[r]=-m.m[r][2];}vr::VROverlayIntersectionResults_t result{};hit=hit||(overlays->IsHoverTargetOverlay(button)&&overlays->ComputeOverlayIntersection(button,&params,&result)&&result.vUVs.v[0]>=float(dock_icon_left)/dock_canvas_width&&result.vUVs.v[0]<=float(dock_icon_left+128)/dock_canvas_width);}
if(hit)gaze_allowed=false;
gaze_connected[hand]=device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device);
@@ -558,6 +639,11 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
if(kind==0&&!active)entry_analog[hand].held=false;
if(kind==0&&active)gaze_buttons[hand]|=down;
if(kind==0){
framing_trigger_known[hand]=active;framing_trigger_down[hand]=down||gaze_buttons[hand];
// Reuse the same physical tip / analog-trigger fallback as the Dock.
// No second action poll or input-focus request for the recording HUD.
auto stop_recording=recording_indicator.pointer(hand,pose.pose.mDeviceToAbsoluteTracking,active&&pose_ok,down);
if(!stop_recording.empty()){std::lock_guard<std::mutex> lock(visibility_mutex);pending_recording_stop=stop_recording;}
auto permission=views.find("framely.apk.permission");
if(permission!=views.end()&&!permission->second->closing&&permission->second->shown&&pose_ok){
auto& view=*permission->second;
@@ -585,7 +671,7 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
}
bool was_held=entry_buttons[hand][kind].held;
entry_buttons[hand][kind].update(active,down,hit,now);
if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!recording_indicator.hovered(hand)&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
}
if(launcher_open&&hand==1){vr::InputDigitalActionData_t close{};bool active=input->GetDigitalActionData(close_action,&close,sizeof(close),hands[hand])==vr::VRInputError_None&&close.bActive;bool down=close.bState;
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};active=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_b",hands[hand],&mode,&state);down=active&&(state.uProperties&vr::VRComponentProperty_IsPressed);}
@@ -603,9 +689,28 @@ 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=="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"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="apk.permission.open"){auto key="framely.apk.permission";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto& v=create_view(key,"Android permission",origin+"/apk/permission",false,1000,560,"",.85f);anchor_permission(v);}else if(kind=="apk.permission.close"){if(views.count("framely.apk.permission"))close_view(*views.at("framely.apk.permission"));}else if(kind=="cast.window"){auto id=c.at("id").get<std::string>();auto key="framely.cast."+id;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}int width=c.value("width",640),height=c.value("height",240);if(width<160||width>1920||height<112||height>8192)continue;auto& v=create_view(key,"Framely casting",origin+"/cast/window/"+id,false,width,height,"",2.f);v.cast_frame.path=c.at("frame").get<std::string>();if(v.width!=width||v.height!=height){v.width=width;v.heighLine 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.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 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=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key.rfind("framely.cast.",0)!=0&&v.key!="framely.apk.permission")close_view(v);break;default:break;}}
static void release_framing_pointer(View& v){
if(!v.framing_left_down)return;v.framing_left_down=false;
auto mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_click_event(v.host,&mouse,MBT_LEFT,1,1);
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;
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);
if(device==vr::k_unTrackedDeviceIndexInvalid)continue;
if(device==v.pointer_device){known=framing_trigger_known[hand];held=framing_trigger_down[hand];owner=true;break;}
}
if(!owner)for(int hand=0;hand<2;hand++){
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
if(device==vr::k_unTrackedDeviceIndexInvalid)continue;found=true;known&=framing_trigger_known[hand];held|=framing_trigger_down[hand];
}
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;}}
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];}
@@ -644,10 +749,10 @@ int main(int argc,char** argv){
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;}
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);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));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 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();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;{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("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);
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;
@@ -727,6 +832,7 @@ int main(int argc,char** argv){
}
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);}}
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;}
if(v->dirty&&!v->closing&&v->pixels.size()==size_t(v->width*v->render_scale)*size_t(v->height*v->render_scale)*4){
@@ -742,7 +848,7 @@ int main(int argc,char** argv){
if(!v->texture_ready[slot]){
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,mipmapped?GL_LINEAR_MIPMAP_LINEAR:GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,v->width*v->render_scale,v->height*v->render_scale,0,GL_BGRA,GL_UNSIGNED_BYTE,nullptr);
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,pixel_width,pixel_height,0,GL_BGRA,GL_UNSIGNED_BYTE,nullptr);
v->texture_ready[slot]=true;
damage.include(0,0,pixel_width,pixel_height);
}
@@ -802,5 +908,7 @@ int main(int argc,char** argv){
// only for immediate API failures, so a broken sync call cannot busy-spin.
if(sync!=vr::VROverlayError_None&&sync!=vr::VROverlayError_TimedOut)std::this_thread::sleep_until(frame_start+std::chrono::milliseconds(8));
}
recording_indicator.destroy();
recording_region_overlay.destroy();
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;
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <array>
#include <vector>
#include <string>
#include <cstdio>
#include <cmath>
#include <algorithm>
struct RecordingIndicatorClick {
bool held=true,armed=false;
bool update(bool down,bool inside,bool valid=true){
if(!valid){held=true;armed=false;return false;}
if(down&&!held)armed=inside;
bool clicked=!down&&held&&armed&&inside;
if(!down)armed=false;
held=down;return clicked;
}
};
inline std::vector<unsigned char> recording_indicator_pixels(uint64_t seconds,bool hover){
constexpr int w=320,h=80;std::vector<unsigned char> pixels(w*h*4);
auto point=[&](int x,int y,int r,int g,int b,int a=255){if(x<0||x>=w||y<0||y>=h)return;auto i=(y*w+x)*4;pixels[i]=r;pixels[i+1]=g;pixels[i+2]=b;pixels[i+3]=a;};
for(int y=0;y<h;y++)for(int x=0;x<w;x++){
int dx=std::max(12-x,std::max(x-(w-13),0)),dy=std::max(12-y,std::max(y-(h-13),0));
if(dx*dx+dy*dy<=144)point(x,y,hover?65:27,hover?45:27,hover?45:29,235);
if((x-32)*(x-32)+(y-40)*(y-40)<=11*11)point(x,y,242,61,75);
}
static constexpr unsigned digits[]={0x7b6f,0x2492,0x73e7,0x73cf,0x5bc9,0x79cf,0x79ef,0x7249,0x7bef,0x7bcf};
char text[32];seconds=std::min<uint64_t>(seconds,359999);std::snprintf(text,sizeof text,"%02llu:%02llu:%02llu",(unsigned long long)(seconds/3600),(unsigned long long)(seconds/60%60),(unsigned long long)(seconds%60));
int x=70;for(char c:std::string(text)){
unsigned glyph=c==':'?0x0410:digits[c-'0'];
for(int row=0;row<5;row++)for(int col=0;col<3;col++)if(glyph&(1u<<(14-row*3-col)))for(int dy=0;dy<4;dy++)for(int dx=0;dx<4;dx++)point(x+col*4+dx,30+row*4+dy,245,245,245);
x+=20;
}
for(int y=29;y<51;y++)for(int x=272;x<294;x++)point(x,y,245,245,245);
return pixels;
}
class RecordingIndicator {
vr::IVROverlay* overlay=nullptr;vr::VROverlayHandle_t handle=0;
std::string id;uint64_t rendered=~uint64_t(0);bool last_hover=false;std::array<RecordingIndicatorClick,2> clicks{};std::array<bool,2> hover{};
public:
void destroy(){if(handle&&overlay)overlay->DestroyOverlay(handle);handle=0;id.clear();hover={};}
bool active()const{return handle&&!id.empty();}
void cancel_input(){clicks={};hover={};}
bool hovered(int hand)const{return hand>=0&&hand<2&&hover[hand];}
std::string pointer(int hand,const vr::HmdMatrix34_t& pose,bool valid,bool down){
if(!active()||hand<0||hand>=2)return {};
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;
for(int k=0;k<3;k++){ray.vSource.v[k]=pose.m[k][3];ray.vDirection.v[k]=-pose.m[k][2];}
vr::VROverlayIntersectionResults_t hit{};
hover[hand]=valid&&overlay->ComputeOverlayIntersection(handle,&ray,&hit)&&hit.vUVs.v[0]>=0&&hit.vUVs.v[0]<=1&&hit.vUVs.v[1]>=0&&hit.vUVs.v[1]<=1;
return clicks[hand].update(down,hover[hand],valid)?id:std::string{};
}
void tick(vr::IVROverlay* overlays,const std::string& next,uint64_t seconds){
overlay=overlays;if(next.empty()){destroy();return;}
if(!handle){
if(overlay->CreateOverlay("framely.recording","Framely recording — click to stop",&handle))return;
overlay->SetOverlayWidthInMeters(handle,.32f);
vr::HmdMatrix34_t transform={{{1,0,0,.50f},{0,1,0,.35f},{0,0,1,-1.f}}};
overlay->SetOverlayTransformTrackedDeviceRelative(handle,vr::k_unTrackedDeviceIndex_Hmd,&transform);
// Stay passive: never take input focus from a game or activate global lasers.
overlay->SetOverlayInputMethod(handle,vr::VROverlayInputMethod_None);
overlay->SetOverlayFlag(handle,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,false);
overlay->SetOverlayFlag(handle,vr::VROverlayFlags_HideLaserIntersection,true);
overlay->SetOverlayFlag(handle,vr::VROverlayFlags_VisibleInDashboard,true);
rendered=~uint64_t(0);
}
if(id!=next){id=next;clicks={};hover={};}
bool highlighted=hover[0]||hover[1];
if(seconds!=rendered||highlighted!=last_hover){auto pixels=recording_indicator_pixels(seconds,highlighted);if(!overlay->SetOverlayRaw(handle,pixels.data(),320,80,4)){overlay->ShowOverlay(handle);rendered=seconds;last_hover=highlighted;}}
}
};
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include <cmath>
#include <algorithm>
#include <vector>
#include <array>
#include <chrono>
#include "vendor/json.hpp"
using RegionJson=nlohmann::json;
struct RecordingRegionPlane {vr::HmdMatrix34_t transform{};float width=0;};
inline void recording_region_foreground(vr::IVROverlay* overlays,vr::VROverlayHandle_t handle){
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_SortWithNonSceneOverlays,true);
overlays->SetOverlaySortOrder(handle,1000);
}
inline bool recording_region_plane(const RegionJson& view,double tan,double cx,double cy,double aspect,RecordingRegionPlane& plane,double distance=2.){
try{
if(!std::isfinite(tan)||tan<=0||!std::isfinite(cx)||!std::isfinite(cy)||!std::isfinite(aspect)||aspect<=0||!std::isfinite(distance)||distance<=0)return false;
const auto& basis=view.at("captureToHead");
for(int i=0;i<3;i++){
double translation=basis.at(i).at(3).get<double>();
for(int j=0;j<3;j++){
double value=basis.at(i).at(j).get<double>();if(!std::isfinite(value))return false;
plane.transform.m[i][j]=value;
translation+=distance*value*(j==0?cx:j==1?-cy:-1.);
}
if(!std::isfinite(translation))return false;plane.transform.m[i][3]=translation;
}
plane.width=2*tan*distance;return std::isfinite(plane.width)&&plane.width>0;
}catch(...){return false;}
}
inline std::array<unsigned char,3> recording_region_color(){
static std::array<unsigned char,3> cached{0,255,255};static auto checked=std::chrono::steady_clock::time_point{};
auto now=std::chrono::steady_clock::now();if(now-checked<std::chrono::seconds(1))return cached;checked=now;
std::array<unsigned char,3> color{0,255,255};auto* settings=vr::VRSettings();
if(!settings)return cached;
const char* keys[]={vr::k_pch_CollisionBounds_ColorGammaR_Int32,vr::k_pch_CollisionBounds_ColorGammaG_Int32,vr::k_pch_CollisionBounds_ColorGammaB_Int32};
for(int i=0;i<3;i++){vr::EVRSettingsError error{};auto value=settings->GetInt32(vr::k_pch_CollisionBounds_Section,keys[i],&error);if(error!=vr::VRSettingsError_None||value<0||value>255)return cached;color[i]=static_cast<unsigned char>(value);}
cached=color;return cached;
}
inline std::vector<unsigned char> recording_region_pixels(int w,int h,const std::array<unsigned char,3>& color={0,255,255}){
std::vector<unsigned char> pixels(w*h*4);
for(int y=0;y<h;y++)for(int x=0;x<w;x++){
bool edge=x<2||x>=w-2||y<2||y>=h-2;
if(edge&&((x<2||x>=w-2?y:x)/14)%2==0){auto i=(y*w+x)*4;for(int c=0;c<3;c++)pixels[i+c]=color[c];pixels[i+3]=72;}
}
return pixels;
}
class RecordingRegionOverlay {
vr::IVROverlay* overlays=nullptr;vr::VROverlayHandle_t handle=0;std::string last,last_pixels;
public:
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;
if(!state.value("visible",false)){destroy();return;}
auto color=recording_region_color();auto serialized=state.dump()+std::string(color.begin(),color.end())+std::to_string(distance)+(live_view?live_view->dump():"");if(serialized==last)return;
try{
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;
overlays->SetOverlayInputMethod(handle,vr::VROverlayInputMethod_None);
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,false);
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_HideLaserIntersection,true);
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_VisibleInDashboard,true);
recording_region_foreground(overlays,handle);
}
int width=state.at("settings").at("width"),height=state.at("settings").at("height");
if(width<160||height<160||width>4096||height>4096||std::abs(double(width)/height-aspect)>1e-6){destroy();return;}
// The INNER edge is the capture boundary. Keep the guide outside it so
// it does not cover pixels inside the intended recorded image.
// Bound raw-overlay traffic independently of recording resolution.
// Square texels are explicitly stretched to the exact capture aspect.
width=height=512;plane.width*=512.f/508.f;
auto error=overlays->SetOverlayWidthInMeters(handle,plane.width);
if(!error)error=overlays->SetOverlayTexelAspect(handle,aspect);
if(!error)error=overlays->SetOverlayTransformTrackedDeviceRelative(handle,vr::k_unTrackedDeviceIndex_Hmd,&plane.transform);
const auto pixel_key=std::string(color.begin(),color.end());
if(!error&&(last_pixels.empty()||pixel_key!=last_pixels)){
auto pixels=recording_region_pixels(width,height,color);
error=overlays->SetOverlayRaw(handle,pixels.data(),width,height,4);
if(!error)last_pixels=pixel_key;
}
if(error){destroy();return;}overlays->ShowOverlay(handle);last=serialized;
}catch(...){destroy();}
}
};
+90
View File
@@ -0,0 +1,90 @@
#pragma once
#include <array>
#include <cmath>
#include <optional>
#include "openvr.h"
// Pure geometry only. Pose validity/freshness is owned by the caller: a
// numerically rigid matrix cannot reveal whether its tracking sample is old.
namespace recording_region_depth_detail {
using Point=std::array<double,3>;
inline double dot(const Point& a,const Point& b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];}
inline Point translation(const vr::HmdMatrix34_t& m){return {m.m[0][3],m.m[1][3],m.m[2][3]};}
inline Point rotate(const vr::HmdMatrix34_t& m,const Point& p){
Point result{};for(int i=0;i<3;i++)for(int j=0;j<3;j++)result[i]+=m.m[i][j]*p[j];return result;
}
inline Point add(const Point& a,const Point& b){return {a[0]+b[0],a[1]+b[1],a[2]+b[2]};}
inline Point subtract(const Point& a,const Point& b){return {a[0]-b[0],a[1]-b[1],a[2]-b[2]};}
inline bool rigid(const vr::HmdMatrix34_t& m){
for(const auto& row:m.m)for(float v:row)if(!std::isfinite(v))return false;
for(int i=0;i<3;i++)for(int j=0;j<3;j++){
double product=0;for(int k=0;k<3;k++)product+=double(m.m[k][i])*m.m[k][j];
if(std::abs(product-(i==j?1.:0.))>0.01)return false;
}
const double determinant=m.m[0][0]*(double(m.m[1][1])*m.m[2][2]-double(m.m[1][2])*m.m[2][1])
-m.m[0][1]*(double(m.m[1][0])*m.m[2][2]-double(m.m[1][2])*m.m[2][0])
+m.m[0][2]*(double(m.m[1][0])*m.m[2][1]-double(m.m[1][1])*m.m[2][0]);
return std::abs(determinant-1.)<=0.01;
}
}
// Refresh just the eye origin. Capture's basis already encodes the LUT's ray
// convention and must not receive the physical-panel rotation a second time.
inline bool recording_region_refresh_eye_origin(vr::EVREye eye,
const vr::HmdMatrix34_t& left_to_head,const vr::HmdMatrix34_t& right_to_head,
vr::HmdMatrix34_t& capture_to_head){
using namespace recording_region_depth_detail;
if((eye!=vr::Eye_Left&&eye!=vr::Eye_Right)||!rigid(left_to_head)||!rigid(right_to_head)||!rigid(capture_to_head))return false;
const auto left=translation(left_to_head),right=translation(right_to_head),baseline=subtract(right,left);
const double ipd=std::sqrt(dot(baseline,baseline));
// Broad plausibility guards, not a replacement for live tracking validity.
if(ipd<0.04||ipd>0.09||baseline[0]<0.8*ipd||std::abs(baseline[1])>0.02||std::abs(baseline[2])>0.02
||dot(left,left)>0.25*0.25||dot(right,right)>0.25*0.25)return false;
const auto& selected=eye==vr::Eye_Left?left_to_head:right_to_head;
for(int i=0;i<3;i++)capture_to_head.m[i][3]=selected.m[i][3];
return true;
}
// Intersect the selected capture-eye center ray with a known UI window plane.
// The returned parameter D is measured along capture -Z, not Euclidean ray
// length: X = eye + D * basis * (centerTanX,-centerTanY,-1).
// It therefore plugs directly into a guide whose width is 2*tanHalfFov*D.
inline std::optional<double> recording_region_window_depth(
const vr::HmdMatrix34_t& hmd_to_world,const vr::HmdMatrix34_t& window_to_world,
const vr::HmdMatrix34_t& capture_to_head,double center_tan_x,double center_tan_y){
using namespace recording_region_depth_detail;
if(!rigid(hmd_to_world)||!rigid(window_to_world)||!rigid(capture_to_head)
||!std::isfinite(center_tan_x)||!std::isfinite(center_tan_y)
||std::abs(center_tan_x)>10.||std::abs(center_tan_y)>10.
||dot(translation(capture_to_head),translation(capture_to_head))>0.25*0.25)return std::nullopt;
const Point origin=add(translation(hmd_to_world),rotate(hmd_to_world,translation(capture_to_head)));
const Point ray=rotate(hmd_to_world,rotate(capture_to_head,{center_tan_x,-center_tan_y,-1.}));
const Point normal={window_to_world.m[0][2],window_to_world.m[1][2],window_to_world.m[2][2]};
const double denominator=dot(normal,ray);
if(std::abs(denominator)<1e-5*std::sqrt(dot(ray,ray)))return std::nullopt;
const double distance=dot(normal,subtract(translation(window_to_world),origin))/denominator;
if(!std::isfinite(distance)||distance<0.25||distance>6.)return std::nullopt;
return distance;
}
// ComputeOverlayIntersection can supply an exact curved/tilted UI hit. Recover
// the same capture -Z parameter, rejecting unrelated/off-ray intersection data.
inline std::optional<double> recording_region_hit_depth(
const vr::HmdMatrix34_t& hmd_to_world,const vr::HmdMatrix34_t& capture_to_head,
double center_tan_x,double center_tan_y,const vr::HmdVector3_t& world_hit){
using namespace recording_region_depth_detail;
if(!rigid(hmd_to_world)||!rigid(capture_to_head)
||!std::isfinite(center_tan_x)||!std::isfinite(center_tan_y)
||std::abs(center_tan_x)>10.||std::abs(center_tan_y)>10.
||dot(translation(capture_to_head),translation(capture_to_head))>0.25*0.25)return std::nullopt;
Point hit{};for(int i=0;i<3;i++){hit[i]=world_hit.v[i];if(!std::isfinite(hit[i]))return std::nullopt;}
const Point origin=add(translation(hmd_to_world),rotate(hmd_to_world,translation(capture_to_head)));
const Point ray=rotate(hmd_to_world,rotate(capture_to_head,{center_tan_x,-center_tan_y,-1.}));
const Point delta=subtract(hit,origin);
const double squared_length=dot(ray,ray),distance=dot(delta,ray)/squared_length;
if(!std::isfinite(distance)||distance<0.25||distance>6.)return std::nullopt;
const Point residual={delta[0]-distance*ray[0],delta[1]-distance*ray[1],delta[2]-distance*ray[2]};
const double tolerance=1e-4+1e-4*distance*std::sqrt(squared_length);
if(dot(residual,residual)>tolerance*tolerance)return std::nullopt;
return distance;
}
+22 -1
View File
@@ -85,6 +85,8 @@ struct Record {
orientation: Option<String>,
#[serde(default)]
framebuffer_compatibility: bool,
#[serde(default)]
q2f_version: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct Database {
@@ -195,6 +197,7 @@ struct App {
orientation: Option<String>,
#[serde(default)]
framebuffer_compatibility: bool,
q2f_version: Option<String>,
}
fn gamepad_trigger_threshold_default() -> u8 {
80
@@ -844,6 +847,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
show_window: rec.and_then(|r| r.show_window),
orientation: rec.and_then(|r| r.orientation.clone()),
framebuffer_compatibility: rec.is_some_and(|r| r.framebuffer_compatibility),
q2f_version: rec.and_then(|r| r.q2f_version.clone()),
});
}
}
@@ -898,6 +902,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
show_window: r.show_window,
orientation: r.orientation.clone(),
framebuffer_compatibility: r.framebuffer_compatibility,
q2f_version: r.q2f_version.clone(),
});
}
}
@@ -943,6 +948,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
show_window: None,
orientation: None,
framebuffer_compatibility: false,
q2f_version: None,
});
}
}
@@ -2082,6 +2088,7 @@ fn record_app(db: &mut Database, a: &App) {
show_window: a.show_window,
orientation: a.orientation.clone(),
framebuffer_compatibility: a.framebuffer_compatibility,
q2f_version: a.q2f_version.clone(),
..Default::default()
});
}
@@ -3024,6 +3031,15 @@ fn operate_internal(
.transpose()?;
let ticket = p["ticket"].as_str().context("Missing reviewed APK")?;
let (review, metadata) = review(home, ticket)?;
let q2f_version = match fs::read_to_string(review.join("q2f-version")) {
Ok(value) => {
let id = value.trim();
ensure!(safe(id) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b".+-".contains(&b)), "Invalid q2f review version");
Some(id.to_owned())
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
let requested = p["context"].as_str().unwrap_or("");
let cs = containers(home, &db);
let context = if requested.is_empty() {
@@ -3127,7 +3143,7 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
// Installation boots a headless container solely for the package manager.
// Finish that runtime before Steam takes ownership of the first app launch.
stop(&c,&log)?;
let rec=db.records.get_mut(&app_id).unwrap();rec.metadata=metadata.clone();rec.removed=false;rec.pending=None;rec.show_window=settings.show_window;save(home,&db)?;fs::remove_dir_all(review)?;
let rec=db.records.get_mut(&app_id).unwrap();rec.metadata=metadata.clone();rec.removed=false;rec.pending=None;rec.show_window=settings.show_window;rec.q2f_version=q2f_version;save(home,&db)?;fs::remove_dir_all(review)?;
// Mirror the operation log under the stable application id for newly installed apps.
if id!=app_id {let _=fs::copy(&log,r.join("logs").join(format!("{}-{}.log",hash(&app_id),now())));}
Ok(json!({"app":app_id,"backup":backup_id,"launchable":!metadata.activities.is_empty()}))})
@@ -5225,12 +5241,17 @@ setup_props
assert!(load(&f.home).unwrap().records.is_empty());
for (version, show) in [(43, false), (44, true)] {
let ticket = f.review(version);
if version == 43 {
fs::write(root(&f.home).join("reviews").join(&ticket).join("q2f-version"), "test-q2f-1").unwrap();
}
f.operation(
"install",
json!({"ticket":ticket,"context":"test","app":id,"approve":true,"showWindow":show}),
)
.unwrap();
assert_eq!(load(&f.home).unwrap().records[id].show_window, Some(show));
assert_eq!(load(&f.home).unwrap().records[id].q2f_version.as_deref(),
if version == 43 { Some("test-q2f-1") } else { None });
assert!(
!running("test"),
"installation must finish its headless runtime"
+126 -1
View File
@@ -4,6 +4,7 @@ use anyhow::{ensure, Context, Result};
use serde_json::{json, Value};
use std::{
fs,
io::Read,
os::unix::fs::{symlink, PermissionsExt},
path::{Path, PathBuf},
process::{Command, Stdio},
@@ -11,6 +12,50 @@ use std::{
time::Duration,
};
fn verify_framebridge(
apk: &Path,
runtime: &Path,
libraries_version: &str,
abis: &[String],
) -> Result<()> {
let mut archive = zip::ZipArchive::new(fs::File::open(apk)?)?;
let mut found = false;
for abi in abis
.iter()
.filter(|abi| abi.as_str() == "arm64-v8a" || abi.as_str() == "armeabi-v7a")
{
found = true;
let library = runtime
.join("libraries")
.join(libraries_version)
.join("lib")
.join(abi);
for (apk_name, source_name) in [
("libopenxr_loader_generic.so", "libopenxr_loader_generic.so"),
(
"libopenxr_loader_original.so",
"libopenxr_loader_original.so",
),
("libframe_settings.so", "libframe_settings.so"),
] {
let expected = fs::read(library.join(source_name))?;
let mut entry = archive.by_name(&format!("lib/{abi}/{apk_name}"))?;
ensure!(
entry.size() == expected.len() as u64,
"Quest adapter file size mismatch: {abi}/{apk_name}"
);
let mut actual = Vec::with_capacity(expected.len());
entry.read_to_end(&mut actual)?;
ensure!(
actual == expected,
"Quest adapter file mismatch: {abi}/{apk_name}"
);
}
}
ensure!(found, "Quest APK has no supported FrameBridge architecture");
Ok(())
}
fn release_root() -> Result<PathBuf> {
Ok(std::env::current_exe()?
.parent()
@@ -120,6 +165,36 @@ fn workspace(storage: &Path, runtime: &Path) -> Result<PathBuf> {
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn converted_apk_requires_the_exact_wrapped_loader_and_settings() {
let temp = tempfile::tempdir().unwrap();
let runtime = temp.path().join("runtime");
let lib = runtime.join("libraries/test/lib/arm64-v8a");
fs::create_dir_all(&lib).unwrap();
let apk = temp.path().join("converted.apk");
let mut writer = zip::ZipWriter::new(fs::File::create(&apk).unwrap());
for (name, data) in [
("libopenxr_loader_generic.so", b"framebridge".as_slice()),
("libopenxr_loader_original.so", b"overport".as_slice()),
("libframe_settings.so", b"controller_fix=1\n".as_slice()),
] {
fs::write(lib.join(name), data).unwrap();
writer
.start_file(
format!("lib/arm64-v8a/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
writer.write_all(data).unwrap();
}
writer.finish().unwrap();
let abis = vec!["arm64-v8a".to_string()];
verify_framebridge(&apk, &runtime, "test", &abis).unwrap();
fs::write(lib.join("libframe_settings.so"), b"controller_fix=0\n").unwrap();
assert!(verify_framebridge(&apk, &runtime, "test", &abis).is_err());
}
#[test]
fn workspace_library_link_survives_apk_storage_move() {
@@ -141,6 +216,34 @@ mod tests {
"ok"
);
}
#[test]
fn runtime_upgrade_keeps_per_app_signing_keys() {
let temp = tempfile::tempdir().unwrap();
let storage = temp.path();
for id in ["old", "framebridge"] {
let runtime = storage.join("q2f/runtimes").join(id);
fs::create_dir_all(runtime.join("libraries")).unwrap();
fs::write(runtime.join("overport-installed.json"), "{}").unwrap();
fs::write(runtime.join("libraries/probe"), id).unwrap();
workspace(storage, &runtime).unwrap();
if id == "old" {
fs::write(
storage.join("q2f/workspace/signatures/game.keystore"),
"original key",
)
.unwrap();
}
}
assert_eq!(
fs::read_to_string(storage.join("q2f/workspace/signatures/game.keystore")).unwrap(),
"original key"
);
assert_eq!(
fs::read_to_string(storage.join("q2f/workspace/libraries/probe")).unwrap(),
"framebridge"
);
}
}
pub(super) fn convert(
@@ -217,16 +320,38 @@ pub(super) fn convert(
let result = (|| -> Result<Value> {
ensure!(converted.is_file(), "Quest converter produced no APK");
progress(json!({"phase":"q2f-verifying","operation":"convert"}))?;
let mut verifier = Command::new(runtime.join("java/bin/java"));
verifier
.arg("-cp")
.arg(format!(
"{}:{}",
runtime.join("overportcli.jar").display(),
runtime.display()
))
.arg("FramelyQ2FVerify")
.arg(&converted);
run(verifier, &log, cancel)?;
let after: Metadata = apk_metadata::read(&converted)?;
ensure!(
after.package == before.package && after.version_code == before.version_code,
"Quest conversion changed APK package or version"
);
verify_framebridge(&converted, &runtime, version, &after.abis)?;
cancel.check()?;
// Create a fresh review after conversion so the user has the full
// review window even when patching takes several minutes.
drop(_guard);
super::inspect(home, &converted, cancel)
let inspected = super::inspect(home, &converted, cancel)?;
let reviewed = storage.join("reviews").join(
inspected["ticket"]
.as_str()
.context("Converted review has no ticket")?,
);
fs::write(
reviewed.join("q2f-version"),
requirement["id"].as_str().context("Invalid q2f identity")?,
)?;
Ok(inspected)
})();
let _ = fs::remove_dir_all(&output);
let next = result?;
+5
View File
@@ -2,5 +2,10 @@
//! run as the Steam session user, apart from the framebuffer export helper.
pub mod settings;
pub mod runtime;
mod recording;
pub mod region;
#[cfg(test)]
mod region_device_test;
pub mod receiver;
pub mod watch_access;
+551
View File
@@ -0,0 +1,551 @@
//! MP4 recording shares the capture encoder, with AAC audio for compatibility.
use anyhow::{ensure, Context, Result};
use serde_json::{json, Value};
use std::{
fs,
io::Write,
os::unix::{fs::OpenOptionsExt, process::CommandExt},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
pub struct Recording {
child: Child,
path: PathBuf,
started: Instant,
progress: PathBuf,
log: PathBuf,
pub id: String,
}
fn has_media(progress: &str) -> bool {
// Stream-copy progress has no `frame=` field on the headset's FFmpeg.
let positive = |name: &str| {
progress
.lines()
.rev()
.find_map(|line| line.strip_prefix(name))
.is_some_and(|value| value.trim().parse::<i64>().is_ok_and(|n| n > 0))
};
positive("out_time_us=") && positive("total_size=")
}
impl Recording {
pub fn start(
rtsp_port: u16,
directory: &Path,
output: &Path,
codec: super::settings::Codec,
) -> Result<Self> {
Self::start_command(Command::new("ffmpeg"), rtsp_port, directory, output, codec)
}
fn start_command(
mut cmd: Command,
rtsp_port: u16,
directory: &Path,
output: &Path,
codec: super::settings::Codec,
) -> Result<Self> {
fs::create_dir_all(output).context("无法创建录制目录")?;
let id = hex::encode(rand::random::<[u8; 16]>());
let path = output.join(format!(
"Framely-{}-{}.mp4",
crate::service::now_ms(),
&id[..8]
));
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)?;
let progress = directory.join(format!("recording-{id}.progress"));
let log = directory.join(format!("recording-{id}.log"));
super::runtime::bind_to_session(&mut cmd);
let spawned = cmd
.process_group(0)
.args([
"-hide_banner",
"-loglevel",
"warning",
"-y",
"-fflags",
"+genpts",
"-analyzeduration",
"1000000",
"-probesize",
"1048576",
"-rtsp_transport",
"tcp",
"-i",
&format!("rtsp://127.0.0.1:{rtsp_port}/headset"),
"-map",
"0:v:0",
"-map",
"0:a?",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"128k",
"-avoid_negative_ts",
"make_zero",
"-stats_period",
"0.2",
"-progress",
])
.arg(&progress)
// Fragmented MP4 remains usable without a final global index, and
// avoids buffering an entire recording until it is stopped.
.args([
"-flush_packets",
"1",
"-movflags",
"+frag_keyframe+empty_moov+default_base_moof",
"-f",
"mp4",
])
.args(if codec == super::settings::Codec::H265 {
vec!["-tag:v", "hvc1"]
} else {
vec![]
})
.arg(&path)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&log)?,
)
.spawn()
.context("启动录制程序失败");
let child = match spawned {
Ok(child) => child,
Err(error) => {
let _ = fs::remove_file(&path);
let _ = fs::remove_file(&log);
return Err(error);
}
};
let mut recording = Self {
child,
path,
started: Instant::now(),
progress,
log,
id,
};
let ready = (|| -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(12);
loop {
ensure!(
recording.child.try_wait()?.is_none(),
"录制程序启动失败:{}",
recording.detail()
);
if fs::read_to_string(&recording.progress)
.ok()
.is_some_and(|s| has_media(&s))
{
return Ok(());
}
ensure!(
Instant::now() < deadline,
"录制启动超时:{}",
recording.detail()
);
std::thread::sleep(Duration::from_millis(50));
}
})();
if let Err(error) = ready {
let _ = recording.finish();
return Err(error);
}
Ok(recording)
}
pub fn status(&self) -> Value {
json!({"active":true,"id":self.id,"path":self.path,"elapsedSeconds":self.started.elapsed().as_secs()})
}
pub fn exited(&mut self) -> Result<bool> {
Ok(self.child.try_wait()?.is_some())
}
pub fn detail(&self) -> String {
fs::read_to_string(&self.log)
.map(|s| {
s.chars()
.rev()
.take(2048)
.collect::<String>()
.chars()
.rev()
.collect()
})
.unwrap_or_default()
}
pub fn finish(&mut self) -> Result<PathBuf> {
if self.child.try_wait()?.is_none() {
if let Some(mut stdin) = self.child.stdin.take() {
let _ = stdin.write_all(b"q\n");
}
let deadline = Instant::now() + Duration::from_secs(8);
while self.child.try_wait()?.is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(25));
}
if self.child.try_wait()?.is_none() {
unsafe {
libc::kill(-(self.child.id() as i32), libc::SIGINT);
}
}
let deadline = Instant::now() + Duration::from_secs(2);
while self.child.try_wait()?.is_none() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(25));
}
if self.child.try_wait()?.is_none() {
unsafe {
libc::kill(-(self.child.id() as i32), libc::SIGKILL);
}
let _ = self.child.wait();
anyhow::bail!("录制结束超时,已保留可恢复文件:{}", self.path.display());
}
}
let status = self.child.wait()?;
ensure!(
status.success(),
"录制异常结束,文件已保留:{};{}",
self.path.display(),
self.detail()
);
Ok(self.path.clone())
}
}
impl Drop for Recording {
fn drop(&mut self) {
let _ = self.finish();
let _ = fs::remove_file(&self.progress);
let _ = fs::remove_file(&self.log);
}
}
#[cfg(test)]
pub(super) fn test_recording(directory: &Path, output: &Path, fail: bool) -> Recording {
let script = directory.join("fake-ffmpeg.sh");
fs::write(
&script,
r#"previous=''
for argument in "$@"; do
if [ "$previous" = '-progress' ]; then progress="$argument"; fi
previous="$argument"
done
printf 'header\n' > "$previous"
printf 'out_time_us=1000\ntotal_size=100\n' > "$progress"
if [ "$FAIL_LATER" = 1 ]; then sleep 0.4; exit 1; fi
read command
[ "$command" = q ] || exit 2
printf 'trailer\n' >> "$previous"
"#,
)
.unwrap();
let mut command = Command::new("sh");
command
.arg(script)
.env("FAIL_LATER", if fail { "1" } else { "0" });
Recording::start_command(command, 1, directory, output, super::settings::Codec::H264).unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn cast_recording_copy_progress_does_not_require_frame_count() {
assert!(has_media(
"total_size=38485\nout_time_us=1191811\nprogress=continue\n"
));
assert!(!has_media("total_size=38485\nout_time_us=N/A\n"));
assert!(!has_media("frame=1\ntotal_size=0\nout_time_us=0\n"));
}
#[test]
fn cast_recording_finalizes_and_uses_unique_private_files() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("videos");
let mut first = test_recording(dir.path(), &output, false);
let path = first.finish().unwrap();
assert_eq!(path.extension().unwrap(), "mp4");
assert_eq!(fs::read_to_string(&path).unwrap(), "header\ntrailer\n");
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
let mut second = test_recording(dir.path(), &output, false);
assert_ne!(first.id, second.id);
assert_ne!(path, second.finish().unwrap());
}
#[test]
fn cast_recording_drop_finalizes_and_cleans_progress() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("videos");
let first = test_recording(dir.path(), &output, false);
let path = first.path.clone();
let progress = first.progress.clone();
drop(first);
assert_eq!(fs::read_to_string(path).unwrap(), "header\ntrailer\n");
assert!(!progress.exists());
}
#[test]
fn cast_recording_spawn_failure_cleans_empty_output() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("videos");
assert!(Recording::start_command(
Command::new("/framely-no-such-ffmpeg"),
1,
dir.path(),
&output,
super::super::settings::Codec::H264
)
.is_err());
assert_eq!(fs::read_dir(output).unwrap().count(), 0);
}
#[test]
#[ignore = "requires FFmpeg, ffprobe and MediaMTX; uses isolated test streams"]
fn cast_recording_real_mux_preserves_video_audio_and_finalizes() {
struct Worker(Child);
impl Drop for Worker {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let port = || {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
};
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("videos");
let rtsp = port();
let api = port();
let whep = port();
let hls = port();
let config = dir.path().join("mediamtx.yml");
fs::write(&config,format!("logLevel: warn\nrtspAddress: 127.0.0.1:{rtsp}\nrtspTransports: [tcp]\napi: yes\napiAddress: 127.0.0.1:{api}\nrtmp: no\nhls: no\nhlsAddress: 127.0.0.1:{hls}\nwebrtc: no\nwebrtcAddress: 127.0.0.1:{whep}\nwebrtcLocalUDPAddress: :0\nwebrtcLocalTCPAddress: :0\nsrt: no\nmoq: no\npaths:\n headset:\n source: publisher\n")).unwrap();
let binary = std::env::var("FRAMELY_MEDIA_MTX").unwrap();
let _server = Worker(
Command::new(binary)
.arg(config)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let end = Instant::now() + Duration::from_secs(5);
while std::net::TcpStream::connect(("127.0.0.1", rtsp)).is_err() {
assert!(Instant::now() < end);
std::thread::sleep(Duration::from_millis(50));
}
for (codec, audio) in [("h264", true), ("hevc", true), ("h264", false)] {
let mut command = Command::new("ffmpeg");
command.args([
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-re",
"-f",
"lavfi",
"-i",
"testsrc2=size=160x96:rate=15",
]);
if audio {
command.args([
"-re",
"-f",
"lavfi",
"-i",
"sine=frequency=440:sample_rate=48000",
]);
}
command.args([
"-c:v",
if codec == "hevc" {
"libx265"
} else {
"libx264"
},
"-preset",
"ultrafast",
"-tune",
"zerolatency",
"-threads",
"1",
"-g",
"15",
]);
if codec == "hevc" {
command.args([
"-x265-params",
"log-level=error:pools=1:frame-threads=1:bframes=0",
]);
}
if audio {
command.args(["-c:a", "libopus"]);
} else {
command.arg("-an");
}
let address = format!("rtsp://127.0.0.1:{rtsp}/headset");
let _publisher = Worker(
command
.args(["-f", "rtsp", "-rtsp_transport", "tcp", &address])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let end = Instant::now() + Duration::from_secs(8);
loop {
if ureq::get(&format!("http://127.0.0.1:{api}/v3/paths/get/headset"))
.timeout(Duration::from_millis(500))
.call()
.ok()
.and_then(|r| r.into_json::<Value>().ok())
.is_some_and(|v| v["ready"] == true)
{
break;
}
assert!(Instant::now() < end, "publisher not ready");
std::thread::sleep(Duration::from_millis(50));
}
let mut recording = Recording::start(
rtsp,
dir.path(),
&output,
if codec == "hevc" {
super::super::settings::Codec::H265
} else {
super::super::settings::Codec::H264
},
)
.unwrap();
// Toggling viewing services must leave the RTSP publisher/recorder
// intact. Record-only initially has no WebRTC or HLS listeners.
assert!(std::net::TcpStream::connect(("127.0.0.1", whep)).is_err());
for enabled in [true, false] {
ureq::patch(&format!("http://127.0.0.1:{api}/v3/config/global/patch"))
.timeout(Duration::from_secs(3))
.send_json(json!({"webrtc":enabled,"hls":enabled}))
.unwrap();
let end = Instant::now() + Duration::from_secs(3);
while [whep, hls].iter().any(|port| {
std::net::TcpStream::connect(("127.0.0.1", *port)).is_ok() != enabled
}) {
assert!(Instant::now() < end, "viewing listeners did not switch");
std::thread::sleep(Duration::from_millis(20));
}
let path: Value =
ureq::get(&format!("http://127.0.0.1:{api}/v3/paths/get/headset"))
.timeout(Duration::from_secs(2))
.call()
.unwrap()
.into_json()
.unwrap();
assert_eq!(path["ready"], true, "viewing toggle interrupted capture");
assert!(!recording.exited().unwrap());
}
std::thread::sleep(Duration::from_millis(800));
let path = recording.finish().unwrap();
assert_eq!(path.extension().unwrap(), "mp4");
let probe = Command::new("ffprobe")
.args([
"-v",
"error",
"-show_streams",
"-show_format",
"-of",
"json",
])
.arg(&path)
.output()
.unwrap();
assert!(
probe.status.success(),
"{}",
String::from_utf8_lossy(&probe.stderr)
);
let data: Value = serde_json::from_slice(&probe.stdout).unwrap();
let streams = data["streams"].as_array().unwrap();
assert!(
data["format"]["format_name"]
.as_str()
.unwrap()
.contains("mp4"),
"{data}"
);
if codec == "hevc" {
assert!(
streams
.iter()
.any(|s| s["codec_name"] == "hevc" && s["codec_tag_string"] == "hvc1"),
"{data}"
);
}
assert!(
streams
.iter()
.any(|s| s["codec_name"] == codec && s["width"] == 160 && s["height"] == 96),
"{data}"
);
assert_eq!(
streams.iter().any(|s| s["codec_name"] == "aac"),
audio,
"{data}"
);
assert!(
data["format"]["duration"]
.as_str()
.unwrap()
.parse::<f64>()
.unwrap()
> 0.5,
"{data}"
);
let decoded = Command::new("ffmpeg")
.args(["-v", "error", "-xerror", "-i"])
.arg(&path)
.args(["-f", "null", "-"])
.output()
.unwrap();
assert!(
decoded.status.success(),
"saved recording is not decodable: {}",
String::from_utf8_lossy(&decoded.stderr)
);
if audio {
let pcm = Command::new("ffmpeg")
.args(["-v", "error", "-i"])
.arg(&path)
.args(["-map", "0:a:0", "-t", "0.2", "-f", "s16le", "pipe:1"])
.output()
.unwrap();
assert!(pcm.status.success());
assert!(
pcm.stdout
.chunks_exact(2)
.any(|s| i16::from_le_bytes([s[0], s[1]]).unsigned_abs() > 1000),
"recorded audio is silent"
);
}
println!(
"PASS: {codec}, audio={audio}, finalized {} bytes",
fs::metadata(path).unwrap().len()
);
drop(recording);
drop(_publisher);
std::thread::sleep(Duration::from_millis(250));
}
}
}
+271
View File
@@ -0,0 +1,271 @@
//! Projection metadata comes from the exact LUT used by panel capture.
use super::settings::{Eye, Output, Settings, Source};
use anyhow::{ensure, Context, Result};
use serde_json::{json, Value};
use std::{
io::Read,
path::Path,
process::{Command, Stdio},
time::{Duration, Instant},
};
#[derive(Default)]
pub struct Region {
pub editing: bool,
pub editor_id: String,
enabled: bool,
settings: Option<Settings>,
description: Option<Value>,
pub error: Option<String>,
}
pub fn supported(settings: &Settings) -> bool {
settings.source == Source::Screen && settings.output == Output::Eye
}
pub fn validate_description(value: &Value) -> Result<()> {
ensure!(value["projection"]=="factory-panel-v1" && matches!(value["eye"].as_u64(),Some(0|1)), "缺少有效的面板校准数据,无法显示准确录制区域");
let tan = value["tanHalfHorizontal"]
.as_f64()
.context("Missing capture FOV")?;
let aspect = value["aspect"].as_f64().context("Missing capture aspect")?;
ensure!(
tan.is_finite() && tan > 0. && aspect.is_finite() && aspect > 0.,
"Invalid capture view"
);
for name in ["centerTanX", "centerTanY"] {
ensure!(
value[name].as_f64().is_some_and(f64::is_finite),
"Invalid capture center"
);
}
let matrix: [[f64; 4]; 3] = serde_json::from_value(value["captureToHead"].clone())?;
ensure!(
matrix.iter().flatten().all(|v| v.is_finite()),
"Invalid capture transform"
);
for i in 0..3 {
for j in 0..3 {
let dot: f64 = (0..3).map(|k| matrix[i][k] * matrix[j][k]).sum();
ensure!(
(dot - if i == j { 1. } else { 0. }).abs() < 0.01,
"Invalid capture basis"
);
}
}
Ok(())
}
impl Region {
/// Last editor identity remains available after closing so the native host
/// can reconcile it on every poll, even if a one-shot close command is lost.
pub fn editor_state(&self) -> Value {
json!({"id":self.editor_id,"open":self.editing})
}
pub fn acknowledge_editor_closed(&mut self, id: &str) {
// A delayed acknowledgement for the previous window must not close a
// newer editor that has already been opened.
if self.editor_id == id {
self.editing = false;
}
}
pub fn require_visible(view: &Value) -> Result<()> {
ensure!(view["regionFullyVisible"] == true, "取景范围超出头显可见区域,请缩小或调整位置");
Ok(())
}
pub fn invalidate(&mut self) {
self.description = None;
self.settings = None;
}
pub fn refresh(
&mut self,
settings: &Settings,
binaries: &Path,
active: Option<Value>,
) -> Result<()> {
self.enabled = settings.recording_region_visible;
if !supported(settings) || (!self.enabled && !self.editing) || (active.is_none() && !self.editing) {
self.description = None;
self.settings = None;
self.error = None;
return Ok(());
}
if let Some(value) = active {
// Never substitute configured geometry for a live recording.
validate_description(&value)?;
if !self.editing { Self::require_visible(&value)?; }
self.description = Some(value);
self.settings = Some(settings.clone());
self.error = None;
return Ok(());
}
let eye = match settings.eye {
Eye::Left => 0,
Eye::Right => 1,
Eye::SteamVR => super::runtime::dominant_eye()?,
};
if self
.settings
.as_ref()
.is_some_and(|s| s.same_capture(settings))
&& self.description.as_ref().is_some_and(|d| d["eye"] == eye)
{
if !self.editing { Self::require_visible(self.description.as_ref().unwrap())?; }
return Ok(());
}
self.description = None;
self.settings = None;
let mut command = Command::new(binaries.join("framely-capture"));
command.args([
"--describe-view",
"--width",
&settings.width.to_string(),
"--height",
&settings.height.to_string(),
"--eye",
&eye.to_string(),
]);
command.args([
format!("--center-x={}", settings.center_x),
format!("--center-y={}", settings.center_y),
]);
if let Some(fov) = settings.horizontal_fov {
command.arg(format!("--fov={fov}"));
}
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.context("无法读取实际录制区域,请更新采集程序")?;
// Drain metadata while the worker runs: the visibility grid can be
// larger than a small pipe's capacity. Waiting first would deadlock.
let stdout=child.stdout.take().context("Missing geometry output")?;
let output=std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
let mut bytes=Vec::new();stdout.take(65537).read_to_end(&mut bytes)?;Ok(bytes)
});
let deadline = Instant::now() + Duration::from_secs(12);
let status = loop {
match child.try_wait() {
Ok(Some(status))=>break status,
Ok(None)=>{},
Err(error)=>{let _=child.kill();let _=child.wait();let _=output.join();return Err(error.into());}
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
let _ = output.join();
anyhow::bail!("读取录制区域超时");
}
std::thread::sleep(Duration::from_millis(20));
};
let bytes=output.join().map_err(|_|anyhow::anyhow!("Invalid geometry output reader"))??;
ensure!(
status.success(),
"无法读取实际录制区域,请确认 SteamVR 已启动"
);
ensure!(bytes.len() <= 65536, "Invalid capture geometry output");
let value: Value = serde_json::from_slice(&bytes)?;
validate_description(&value)?;
if !self.editing { Self::require_visible(&value)?; }
self.description = Some(value);
self.settings = Some(settings.clone());
self.error = None;
Ok(())
}
pub fn fail(&mut self, error: &anyhow::Error) {
self.description = None;
self.error = Some(format!("{error:#}"));
}
pub fn snapshot(&self, active:bool) -> Value {
json!({"visible":active && self.enabled && !self.editing && self.description.is_some(),"editing":self.editing,"view":self.description,"settings":self.settings,"error":self.error})
}
pub fn editor(&self) -> Result<Value> {
let view = self
.description
.as_ref()
.context("无法读取实际录制区域,请确认 SteamVR 已启动")?;
let reference=view["editorReferenceTan"].as_f64().filter(|v|v.is_finite()&&*v>0.).context("无法读取实际录制区域,请更新采集程序")?;
let visibility=&view["visibility"];
let n=visibility["size"].as_u64().filter(|n|(8..=256).contains(n)&&n%4==0).context("无法读取实际录制区域,请更新采集程序")?;
ensure!(visibility["extent"].as_f64()==Some(reference) && visibility["cells"].as_str().is_some_and(|s|s.len()==(n*n/4) as usize&&s.bytes().all(|c|c.is_ascii_hexdigit())),"无法读取实际录制区域,请更新采集程序");
Ok(
json!({"id":self.editor_id,"settings":self.settings,"view":view,"referenceTan":reference}),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recording_region_editor_close_state_survives_poll_and_rejects_stale_ack() {
let mut region=Region::default();
region.editor_id="first".into();region.editing=true;
assert_eq!(region.editor_state(),json!({"id":"first","open":true}));
region.acknowledge_editor_closed("first");
assert_eq!(region.editor_state(),json!({"id":"first","open":false}));
// Reading a state response must not consume the required close state.
assert_eq!(region.editor_state(),json!({"id":"first","open":false}));
region.editor_id="second".into();region.editing=true;
region.acknowledge_editor_closed("first");
assert_eq!(region.editor_state(),json!({"id":"second","open":true}));
region.acknowledge_editor_closed("second");
assert!(!region.editing);
}
#[test]
fn recording_region_drains_large_metadata_before_waiting() {
use std::os::unix::fs::PermissionsExt;
let directory=tempfile::tempdir().unwrap();
let view=json!({"projection":"factory-panel-v1","eye":0,"regionFullyVisible":true,"editorReferenceTan":1.7,"visibility":{"size":8,"extent":1.7,"cells":"f".repeat(16)},"tanHalfHorizontal":0.5,"aspect":4./3.,"centerTanX":0.,"centerTanY":0.,"captureToHead":[[1,0,0,0],[0,1,0,0],[0,0,1,0]],"padding":"x".repeat(48000)});
let worker=directory.path().join("framely-capture");
std::fs::write(&worker,format!("#!/bin/sh\nprintf '%s' '{}'\n",view)).unwrap();
std::fs::set_permissions(&worker,std::fs::Permissions::from_mode(0o700)).unwrap();
let mut region=Region::default();region.editing=true;
let started=Instant::now();
region.refresh(&Settings {eye:Eye::Left,..Settings::default()},directory.path(),None).unwrap();
assert!(started.elapsed()<Duration::from_secs(3));
assert_eq!(region.editor().unwrap()["referenceTan"],1.7);
}
#[test]
fn recording_region_visibility_persists_independently_of_capture() {
let root=tempfile::tempdir().unwrap();
let mut core=crate::tests::accepted_service(root.path(),1000).unwrap();
let before=core.handle("status",json!({})).unwrap()["database"]["casting"].clone();
core.handle("cast.region.visible.save",json!({"enabled":true})).unwrap();
let mut enabled=core.handle("status",json!({})).unwrap()["database"]["casting"].clone();
assert_eq!(enabled["recordingRegionVisible"],true);enabled["recordingRegionVisible"]=json!(false);assert_eq!(enabled,before);
drop(core);
let mut restored=crate::service::Service::load(root.path(),1000).unwrap();
assert_eq!(restored.handle("status",json!({})).unwrap()["database"]["casting"]["recordingRegionVisible"],true);
assert!(!serde_json::from_value::<Settings>(json!({})).unwrap().recording_region_visible);
}
#[test]
fn recording_region_rejects_missing_invalid_or_stale_geometry() {
let view = json!({"projection":"factory-panel-v1","eye":0,"regionFullyVisible":true,"editorReferenceTan":1.7,"visibility":{"size":8,"extent":1.7,"cells":"f".repeat(16)},"tanHalfHorizontal":1.,"aspect":4./3.,"centerTanX":0.1,"centerTanY":-0.1,"captureToHead":[[1,0,0,-0.032],[0,1,0,0],[0,0,1,0]]});
assert!(Region::require_visible(&json!({"regionFullyVisible":false})).is_err());
assert!(Region::require_visible(&json!({})).is_err());
validate_description(&view).unwrap();
let mut region = Region::default();
let mut settings = Settings::default();
settings.recording_region_visible = true;
region
.refresh(&settings, Path::new("/missing"), Some(view))
.unwrap();
assert_eq!(region.snapshot(true)["visible"], true);
assert_eq!(region.editor().unwrap()["referenceTan"],1.7);
let mut smaller=region.description.clone().unwrap();smaller["tanHalfHorizontal"]=json!(0.1);
region.refresh(&settings,Path::new("/missing"),Some(smaller)).unwrap();
assert_eq!(region.editor().unwrap()["referenceTan"],1.7);
assert_eq!(region.snapshot(false)["visible"], false);
region.refresh(&settings,Path::new("/missing"),None).unwrap();
assert_eq!(region.snapshot(true)["visible"], false);
assert!(region
.refresh(&settings, Path::new("/missing"), Some(json!({})))
.is_err());
region.fail(&anyhow::anyhow!("invalid"));
assert_eq!(region.snapshot(true)["visible"], false);
settings.output = Output::Raw;
region
.refresh(&settings, Path::new("/missing"), None)
.unwrap();
assert_eq!(region.snapshot(true)["visible"], false);
}
}
+168
View File
@@ -0,0 +1,168 @@
use super::{
runtime::Casting,
settings::{Eye, Settings},
};
use serde_json::Value;
use std::{
fs,
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant},
};
#[test]
#[ignore = "displays calibration markers on Steam Frame; requires explicit user approval"]
fn recording_region_device_alignment() {
let media = PathBuf::from(std::env::var("FRAMELY_REGION_TEST_MEDIA").unwrap());
let marker = std::env::var("FRAMELY_REGION_TEST_MARKER").unwrap();
let directory = tempfile::tempdir().unwrap();
for (eye, width, height, fov, cx, cy) in [
(Eye::Left, 640, 480, None, 0., 0.),
(Eye::Right, 640, 360, Some(55.), 8., -5.),
(Eye::Left, 360, 640, Some(45.), -6., 4.),
(Eye::Right, 1920, 1440, Some(70.), 0., 0.),
(Eye::Right, 2560, 1600, Some(76.47219658546469), 0., -1.504517427660815),
(Eye::Left, 2560, 1600, Some(76.47219658546469), 0., -1.504517427660815),
] {
let mut command = Command::new(media.join("framely-capture"));
command.args([
"--describe-view",
"--eye",
if eye == Eye::Left { "0" } else { "1" },
"--width",
&width.to_string(),
"--height",
&height.to_string(),
]);
command.args([format!("--center-x={cx}"), format!("--center-y={cy}")]);
if let Some(fov) = fov {
command.arg(format!("--fov={fov}"));
}
let output = command.output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let mut view: Value = serde_json::from_slice(&output.stdout).unwrap();
super::region::validate_description(&view).unwrap();
view["width"] = width.into();
view["height"] = height.into();
let geometry = directory.path().join("geometry.json");
let ready = directory.path().join("ready");
let stop = directory.path().join("stop");
let _ = fs::remove_file(&ready);
let _ = fs::remove_file(&stop);
fs::write(&geometry, view.to_string()).unwrap();
let mut overlay = Command::new(&marker)
.arg(&geometry)
.arg(&ready)
.arg(&stop)
.stdout(Stdio::null())
.spawn()
.unwrap();
let end = Instant::now() + Duration::from_secs(5);
while !ready.exists() {
assert!(overlay.try_wait().unwrap().is_none());
assert!(Instant::now() < end);
std::thread::sleep(Duration::from_millis(20));
}
let helper = crate::ipc::call(
Path::new("/run/framely/control.sock"),
"cast.panel.start",
serde_json::json!({}),
)
.unwrap();
let socket = PathBuf::from(helper["socket"].as_str().unwrap());
let mut cast = Casting::new(directory.path().join("capture"), media.clone());
let settings = Settings {
eye,
width,
height,
horizontal_fov: fov,
center_x: cx,
center_y: cy,
system_audio: false,
..Settings::default()
};
let result = (|| -> anyhow::Result<Vec<u8>> {
let status=cast.start(&settings, Some(&socket))?;
super::region::validate_description(&status["view"])?;
for key in ["tanHalfHorizontal","centerTanX","centerTanY","aspect"] {
anyhow::ensure!((status["view"][key].as_f64().unwrap()-view[key].as_f64().unwrap()).abs()<1e-10,"described and active capture differ: {key}");
}
let frame = Command::new("ffmpeg")
.args([
"-v",
"error",
"-rtsp_transport",
"tcp",
"-i",
&format!("rtsp://127.0.0.1:{}/headset", cast.test_rtsp_port()),
"-frames:v",
"1",
"-pix_fmt",
"rgb24",
"-f",
"rawvideo",
"pipe:1",
])
.output()?;
anyhow::ensure!(
frame.status.success(),
"{}",
String::from_utf8_lossy(&frame.stderr)
);
Ok(frame.stdout)
})();
cast.stop();
fs::write(&stop, b"stop").unwrap();
let _ = overlay.wait();
let rgb = result.unwrap();
assert_eq!(rgb.len(), width as usize * height as usize * 3);
let positions = [
(16, 16),
(width - 16, 16),
(16, height - 16),
(width - 16, height - 16),
(width / 2, height / 2),
];
for (index, (x, y)) in positions.into_iter().enumerate() {
let mut n = 0.;
let mut sx = 0.;
let mut sy = 0.;
for dy in -24..24 {
for dx in -24..24 {
let px = x as i32 + dx;
let py = y as i32 + dy;
if px < 0 || py < 0 || px >= width as i32 || py >= height as i32 { continue; }
let i = (py as usize * width as usize + px as usize) * 3;
let (r, g, b) = (rgb[i], rgb[i + 1], rgb[i + 2]);
let hit = match index {
0 => r > 160 && g < 100 && b < 100,
1 => g > 160 && r < 100 && b < 100,
2 => b > 160 && r < 100 && g < 100,
3 => r > 160 && b > 160 && g < 100,
_ => r > 200 && g > 200 && b > 200,
};
if hit {
n += 1.;
sx += px as f64;
sy += py as f64;
}
}
}
assert!(n > 400., "fiducial {index} missing for {eye:?}: {n} pixels");
let error = (sx / n - (x as f64 - 0.5))
.abs()
.max((sy / n - (y as f64 - 0.5)).abs());
println!(
"ALIGNMENT {eye:?} {width}x{height} marker={index} error={error:.2}px pixels={n}"
);
assert!(
error <= 3.,
"recording border does not match sampled image: {error:.2}px"
);
}
}
}
+300 -24
View File
@@ -15,6 +15,7 @@ pub struct Casting {
pub binaries: PathBuf,
workers: Vec<Child>,
started: Option<Instant>,
streaming: bool,
pub whep_port: u16,
rtsp_port: u16,
api_port: u16,
@@ -26,6 +27,10 @@ pub struct Casting {
pub stream_key: String,
error: Option<String>,
preview_restore: Option<Option<Settings>>,
recording: Option<super::recording::Recording>,
recording_error: Option<String>,
recording_path: Option<PathBuf>,
pub region: super::region::Region,
}
fn port() -> Result<u16> {
Ok(std::net::TcpListener::bind("127.0.0.1:0")?
@@ -39,6 +44,7 @@ impl Casting {
binaries,
workers: Vec::new(),
started: None,
streaming: false,
whep_port: 0,
rtsp_port: 0,
api_port: 0,
@@ -50,9 +56,35 @@ impl Casting {
stream_key: String::new(),
error: None,
preview_restore: None,
recording: None,
recording_error: None,
recording_path: None,
region: Default::default(),
}
}
pub fn status(&mut self) -> Value {
self.poll_recording();
self.poll_workers();
let view = fs::File::open(self.directory.join("stream.log")).ok().and_then(|file| { let mut log = String::new(); file.take(65536).read_to_string(&mut log).ok()?; Some(log) }).and_then(|log| log.lines().filter_map(|line|serde_json::from_str::<Value>(line).ok()).find(|v|v["event"]=="view"));
json!({"running":self.streaming && self.started.is_some(),"error":self.error,"preview":self.preview_restore.is_some(),"view":view,
"watchPath":if self.streaming && self.started.is_some(){Some("/cast/watch")}else{None},
"elapsedSeconds":if self.streaming {self.started.map(|t|t.elapsed().as_secs())} else {None},
"codec":self.active_settings.as_ref().map(|s|s.codec.name()),"dominantEye":dominant_eye().ok(),"recording":self.recording_status()})
}
pub fn refresh_region(&mut self, configured: &Settings) -> Result<()> {
let mut effective=self.active_settings.clone().unwrap_or_else(||configured.clone());
effective.recording_region_visible=configured.recording_region_visible;
let active=if self.started.is_some() && super::region::supported(&effective) && (effective.recording_region_visible || self.region.editing) {
Some(self.status()["view"].clone())
} else {None};
let result=self.region.refresh(&effective,&self.binaries,active);
if let Err(error)=&result {self.region.fail(error);}
result
}
pub fn region_status(&self) -> Value {
self.region.snapshot((self.streaming && self.started.is_some()) || self.recording.is_some())
}
fn poll_workers(&mut self) {
let exited = self
.workers
.iter_mut()
@@ -61,13 +93,65 @@ impl Casting {
self.error = Some(format!("串流进程已退出:{code}"));
self.stop_workers();
}
let view = fs::File::open(self.directory.join("stream.log")).ok().and_then(|file| { let mut log = String::new(); file.take(65536).read_to_string(&mut log).ok()?; Some(log) }).and_then(|log| log.lines().filter_map(|line|serde_json::from_str::<Value>(line).ok()).find(|v|v["event"]=="view"));
json!({"running":self.started.is_some(),"error":self.error,"preview":self.preview_restore.is_some(),"view":view,
"watchPath":if self.started.is_some(){Some("/cast/watch")}else{None},
"elapsedSeconds":self.started.map(|t|t.elapsed().as_secs()),
"codec":self.active_settings.as_ref().map(|s|s.codec.name()),"dominantEye":dominant_eye().ok()})
}
pub fn recording_status(&self) -> Value {
let mut value = self.recording.as_ref().map(|r|r.status()).unwrap_or(json!({"active":false,"path":self.recording_path}));
value["error"] = json!(self.recording_error);
value
}
fn poll_recording(&mut self) {
if self.recording.as_mut().is_some_and(|r|r.exited().unwrap_or(true)) {
self.recording_error = Some(format!("录制进程意外退出:{}",self.recording.as_ref().unwrap().detail()));
let _ = self.stop_recording();
}
}
pub fn validate_recording_settings(&self, settings: &Settings) -> Result<()> {
ensure!(self.recording.is_none() || self.active_settings.as_ref().is_some_and(|s|s.same_capture(settings)), "请先停止录制再调整取景");
Ok(())
}
pub fn record(&mut self, settings: &Settings, panel_socket: Option<&Path>, output: &Path) -> Result<Value> {
ensure!(!self.region.editing, "请先关闭画幅调整窗口");
self.status();
ensure!(self.preview_restore.is_none(), "取景预览期间不能录制");
ensure!(self.recording.is_none(), "录制已在运行");
let capture_started = self.started.is_none();
if capture_started {
self.start_capture(settings, panel_socket, false)?;
}
let result = self.start_recording(output);
if result.is_err() && capture_started { self.stop_workers(); }
result
}
fn start_recording(&mut self, output: &Path) -> Result<Value> {
self.status();
ensure!(self.started.is_some(), "请先开始串流");
ensure!(self.preview_restore.is_none(), "取景预览期间不能录制");
ensure!(self.recording.is_none(), "录制已在运行");
self.recording_error = None;
match super::recording::Recording::start(self.rtsp_port, &self.directory, output, self.codec()) {
Ok(recording) => {self.recording = Some(recording); Ok(self.recording_status())},
Err(error) => {self.recording_error = Some(format!("{error:#}")); Err(error)}
}
}
pub fn stop_recording(&mut self) -> Result<Value> {
let result = self.finish_recording();
if !self.streaming {self.stop_workers();}
result
}
fn finish_recording(&mut self) -> Result<Value> {
if let Some(mut recording) = self.recording.take() {
match recording.finish() {
Ok(path) => self.recording_path = Some(path),
Err(error) => {self.recording_error = Some(format!("{error:#}")); return Err(error);}
}
}
Ok(self.recording_status())
}
pub fn stop_recording_id(&mut self, id: &str) {
if self.recording.as_ref().is_some_and(|r|r.id == id) {let _ = self.stop_recording();}
}
fn stop_workers(&mut self) {
let _ = self.finish_recording();
for child in &mut self.workers {
if child.try_wait().ok().flatten().is_some() {
continue;
@@ -93,13 +177,22 @@ impl Casting {
self.dlna_started = false;
self.stop_vp8();
self.active_settings = None;
self.region.invalidate();
self.started = None;
self.streaming = false;
self.stream_key.clear();
}
/// Negotiate with browsers that omit H.264 (including the bundled CEF).
/// All VP8 viewers share one on-demand compatibility encoder.
#[cfg(test)]
pub(crate) fn test_rtsp_port(&self) -> u16 {self.rtsp_port}
#[cfg(test)]
pub(crate) fn test_whep_server(&mut self, port: u16) {
self.started = Some(Instant::now());
self.streaming = true;
self.active_settings = Some(Settings::default());
self.whep_port = port;
}
pub fn codec(&self) -> Codec { self.active_settings.as_ref().map(|s|s.codec).unwrap_or_default() }
pub fn ensure_vp8(&mut self) -> Result<()> {
ensure!(self.status()["running"] == true, "请先开始头显串流");
@@ -189,6 +282,8 @@ impl Casting {
}
}
pub fn maintain(&mut self) {
self.poll_recording();
self.poll_workers();
if self.compatibility_poll.elapsed() < Duration::from_secs(2) {
return;
}
@@ -277,7 +372,26 @@ impl Casting {
self.error = None;
json!(true)
}
fn configure_streaming(&mut self, enabled: bool) -> Result<()> {
ureq::AgentBuilder::new().redirects(0).timeout(Duration::from_secs(3)).build()
.patch(&format!("http://127.0.0.1:{}/v3/config/global/patch", self.api_port))
.send_json(json!({"webrtc":enabled,"hls":enabled}))
.context("无法切换串流服务")?;
self.streaming = enabled;
if !enabled {self.stream_key.clear();self.stop_vp8();}
else {self.stream_key = crate::session::cast_random_key();}
Ok(())
}
pub fn stop_streaming(&mut self) -> Result<Value> {
self.status();
if self.recording.is_some() {
if self.streaming {self.configure_streaming(false)?;}
} else {self.stop();}
Ok(json!(true))
}
pub fn start_preview(&mut self, settings: &Settings, socket: &Path) -> Result<Value> {
ensure!(!self.region.editing, "请先关闭画幅调整窗口");
ensure!(self.recording.is_none(), "请先停止录制再调整取景");
ensure!(self.preview_restore.is_none(), "取景预览已在运行");
let restore = self.active_settings.clone();
self.stop_workers();
@@ -318,12 +432,27 @@ impl Casting {
Ok(())
}
pub fn start(&mut self, settings: &Settings, panel_socket: Option<&Path>) -> Result<Value> {
ensure!(!self.region.editing, "请先关闭画幅调整窗口");
self.status();
ensure!(!self.streaming, "串流已在运行");
if self.started.is_some() {
// Recording already owns the encoder. Enabling viewing must not
// restart it or change the recording's resolution/bitrate.
ensure!(self.active_settings.as_ref().is_some_and(|s|s.same_capture(settings)), "请先停止录制再调整取景");
self.configure_streaming(true)?;
return Ok(self.status());
}
self.start_capture(settings, panel_socket, true)
}
fn start_capture(&mut self, settings: &Settings, panel_socket: Option<&Path>, streaming: bool) -> Result<Value> {
self.region.invalidate();
settings.validate()?;
ensure!(self.started.is_none(), "串流已在运行");
fs::create_dir_all(&self.directory)?;
fs::set_permissions(&self.directory, fs::Permissions::from_mode(0o700))?;
// A new session does not accumulate unbounded log history.
fs::write(self.directory.join("stream.log"), [])?;
self.streaming = streaming;
let result = self.start_inner(settings, panel_socket);
if let Err(e) = &result {
use std::io::{Read, Seek, SeekFrom};
@@ -353,8 +482,8 @@ impl Casting {
self.api_port = port()?;
self.whep_port = port()?;
self.hls_port = port()?;
self.stream_key = crate::session::cast_random_key();
let configuration = format!("logLevel: warn\nrtspAddress: 127.0.0.1:{rtsp}\nrtspTransports: [tcp]\nrtmp: no\nmoq: no\nsrt: no\napi: yes\napiAddress: 127.0.0.1:{}\nmetrics: no\nplayback: no\nwebrtc: yes\nwebrtcAddress: 127.0.0.1:{}\nwebrtcLocalUDPAddress: :0\nwebrtcLocalTCPAddress: :0\nhls: yes\nhlsAddress: 127.0.0.1:{}\nhlsVariant: mpegts\nhlsSegmentCount: 3\nhlsSegmentDuration: 1s\npaths:\n headset:\n source: publisher\n dlna:\n source: publisher\n headset_vp8:\n source: publisher\n", self.api_port, self.whep_port, self.hls_port);
self.stream_key = if self.streaming {crate::session::cast_random_key()} else {String::new()};
let configuration = format!("logLevel: warn\nrtspAddress: 127.0.0.1:{rtsp}\nrtspTransports: [tcp]\nrtmp: no\nmoq: no\nsrt: no\napi: yes\napiAddress: 127.0.0.1:{}\nmetrics: no\nplayback: no\nwebrtc: {}\nwebrtcAddress: 127.0.0.1:{}\nwebrtcLocalUDPAddress: :0\nwebrtcLocalTCPAddress: :0\nhls: {}\nhlsAddress: 127.0.0.1:{}\nhlsVariant: mpegts\nhlsSegmentCount: 3\nhlsSegmentDuration: 1s\npaths:\n headset:\n source: publisher\n dlna:\n source: publisher\n headset_vp8:\n source: publisher\n", self.api_port, self.streaming, self.whep_port, self.streaming, self.hls_port);
let config_path = self.directory.join("mediamtx.yml");
fs::write(&config_path, configuration)?;
self.spawn(Command::new(mediamtx).arg(config_path))?;
@@ -409,11 +538,11 @@ impl Casting {
if let Some(fov) = s.horizontal_fov {
command.args(["--fov", &fov.to_string()]);
}
// Bind signed values to their options: older capture workers'
// clap parser treats a separate negative number as a new flag.
command.args([
"--center-x",
&s.center_x.to_string(),
"--center-y",
&s.center_y.to_string(),
format!("--center-x={}", s.center_x),
format!("--center-y={}", s.center_y),
]);
}
bind_to_session(&mut command);
@@ -452,6 +581,11 @@ impl Casting {
"0",
"-framerate",
&s.fps.to_string(),
// Timestamp raw packets before FFmpeg schedules the inputs.
// Use the capture frame count rather than arrival wallclock:
// pipe reads can batch frames and distort scheduler pacing.
"-bsf:v",
&video_packet_timestamps(s.fps),
"-f",
if s.codec == Codec::H265 {"hevc"} else {"h264"},
"-i",
@@ -460,7 +594,6 @@ impl Casting {
} else {
ffmpeg.args(["-thread_queue_size", "4", "-f", "v4l2", "-i", "/dev/video99"]);
}
let audio = s.system_audio || s.microphone;
if s.system_audio {
add_pulse_input(&mut ffmpeg, "@DEFAULT_MONITOR@");
}
@@ -468,22 +601,15 @@ impl Casting {
add_pulse_input(&mut ffmpeg, "default");
}
ffmpeg.args(["-map", "0:v:0"]);
if s.system_audio && s.microphone {
ffmpeg.args([
"-filter_complex",
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0[a]",
"-map",
"[a]",
]);
} else if audio {
ffmpeg.args(["-map", "1:a:0"]);
}
let audio = map_audio(&mut ffmpeg, s.system_audio, s.microphone);
if s.source == Source::Screen {
ffmpeg.args(["-c:v", "copy"]);
if s.codec==Codec::H265 {
if s.codec == Codec::H265 {
// Iris HEVC headers omit reliable frame timing. Write the requested
// timing without decoding/re-encoding, avoiding false 50 fps playback.
ffmpeg.args(["-bsf:v",&format!("hevc_metadata=tick_rate={}:num_ticks_poc_diff_one=1",s.fps)]);
ffmpeg.args(["-bsf:v", &format!(
"hevc_metadata=tick_rate={}:num_ticks_poc_diff_one=1", s.fps
)]);
}
} else {
ffmpeg.args(["-vf", &format!("fps={},scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2,format=yuv420p",s.fps,s.width,s.height,s.width,s.height),
@@ -598,6 +724,29 @@ fn add_pulse_input(command: &mut Command, device: &str) {
command.args(["-thread_queue_size", "4", "-f", "pulse", "-sample_rate", "48000", "-channels", "2", "-fragment_size", "3840", "-i", device]);
}
// Pulse capture timestamps use the audio server's clock, while raw panel video
// starts at zero. Generate audio PTS from the sample count in that same origin.
// libopus low-delay mode subtracts its 120-sample pre-roll from packet PTS, so
// offset the input by 120 samples and keep the first RTSP packet non-negative.
fn map_audio(command: &mut Command, system_audio: bool, microphone: bool) -> bool {
let audio = system_audio || microphone;
if system_audio && microphone {
command.args([
"-filter_complex",
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0,asetpts=(N+120)/SR/TB[a]",
"-map",
"[a]",
]);
} else if audio {
command.args(["-map", "1:a:0", "-af", "asetpts=(N+120)/SR/TB"]);
}
audio
}
fn video_packet_timestamps(fps: u32) -> String {
format!("setts=time_base=1/{fps}:pts=N:dts=N:duration=1")
}
pub fn bind_to_session(command: &mut Command) {
// Linux PDEATHSIG follows the spawning thread, not the session process.
// HTTP request threads end immediately after returning a response. Worker
@@ -609,6 +758,133 @@ pub fn bind_to_session(command: &mut Command) {
mod tests {
use super::*;
#[test]
fn audio_mapping_generates_monotonic_timestamps() {
let arguments = |system_audio, microphone| {
let mut command = Command::new("ffmpeg");
let enabled = map_audio(&mut command, system_audio, microphone);
let arguments = command
.get_args()
.map(|argument| argument.to_string_lossy().into_owned())
.collect::<Vec<_>>();
(enabled, arguments)
};
assert_eq!(arguments(false, false), (false, vec![]));
for inputs in [(true, false), (false, true)] {
assert_eq!(
arguments(inputs.0, inputs.1),
(
true,
vec!["-map", "1:a:0", "-af", "asetpts=(N+120)/SR/TB"]
.into_iter()
.map(str::to_owned)
.collect()
)
);
}
assert_eq!(
arguments(true, true),
(
true,
vec![
"-filter_complex",
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0,asetpts=(N+120)/SR/TB[a]",
"-map",
"[a]",
]
.into_iter()
.map(str::to_owned)
.collect()
)
);
}
#[test]
fn cast_recording_only_stays_private_and_releases_capture_on_stop() {
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
let mut cast=Casting::new(dir.path().into(),dir.path().into());
cast.started=Some(Instant::now());cast.active_settings=Some(Settings::default());
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
let status=cast.status();
assert_eq!(status["running"],false);assert_eq!(status["recording"]["active"],true);
assert!(status["watchPath"].is_null());assert!(cast.stream_key.is_empty());
cast.stop_streaming().unwrap();assert!(cast.recording.is_some());
let id=cast.recording.as_ref().unwrap().id.clone();cast.stop_recording_id(&id);
assert!(cast.started.is_none());assert!(cast.active_settings.is_none());
assert_eq!(cast.recording_path.as_ref().unwrap().extension().unwrap(),"mp4");
}
#[test]
fn cast_recording_stream_toggle_keeps_encoder_and_recording() {
use tiny_http::{Response,Server};
let server=Server::http("127.0.0.1:0").unwrap();
let port=server.server_addr().to_ip().unwrap().port();
let worker=std::thread::spawn(move || {
for enabled in [true,false,true] {
let mut req=server.recv().unwrap();assert_eq!(req.method(),&tiny_http::Method::Patch);
assert_eq!(req.url(),"/v3/config/global/patch");
let value:Value=serde_json::from_reader(req.as_reader()).unwrap();
assert_eq!(value,json!({"webrtc":enabled,"hls":enabled}));
req.respond(Response::from_string("{}")).unwrap();
}
});
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
let mut cast=Casting::new(dir.path().into(),dir.path().into());
let settings=Settings::default();cast.api_port=port;
cast.started=Some(Instant::now());cast.active_settings=Some(settings.clone());
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
let started=cast.started;let id=cast.recording.as_ref().unwrap().id.clone();
assert_eq!(cast.start(&settings,None).unwrap()["running"],true);
cast.stop_streaming().unwrap();assert_eq!(cast.status()["running"],false);
assert_eq!(cast.recording.as_ref().unwrap().id,id);assert_eq!(cast.started,started);
assert_eq!(cast.start(&settings,None).unwrap()["running"],true);
cast.stop_recording().unwrap();assert!(cast.streaming);assert_eq!(cast.started,started);
worker.join().unwrap();cast.stop();
}
#[test]
fn cast_recording_capture_settings_are_shared_and_locked() {
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
let mut cast=Casting::new(dir.path().into(),dir.path().into());
let mut settings=Settings::default();assert_eq!(settings.width*3,settings.height*4);
cast.started=Some(Instant::now());cast.active_settings=Some(settings.clone());
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
settings.height=1080;assert!(cast.validate_recording_settings(&settings).is_err());
assert!(cast.start(&settings,None).is_err());
settings.height=1440;settings.bitrate_mbps+=1;assert!(cast.validate_recording_settings(&settings).is_err());
settings.bitrate_mbps-=1;settings.airplay=true;settings.receiver_name="Another name".into();
assert!(cast.validate_recording_settings(&settings).is_ok());
}
#[test]
fn cast_recording_stale_indicator_does_not_stop_new_recording_and_stream_stop_finishes() {
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
let mut cast=Casting::new(dir.path().into(),dir.path().into());
assert!(cast.start_recording(&output).is_err());
cast.started=Some(Instant::now());
cast.streaming=true;
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
assert!(cast.start_recording(&output).is_err());
assert!(cast.start_preview(&Settings::default(),dir.path()).is_err());
cast.stop_recording_id("old-recording-id");assert!(cast.recording.is_some());
let id=cast.recording.as_ref().unwrap().id.clone();cast.stop_recording_id(&id);
assert!(cast.recording.is_none());assert!(cast.started.is_some());
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
cast.stop();assert!(cast.recording.is_none());assert!(cast.started.is_none());
assert_eq!(fs::read_to_string(cast.recording_path.as_ref().unwrap()).unwrap(),"header\ntrailer\n");
}
#[test]
fn cast_recording_process_failure_keeps_stream_and_reports_error() {
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
let mut cast=Casting::new(dir.path().into(),dir.path().into());cast.started=Some(Instant::now());cast.streaming=true;
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,true));
let end=Instant::now()+Duration::from_secs(3);
while cast.recording.is_some()&&Instant::now()<end {cast.maintain();std::thread::sleep(Duration::from_millis(25));}
assert!(cast.recording.is_none());assert!(cast.started.is_some());assert!(cast.recording_error.is_some());
}
#[test]
fn copied_video_packets_receive_frame_timestamps() {
assert_eq!(
video_packet_timestamps(60),
"setts=time_base=1/60:pts=N:dts=N:duration=1"
);
}
#[test]
fn media_worker_survives_the_http_request_thread() {
let mut child = std::thread::spawn(|| {
let mut command = Command::new("sleep");
+11 -1
View File
@@ -56,6 +56,7 @@ pub struct Settings {
pub airplay: bool,
pub dlna: bool,
pub receiver_name: String,
pub recording_region_visible: bool,
}
impl Default for Settings {
fn default() -> Self {
@@ -65,7 +66,7 @@ impl Default for Settings {
output: Output::Eye,
eye: Eye::SteamVR,
width: 1920,
height: 1080,
height: 1440,
fps: 30,
bitrate_mbps: 8,
horizontal_fov: None,
@@ -76,10 +77,19 @@ impl Default for Settings {
airplay: false,
dlna: false,
receiver_name: "Framely".into(),
recording_region_visible: false,
}
}
}
impl Settings {
pub fn same_capture(&self, other: &Self) -> bool {
let mut capture = self.clone();
capture.airplay = other.airplay;
capture.dlna = other.dlna;
capture.receiver_name.clone_from(&other.receiver_name);
capture.recording_region_visible = other.recording_region_visible;
capture == *other
}
pub fn validate(&self) -> Result<()> {
ensure!(
(160..=4096).contains(&self.width)
+100
View File
@@ -0,0 +1,100 @@
//! Persistent, watch-only capability; deliberately separate from cast settings.
use anyhow::{ensure, Result};
use serde::{Deserialize, Serialize};
use std::{fs, io::Write, os::unix::fs::OpenOptionsExt, path::Path};
#[derive(Default, Serialize, Deserialize)]
pub struct Grant {
pub token: Option<String>,
}
impl Grant {
pub fn load(root: &Path) -> Result<Self> {
let bytes = match fs::read(root.join("cast-watch-access.json")) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => return Err(e.into()),
};
let grant: Self = serde_json::from_slice(&bytes)?;
ensure!(grant.token.as_ref().is_none_or(|t| t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())), "Invalid watch access record");
Ok(grant)
}
pub fn save(root: &Path, enabled: bool) -> Result<Self> {
let mut grant = Self::load(root)?;
if enabled && grant.token.is_none() {
grant.token = Some(crate::session::cast_random_key());
} else if !enabled {
grant.token = None;
}
let temporary = root.join("cast-watch-access.json.tmp");
let mut file = fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(&temporary)?;
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
let result = (|| -> Result<()> {
file.write_all(&serde_json::to_vec(&grant)?)?;
file.sync_all()?;
fs::rename(&temporary, root.join("cast-watch-access.json"))?;
fs::File::open(root)?.sync_all()?;
Ok(())
})();
if result.is_err() { let _ = fs::remove_file(&temporary); }
result?;
Ok(grant)
}
}
#[derive(Default)]
pub struct Access {
pub token: Option<String>,
pub sessions: std::collections::BTreeMap<String, String>,
pub revoked: Vec<String>,
}
impl Access {
pub fn accepts(&self, candidate: &str) -> bool {
self.token.as_deref().is_some_and(|token| {
token.len() == candidate.len() && token.bytes().zip(candidate.bytes()).fold(0u8, |diff, (a,b)| diff | (a ^ b)) == 0
})
}
pub fn replace(&mut self, token: Option<String>) {
if self.token != token {
self.revoked.extend(std::mem::take(&mut self.sessions).into_values());
self.token = token;
}
}
pub fn path(&self) -> String {
match &self.token {
Some(token) => format!("/cast/watch?token={token}"),
None => "/cast/watch".into(),
}
}
pub fn disconnect_revoked(&mut self) {
let client = ureq::AgentBuilder::new().redirects(0).timeout(std::time::Duration::from_secs(2)).build();
self.revoked.retain(|target| {
match client.delete(target).set("If-Match", "*").call() {
Ok(_) | Err(ureq::Error::Status(404 | 410, _)) => false,
Err(_) => true,
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn watch_access_persists_rotates_and_preserves_on_failure() {
let root = tempfile::tempdir().unwrap();
assert!(Grant::load(root.path()).unwrap().token.is_none());
let first = Grant::save(root.path(), true).unwrap().token.unwrap();
assert_eq!(first.len(), 64);
assert_eq!(Grant::load(root.path()).unwrap().token.as_ref(), Some(&first));
assert_eq!(Grant::save(root.path(), true).unwrap().token.as_ref(), Some(&first));
assert_eq!(fs::metadata(root.path().join("cast-watch-access.json")).unwrap().permissions().mode() & 0o777, 0o600);
fs::create_dir(root.path().join("cast-watch-access.json.tmp")).unwrap();
assert!(Grant::save(root.path(), false).is_err());
assert_eq!(Grant::load(root.path()).unwrap().token.as_ref(), Some(&first));
fs::remove_dir(root.path().join("cast-watch-access.json.tmp")).unwrap();
assert!(Grant::save(root.path(), false).unwrap().token.is_none());
assert_ne!(Grant::save(root.path(), true).unwrap().token.unwrap(), first);
}
}
+167 -7
View File
@@ -45,6 +45,20 @@ pub(crate) fn interruptible<T: Send + 'static>(
cancel: &crate::jobs::Cancellation,
timeout: Duration,
action: impl FnOnce(crate::jobs::Cancellation) -> Result<T> + Send + 'static,
) -> Result<T> {
interruptible_inner(cancel, Some(timeout), action)
}
/// Downloads have an idle socket timeout, not a wall-clock deadline.
pub(crate) fn interruptible_download<T: Send + 'static>(
cancel: &crate::jobs::Cancellation,
action: impl FnOnce(crate::jobs::Cancellation) -> Result<T> + Send + 'static,
) -> Result<T> {
interruptible_inner(cancel, None, action)
}
fn interruptible_inner<T: Send + 'static>(
cancel: &crate::jobs::Cancellation,
timeout: Option<Duration>,
action: impl FnOnce(crate::jobs::Cancellation) -> Result<T> + Send + 'static,
) -> Result<T> {
use std::sync::{atomic::Ordering, mpsc};
cancel.check()?;
@@ -78,12 +92,17 @@ pub(crate) fn interruptible<T: Send + 'static>(
let _permit = permit;
let _ = tx.send(action(worker_token));
})?;
let deadline = std::time::Instant::now() + timeout;
let deadline = timeout.map(|timeout| std::time::Instant::now() + timeout);
loop {
cancel.check()?;
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
ensure!(!remaining.is_zero(), "网络请求超时");
match rx.recv_timeout(remaining.min(Duration::from_millis(50))) {
let wait = if let Some(deadline) = deadline {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
ensure!(!remaining.is_zero(), "网络请求超时");
remaining.min(Duration::from_millis(50))
} else {
Duration::from_millis(50)
};
match rx.recv_timeout(wait) {
Ok(result) => return result,
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => bail!("网络请求任务异常"),
@@ -109,7 +128,39 @@ pub fn get_with_proxy_cancel(
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
get_blocking(&initial, allow_http, timeout, &refs, &proxy, &token)
get_blocking(&initial, allow_http, timeout, &refs, &proxy, &token, false)
})
}
/// Bound connection/individual socket waits while allowing a progressing body
/// to take arbitrarily long. Metadata requests retain their total deadlines.
pub fn get_download_with_proxy(
initial: &str,
allow_http: bool,
idle_timeout: Duration,
headers: &[(&str, &str)],
proxy: &crate::model::ProxySettings,
) -> Result<ureq::Response> {
let initial = initial.to_owned();
let proxy = proxy.clone();
let headers: Vec<_> = headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
// Only obtaining headers has a total deadline; the returned body does not.
interruptible(&Default::default(), idle_timeout, move |token| {
let refs: Vec<_> = headers
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
get_blocking(
&initial,
allow_http,
idle_timeout,
&refs,
&proxy,
&token,
true,
)
})
}
fn get_blocking(
@@ -119,14 +170,22 @@ fn get_blocking(
headers: &[(&str, &str)],
proxy: &crate::model::ProxySettings,
cancel: &crate::jobs::Cancellation,
download: bool,
) -> Result<ureq::Response> {
cancel.check()?;
proxy.validate()?;
crate::model::validate_url(initial, allow_http)?;
let mut builder = ureq::AgentBuilder::new()
.timeout(timeout)
let builder = ureq::AgentBuilder::new()
.redirects(0)
.try_proxy_from_env(false);
let mut builder = if download {
builder
.timeout_connect(timeout)
.timeout_read(timeout)
.timeout_write(timeout)
} else {
builder.timeout(timeout)
};
if proxy.http_enabled {
builder = builder.proxy(ureq::Proxy::new(&proxy.http)?);
}
@@ -211,6 +270,107 @@ fn routed_url(url: &url::Url, proxy: &crate::model::ProxySettings) -> String {
mod tests {
use super::*;
#[test]
fn download_progress_outlives_idle_timeout() {
use std::{
io::{Read, Write},
net::TcpListener,
};
for chunked in [false, true] {
let server = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/slow", server.local_addr().unwrap());
let server_thread = std::thread::spawn(move || {
let (mut stream, _) = server.accept().unwrap();
let headers = if chunked {
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"
} else {
"HTTP/1.1 200 OK\r\nContent-Length: 12\r\nConnection: close\r\n\r\n"
};
stream.write_all(headers.as_bytes()).unwrap();
for _ in 0..12 {
std::thread::sleep(Duration::from_millis(100));
stream
.write_all(if chunked { b"1\r\nx\r\n" } else { b"x" })
.unwrap();
stream.flush().unwrap();
}
if chunked {
stream.write_all(b"0\r\n\r\n").unwrap();
}
});
let start = std::time::Instant::now();
let result = interruptible_download(&Default::default(), move |_| {
let response = get_download_with_proxy(
&url,
true,
Duration::from_millis(500),
&[],
&Default::default(),
)?;
let mut body = Vec::new();
response.into_reader().read_to_end(&mut body)?;
Ok(body)
});
server_thread.join().unwrap();
assert_eq!(result.unwrap(), b"xxxxxxxxxxxx");
assert!(start.elapsed() > Duration::from_millis(500));
}
}
#[test]
fn download_stalled_body_still_times_out() {
use std::{
io::{Read, Write},
net::TcpListener,
sync::mpsc,
};
let server = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/stalled", server.local_addr().unwrap());
let (release_tx, release_rx) = mpsc::channel();
let server_thread = std::thread::spawn(move || {
let (mut stream, _) = server.accept().unwrap();
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nh")
.unwrap();
release_rx.recv_timeout(Duration::from_secs(3)).unwrap();
});
let response = get_download_with_proxy(
&url,
true,
Duration::from_millis(250),
&[],
&Default::default(),
)
.unwrap();
let start = std::time::Instant::now();
let result = response.into_reader().read_to_end(&mut Vec::new());
release_tx.send(()).unwrap();
server_thread.join().unwrap();
assert!(result.is_err());
assert!(start.elapsed() < Duration::from_secs(2));
}
#[test]
fn download_without_total_deadline_remains_cancellable() {
use std::sync::mpsc;
let token = crate::jobs::Cancellation::default();
let worker_token = token.clone();
let (seen_tx, seen_rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
interruptible_download(&worker_token, move |cancel| {
seen_tx.send(()).unwrap();
loop {
cancel.check()?;
std::thread::sleep(Duration::from_millis(5));
}
#[allow(unreachable_code)]
Ok(())
})
});
seen_rx.recv_timeout(Duration::from_secs(2)).unwrap();
let start = std::time::Instant::now();
token.stop();
assert!(worker.join().unwrap().is_err());
assert!(start.elapsed() < Duration::from_millis(500));
}
#[test]
fn redirect_download_retains_integrity_and_bounds() {
use tiny_http::{Header, Response, Server};
let server = Server::http("127.0.0.1:0").unwrap();
+1 -2
View File
@@ -275,9 +275,8 @@ impl Jobs {
let download_request = request.clone();
let download_jobs = jobs.clone();
let download_key = key.clone();
let bytes = crate::http::interruptible(
let bytes = crate::http::interruptible_download(
&cancel,
std::time::Duration::from_secs(180),
move |token| {
package::stage_request_proxy(
&download_request,
+2 -2
View File
@@ -223,7 +223,7 @@ pub fn stage_request_proxy(
progress(0, None)?;
let url = v["url"].as_str().context("Missing package/URL")?;
validate_url(url, v["allowHttp"].as_bool().unwrap_or(false))?;
let response = crate::http::get_with_proxy(
let response = crate::http::get_download_with_proxy(
url,
v["allowHttp"].as_bool().unwrap_or(false),
std::time::Duration::from_secs(60),
@@ -421,7 +421,7 @@ pub fn from_request_proxy(
progress(0, None)?;
let url = v["url"].as_str().context("Missing package/URL")?;
validate_url(url, v["allowHttp"].as_bool().unwrap_or(false))?;
let response = crate::http::get_with_proxy(
let response = crate::http::get_download_with_proxy(
url,
v["allowHttp"].as_bool().unwrap_or(false),
std::time::Duration::from_secs(60),
+13
View File
@@ -779,6 +779,7 @@ impl Service {
| "agreement.revoke"
| "network.password.verify"
| "network.password.configured"
| "cast.watchAccess.get"
| "network.password.setup"
| "system.uninstall.prepare"
),
@@ -1120,6 +1121,11 @@ impl Service {
self.save()?;
Ok(json!(true))
}
"cast.watchAccess.get" => Ok(serde_json::to_value(crate::cast::watch_access::Grant::load(&self.root)?)?),
"cast.watchAccess.save" => {
let enabled = p["enabled"].as_bool().context("Missing enabled")?;
Ok(serde_json::to_value(crate::cast::watch_access::Grant::save(&self.root, enabled)?)?)
}
"cast.settings.save" => {
let settings: crate::cast::settings::Settings = serde_json::from_value(p)?;
settings.validate()?;
@@ -1127,6 +1133,13 @@ impl Service {
self.save()?;
Ok(json!(true))
}
"cast.region.visible.save" => {
let enabled=p["enabled"].as_bool().context("Missing region visibility")?;
let previous=self.db.casting.recording_region_visible;
self.db.casting.recording_region_visible=enabled;
if let Err(error)=self.save() {self.db.casting.recording_region_visible=previous;return Err(error);}
Ok(json!(true))
}
"cast.panel.start" => {
let executable = self.root.join("current/bin/framely-panel-grab");
ensure!(executable.is_file(), "缺少内置屏幕采集助手");
+424 -13
View File
@@ -37,6 +37,8 @@ struct Agent {
files: Arc<crate::manager_files::Files>,
catalog_cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
casting: Mutex<crate::cast::runtime::Casting>,
recording_overlay_cache: Mutex<Value>,
watch_access: Mutex<crate::cast::watch_access::Access>,
receivers: Mutex<crate::cast::receiver::Receivers>,
}
#[derive(Default)]
@@ -75,6 +77,25 @@ fn push(q: &Mutex<VecDeque<Value>>, v: Value) {
q.push_back(v);
}
impl Agent {
fn watch_cookie(r: &Request) -> Option<String> {
r.headers().iter().filter(|h| h.field.equiv("Cookie"))
.flat_map(|h| h.value.as_str().split(';'))
.find_map(|cookie| cookie.trim().strip_prefix("framely-watch=").map(str::to_owned))
}
fn watch_resource(path: &str, method: &Method) -> bool {
(method == &Method::Get && matches!(path, "/cast/watch" | "/cast/access" | "/assets/app.js" | "/assets/app.css"))
|| ((path == "/cast/whep" || path.starts_with("/cast/whep/"))
&& matches!(method, Method::Post | Method::Patch | Method::Delete | Method::Options))
}
fn watch_expired(r: Request) -> Result<()> {
r.respond(Response::from_string("<!doctype html><meta charset=\"utf-8\"><title>Framely</title><p>观看链接已失效</p>")
.with_status_code(403)
.with_header(header("Content-Type", "text/html; charset=utf-8"))
.with_header(header("X-Framely-Watch-Expired", "1"))
.with_header(header("Cache-Control", "no-store"))
.with_header(header("Referrer-Policy", "no-referrer")))?;
Ok(())
}
fn core(&self, m: &str, p: Value) -> Result<Value> {
ipc::call(&self.socket, m, p)
}
@@ -229,8 +250,30 @@ impl Agent {
return Ok(());
}
}
let configured = remote && self.core("network.password.configured", json!({}))? == true;
if remote && (!configured || !self.network_authorized(&r)) {
let watch_cookie = Self::watch_cookie(&r);
if remote && path == "/cast/watch" && r.method() == &Method::Get {
let token = r.url().split_once('?').and_then(|(_, query)| url::form_urlencoded::parse(query.as_bytes()).find(|(key,_)| key == "token").map(|(_,value)| value.into_owned()));
if let Some(token) = token {
if !self.watch_access.lock().unwrap().accepts(&token) { return Self::watch_expired(r); }
r.respond(Response::empty(StatusCode(303))
.with_header(header("Location", "/cast/watch"))
.with_header(header("Set-Cookie", &format!("framely-watch={token}; Path=/; HttpOnly; SameSite=Strict")))
.with_header(header("Cache-Control", "no-store"))
.with_header(header("Referrer-Policy", "no-referrer")))?;
return Ok(());
}
}
let manager_authorized = remote && self.network_authorized(&r);
let guest = remote && !manager_authorized && Self::watch_resource(&path, r.method())
&& watch_cookie.as_ref().is_some_and(|token| self.watch_access.lock().unwrap().accepts(token));
if remote && Self::watch_resource(&path, r.method()) && !manager_authorized && !guest
&& (watch_cookie.is_some() || path == "/cast/access") { return Self::watch_expired(r); }
if remote && path == "/cast/access" && (guest || manager_authorized) {
r.respond(Response::empty(StatusCode(204)).with_header(header("Cache-Control", "no-store")))?;
return Ok(());
}
let configured = remote && !guest && self.core("network.password.configured", json!({}))? == true;
if remote && !guest && (!configured || !manager_authorized) {
let status = self.core("status", json!({}))?;
let protected = status["database"]["networkPanel"]["passwordEnabled"]
.as_bool()
@@ -366,6 +409,15 @@ impl Agent {
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)?;
// 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.
if let Some(id) = views["stopRecording"].as_str().filter(|id|!id.is_empty()) {
self.casting.lock().unwrap().stop_recording_id(id);
}
if let Some(id)=views["framingClosed"].as_str().filter(|id|!id.is_empty()) {
self.casting.lock().unwrap().region.acknowledge_editor_closed(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!({}));}}}
@@ -395,7 +447,7 @@ impl Agent {
.unwrap_or(json!({}));
return send_json(
r,
json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"]}),
{let (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)};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})},
);
}
if let Some(rel) = path.strip_prefix("/plugin-assets/") {
@@ -511,6 +563,15 @@ impl Agent {
}
if let Some(rel) = path.strip_prefix("/assets/") {
safe_path(rel)?;
if guest {
let mime = if path.ends_with(".js") { "text/javascript" } else { "text/css" };
r.respond(Response::from_data(fs::read(self.assets.join(&path[1..]))?)
.with_header(header("Content-Type", mime))
.with_header(header("X-Content-Type-Options", "nosniff"))
.with_header(header("Cache-Control", "no-store"))
.with_header(header("Referrer-Policy", "no-referrer")))?;
return Ok(());
}
return send_file(r, &self.assets.join(&path[1..]), true);
}
if let Some(key) = path.strip_prefix("/boot/") {
@@ -538,7 +599,7 @@ impl Agent {
}
ensure!(
if remote {
self.network_authorized(&r)
guest || self.network_authorized(&r)
} else {
self.authorized(&r)
},
@@ -791,11 +852,20 @@ impl Agent {
);
}
if path == "/cast/whep" || path.starts_with("/cast/whep/") {
// Serialize guest creation/registration with revocation: disabling
// cannot miss a WHEP session that is still being created upstream.
let mut grant = if guest { Some(self.watch_access.lock().unwrap()) } else { None };
if let Some(access) = grant.as_ref() {
if !watch_cookie.as_ref().is_some_and(|token| access.accepts(token)) { return Self::watch_expired(r); }
}
ensure!(matches!(r.method(), Method::Post | Method::Patch | Method::Delete | Method::Options), "Invalid WHEP method");
let origin = r.headers().iter().find(|h| h.field.equiv("Origin")).map(|h|h.value.as_str());
ensure!(origin == Some(expected_origin.as_str()), "Cross-origin playback request denied");
if r.method()==&Method::Options {r.respond(Response::empty(StatusCode(204)).with_header(header("Accept-Post","application/sdp")))?;return Ok(());}
let suffix = path.strip_prefix("/cast/whep").unwrap();
if let Some(access) = grant.as_ref() {
if !suffix.is_empty() && !access.sessions.contains_key(&path) { return Self::watch_expired(r); }
}
let mut body = Vec::new();
r.as_reader().take(65537).read_to_end(&mut body)?;
ensure!(body.len() <= 65536, "SDP too large");
@@ -819,7 +889,7 @@ impl Agent {
}
let response = match request.send_bytes(&body) { Ok(v) => v, Err(ureq::Error::Status(_,v)) => v, Err(e) => return Err(e.into()) };
let code = response.status();
let mut headers = vec![header("Cache-Control", "no-store")];
let mut headers = vec![header("Cache-Control", "no-store"), header("Referrer-Policy", "no-referrer")];
for name in ["Content-Type", "ETag", "Accept-Patch"] {
if let Some(value) = response.header(name) { headers.push(header(name,value)); }
}
@@ -828,6 +898,14 @@ impl Agent {
let prefix=format!("/{media_path}/whep/");
ensure!(parsed.path().starts_with(&prefix), "Invalid media session location");
headers.push(header("Location", &format!("/cast/whep/{codec}/{}",parsed.path().strip_prefix(&prefix).unwrap())));
if (200..300).contains(&code) {
if let Some(access) = grant.as_mut() {
access.sessions.insert(format!("/cast/whep/{codec}/{}", parsed.path().strip_prefix(&prefix).unwrap()), parsed.to_string());
}
}
}
if r.method() == &Method::Delete && matches!(code, 200..=299 | 404 | 410) {
if let Some(access) = grant.as_mut() { access.sessions.remove(&path); }
}
let mut bytes = Vec::new(); response.into_reader().take(65537).read_to_end(&mut bytes)?;
ensure!(bytes.len() <= 65536, "Media response too large");
@@ -870,6 +948,7 @@ impl Agent {
}
if path == "/"
|| path == "/cast/watch"
|| path == "/cast/framing"
|| path.starts_with("/cast/window/")
|| path == "/apk/permission"
|| path == "/manager"
@@ -889,7 +968,8 @@ impl Agent {
} else {
data
};
let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
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(());
}
@@ -898,6 +978,10 @@ impl Agent {
}
fn maintain_cast(&self) {
self.casting.lock().unwrap().maintain();
// Retry failed disconnects, without interrupting authenticated viewers.
if let Ok(mut access) = self.watch_access.try_lock() {
if !access.revoked.is_empty() { access.disconnect_revoked(); }
}
let events=self.receivers.lock().unwrap().poll();
for event in events {
let session=&event["session"];
@@ -945,20 +1029,123 @@ impl Agent {
match method {
"cast.status" => {
let mut value = self.casting.lock().unwrap().status();
value["recordingRegion"]=self.casting.lock().unwrap().region_status();
let access = self.watch_access.lock().unwrap();
let watch_path = access.path();
value["watchAccessEnabled"] = json!(access.token.is_some());
value["watchPath"] = json!(watch_path);
let receivers = self.receivers.lock().unwrap();
value["sessions"] = json!(receivers.sessions.values().collect::<Vec<_>>());
value["receiverError"] = json!(receivers.error);
let network=self.network_status.lock().unwrap();
value["watchAddresses"]=if network["enabled"]==true{json!(network_ipv4_addresses().into_iter().map(|ip|format!("http://{ip}:{}/cast/watch",network["port"])).collect::<Vec<_>>())}else{json!([])};
value["watchAddresses"]=if network["enabled"]==true{json!(network_ipv4_addresses().into_iter().map(|ip|format!("http://{ip}:{}{watch_path}",network["port"])).collect::<Vec<_>>())}else{json!([])};
Ok(value)
},
"cast.watchAccess.save" => {
let mut access = self.watch_access.lock().unwrap();
let result = self.core(method, p)?;
access.replace(result["token"].as_str().map(str::to_owned));
access.disconnect_revoked();
Ok(json!({"enabled":access.token.is_some()}))
},
"cast.watchAccess.get" => anyhow::bail!("Internal watch access operation"),
"cast.settings.save" => {
let settings: crate::cast::settings::Settings = serde_json::from_value(p.clone())?;
settings.validate()?;
let cast = self.casting.lock().unwrap();
ensure!(!cast.region.editing, "请先关闭画幅调整窗口");
cast.validate_recording_settings(&settings)?;
let result = self.core(method,p)?;
drop(cast);
self.receivers.lock().unwrap().configure(&settings)?;
Ok(result)
},
"cast.region.visible.save" => {
let enabled=p["enabled"].as_bool().context("Missing region visibility")?;
let mut settings:crate::cast::settings::Settings=serde_json::from_value(self.core("status",json!({}))?["database"]["casting"].clone())?;
ensure!(!enabled || crate::cast::region::supported(&settings),"录制区域显示仅支持屏幕采集的单眼画面");
let previous=settings.clone();
settings.recording_region_visible=enabled;
let mut cast=self.casting.lock().unwrap();
// Resolve geometry before persisting an enabled switch.
cast.refresh_region(&settings)?;
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.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),"画幅调整仅支持屏幕采集的单眼画面");
let mut cast=self.casting.lock().unwrap();
ensure!(cast.recording_status()["active"]!=true,"请先停止录制再调整取景");
ensure!(!cast.region.editing && cast.status()["preview"]!=true,"请先关闭画幅调整窗口");
cast.region.editing=true;
cast.region.editor_id=hex::encode(rand::random::<[u8;16]>());
if let Err(error)=cast.refresh_region(&settings) {cast.region.editing=false;return Err(error);}
let geometry=match cast.region.editor() {
Ok(geometry)=>geometry,
Err(error)=>{cast.region.editing=false;return Err(error);}
};
self.commands.lock().unwrap().push_back(json!({"kind":"cast.framing.open","geometry":geometry}));
Ok(geometry)
},
"cast.framing.get" => {
let cast=self.casting.lock().unwrap();ensure!(cast.region.editing,"画幅调整窗口已关闭");cast.region.editor()
},
"cast.framing.reference" => {
let cast=self.casting.lock().unwrap();
ensure!(cast.region.editing && p["id"]==cast.region.editor_id,"画幅调整窗口已关闭");
let cx=p["centerX"].as_f64().context("Invalid reference center")?;
let cy=p["centerY"].as_f64().context("Invalid reference center")?;
ensure!([cx,cy].into_iter().all(|v|v.is_finite()&&(-60.0..=60.0).contains(&v)),"Invalid reference center");
// Presentation-only depth probe; never persist a draft crop.
self.commands.lock().unwrap().push_back(json!({"kind":"cast.framing.reference","id":cast.region.editor_id,"centerTanX":cx.to_radians().tan(),"centerTanY":cy.to_radians().tan()}));
Ok(json!(true))
},
"cast.framing.cancel" => {
let mut cast=self.casting.lock().unwrap();
ensure!(p["id"]==cast.region.editor_id,"画幅调整窗口已关闭");
cast.region.editing=false;
self.commands.lock().unwrap().push_back(json!({"kind":"cast.framing.close","id":cast.region.editor_id}));Ok(json!(true))
},
"cast.framing.apply" => {
let mut settings:crate::cast::settings::Settings=serde_json::from_value(self.core("status",json!({}))?["database"]["casting"].clone())?;
let mut cast=self.casting.lock().unwrap();ensure!(cast.region.editing,"画幅调整窗口已关闭");
ensure!(p["id"]==cast.region.editor_id,"画幅调整窗口已关闭");
ensure!(cast.recording_status()["active"]!=true,"请先停止录制再调整取景");
settings.width=p["width"].as_u64().and_then(|v|u32::try_from(v).ok()).context("Invalid width")?;
settings.height=p["height"].as_u64().and_then(|v|u32::try_from(v).ok()).context("Invalid height")?;
settings.horizontal_fov=Some(p["horizontalFov"].as_f64().context("Invalid FOV")?);
settings.center_x=p["centerX"].as_f64().context("Invalid center")?;
settings.center_y=p["centerY"].as_f64().context("Invalid center")?;
settings.validate()?;
let mut candidate=crate::cast::region::Region::default();
candidate.editing=true;
candidate.refresh(&settings,&cast.binaries,None)?;
ensure!(p["expectedEye"]==candidate.editor()?["view"]["eye"],"录制眼已改变,请重新打开画幅调整");
crate::cast::region::Region::require_visible(&candidate.editor()?["view"])?;
// Opening the editor does not pause streaming. Check the live
// state now rather than replaying a stale state from opening.
let running=cast.status()["running"]==true;
self.core("cast.settings.save",serde_json::to_value(&settings)?)?;
cast.region.editing=false;
// Close before starting workers, which can take several polls.
// The persistent framingEditor state also retries lost closes.
self.commands.lock().unwrap().push_back(json!({"kind":"cast.framing.close","id":cast.region.editor_id}));
if running {
cast.stop();
let restart=(|| -> Result<()> {
let panel=self.core("cast.panel.start",json!({}))?;
let socket=PathBuf::from(panel["socket"].as_str().context("Missing panel socket")?);
cast.start(&settings,Some(&socket))?;
Ok(())
})();
restart.context("取景已保存,但重新启动串流失败")?;
}
// Saved settings and a closed editor are committed even if the
// optional passive guide cannot refresh; never revive a stale UI.
cast.refresh_region(&settings).context("取景已保存,但录制区域显示刷新失败")?;
Ok(json!(true))
},
"cast.devices" => self.receivers.lock().unwrap().devices(),
"cast.send" => {
let network=self.network_status.lock().unwrap().clone();ensure!(network["enabled"]==true,"请先开启网络管理面板,以便设备获取投屏画面");
@@ -1012,7 +1199,18 @@ impl Agent {
} else { None };
self.casting.lock().unwrap().start(&settings, panel.as_deref())
}
"cast.stop" => Ok(self.casting.lock().unwrap().stop()),
"cast.stop" => self.casting.lock().unwrap().stop_streaming(),
"cast.record.start" => {
let mut cast = self.casting.lock().unwrap();
let status = self.core("status", json!({}))?;
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 = self.core("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"))
},
"cast.record.stop" => self.casting.lock().unwrap().stop_recording(),
"launcher.search.index" => {
let names: Vec<String> = serde_json::from_value(p["names"].clone())?;
ensure!(
@@ -1442,7 +1640,7 @@ impl Agent {
view.len() <= 160
&& (matches!(
view,
"menu" | "launcher" | "framely.manager" | "notifications"
"menu" | "launcher" | "framely.manager" | "notifications" | "framely.framing"
) || view.starts_with("framely.window.") || view.starts_with("framely.cast.")),
"Invalid haptic view"
);
@@ -1743,6 +1941,9 @@ fn fetch_catalogs_progress(
fn send_json(r: Request, v: Value) -> Result<()> {
r.respond(
Response::from_string(serde_json::to_string(&v)?)
// The native poller reads a length-delimited JSON body, not HTTP
// chunks. Geometry metadata can exceed tiny_http's 32 KiB default.
.with_chunked_threshold(usize::MAX)
.with_header(header("Content-Type", "application/json"))
.with_header(header("Cache-Control", "no-store")),
)?;
@@ -1898,7 +2099,12 @@ pub fn serve(
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))?;
let agent = Arc::new(Agent {
watch_access: Mutex::new(crate::cast::watch_access::Access {
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})),
receivers: Mutex::new(crate::cast::receiver::Receivers::new(runtime.join("receive"), crate::cast::runtime::binary_directory(&assets))),
socket,
state,
@@ -1920,7 +2126,7 @@ pub fn serve(
let cast_agent=agent.clone();
std::thread::spawn(move || { let mut configured=std::time::Instant::now()-Duration::from_secs(10);loop {
if configured.elapsed()>Duration::from_secs(2) {
if let Ok(status)=cast_agent.core("status",json!({})) {if status["agreement"]["accepted"]==true {if let Ok(settings)=serde_json::from_value(status["database"]["casting"].clone()){let _=cast_agent.receivers.lock().unwrap().configure(&settings);}}}
if let Ok(status)=cast_agent.core("status",json!({})) {if status["agreement"]["accepted"]==true {if let Ok(settings)=serde_json::from_value(status["database"]["casting"].clone()){let _=cast_agent.casting.lock().unwrap().refresh_region(&settings);let _=cast_agent.receivers.lock().unwrap().configure(&settings);}}}
configured=std::time::Instant::now();
}
cast_agent.maintain_cast();std::thread::sleep(Duration::from_millis(100));
@@ -2431,7 +2637,9 @@ 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})),
receivers: Mutex::new(crate::cast::receiver::Receivers::new(root.join("receive"), root.join("media"))),
socket: root.join("control.sock"),
state: root.join("state"),
@@ -2467,6 +2675,62 @@ mod tests {
})
}
#[test]
fn framing_apply_restarts_live_stream_and_closes_even_when_restart_fails() {
use std::os::unix::fs::PermissionsExt;
for running in [false,true] {
let root=tempfile::tempdir().unwrap();
let server=Server::http("127.0.0.1:0").unwrap();
let agent=agent(&server,root.path());
let mut core=crate::tests::accepted_service(&agent.state,1000).unwrap();
let mut settings=crate::cast::settings::Settings::default();
settings.eye=crate::cast::settings::Eye::Left;
core.handle("cast.settings.save",serde_json::to_value(&settings).unwrap()).unwrap();
fs::create_dir(root.path().join("media")).unwrap();
let view=json!({"projection":"factory-panel-v1","eye":0,"regionFullyVisible":true,"editorReferenceTan":1.7,"visibility":{"size":8,"extent":1.7,"cells":"f".repeat(16)},"tanHalfHorizontal":0.5,"aspect":4./3.,"centerTanX":0.,"centerTanY":0.,"captureToHead":[[1,0,0,0],[0,1,0,0],[0,0,1,0]]});
let capture=root.path().join("media/framely-capture");
fs::write(&capture,format!("#!/bin/sh\nprintf '%s' '{}'\n",view)).unwrap();
fs::set_permissions(&capture,fs::Permissions::from_mode(0o700)).unwrap();
{
let mut cast=agent.casting.lock().unwrap();
if running {cast.test_whep_server(1);}
cast.region.editor_id="apply-test".into();cast.region.editing=true;
}
let listener=UnixListener::bind(&agent.socket).unwrap();
listener.set_nonblocking(true).unwrap();
let stop=Arc::new(AtomicBool::new(false));let core_stop=stop.clone();
let panel_calls=Arc::new(std::sync::atomic::AtomicUsize::new(0));let calls=panel_calls.clone();
let worker=std::thread::spawn(move || {
while !core_stop.load(Ordering::Relaxed) {
if let Ok((mut stream,_))=listener.accept() {
let request=ipc::read(&mut stream).unwrap();
let method=request["method"].as_str().unwrap();
let result=if method=="cast.panel.start" {
calls.fetch_add(1,Ordering::Relaxed);
Err(anyhow::anyhow!("test restart failure"))
} else {core.handle(method,request["params"].clone())};
ipc::write(&mut stream,&ipc::response(result)).unwrap();
} else {std::thread::sleep(Duration::from_millis(5));}
}
Ok::<_,anyhow::Error>(())
});
assert!(agent.api(json!({"method":"cast.framing.reference","params":{"id":"old-test","centerX":0.,"centerY":0.}})).is_err());
assert!(agent.api(json!({"method":"cast.framing.reference","params":{"id":"apply-test","centerX":61.,"centerY":0.}})).is_err());
agent.api(json!({"method":"cast.framing.reference","params":{"id":"apply-test","centerX":4.5,"centerY":-3.}})).unwrap();
let reference=agent.commands.lock().unwrap().back().unwrap().clone();
assert_eq!(reference["kind"],"cast.framing.reference");
assert!((reference["centerTanX"].as_f64().unwrap()-4.5_f64.to_radians().tan()).abs()<1e-12);
assert_eq!(agent.core("status",json!({})).unwrap()["database"]["casting"]["centerX"],0.);
let result=agent.api(json!({"method":"cast.framing.apply","params":{"id":"apply-test","expectedEye":0,"width":1280,"height":960,"horizontalFov":60.,"centerX":0.,"centerY":0.}}));
assert_eq!(result.is_err(),running);
if let Err(error)=result {assert!(format!("{error:#}").contains("取景已保存,但重新启动串流失败"));}
assert_eq!(panel_calls.load(Ordering::Relaxed),usize::from(running));
assert_eq!(agent.core("status",json!({})).unwrap()["database"]["casting"]["width"],1280);
assert_eq!(agent.casting.lock().unwrap().region.editor_state(),json!({"id":"apply-test","open":false}));
assert_eq!(agent.commands.lock().unwrap().back().unwrap(),&json!({"kind":"cast.framing.close","id":"apply-test"}));
stop.store(true,Ordering::Relaxed);worker.join().unwrap().unwrap();
}
}
#[test]
fn calibration_preview_allows_unvalidated_error_but_rejects_invalid_geometry() {
let root = tempfile::tempdir().unwrap();
let server = Server::http("127.0.0.1:0").unwrap();
@@ -2880,6 +3144,126 @@ mod tests {
thread.join().unwrap();
}
#[test]
fn watch_access_limits_http_routes_and_revokes_only_guest_whep() {
let root = tempfile::tempdir().unwrap();
let server = Server::http("127.0.0.1:0").unwrap();
let agent = agent(&server, root.path());
let mut core = crate::tests::accepted_service(&agent.state, unsafe { libc::geteuid() }).unwrap();
// Authentication is exercised through existing manager cookies, not a
// password hash, so no password derivation is needed in this fixture.
fs::write(agent.state.join("network-password.json"), b"{}").unwrap();
core.db.network_panel.password_enabled = true;
let socket = UnixListener::bind(&agent.socket).unwrap();
socket.set_nonblocking(true).unwrap();
let stop = Arc::new(AtomicBool::new(false));
let core_stop = stop.clone();
let core_worker = std::thread::spawn(move || {
while !core_stop.load(Ordering::Relaxed) {
if let Ok((mut stream,_)) = socket.accept() {
let request = ipc::read(&mut stream).unwrap();
let response = ipc::response(core.handle(request["method"].as_str().unwrap(), request["params"].clone()));
ipc::write(&mut stream, &response).unwrap();
} else { std::thread::sleep(Duration::from_millis(5)); }
}
});
let worker_agent = agent.clone();
let worker_stop = stop.clone();
let worker = std::thread::spawn(move || {
while !worker_stop.load(Ordering::Relaxed) {
if let Ok(Some(r)) = server.recv_timeout(Duration::from_millis(50)) {
let _ = worker_agent.handle_http(r, true);
}
}
});
let media = Server::http("127.0.0.1:0").unwrap();
agent.casting.lock().unwrap().test_whep_server(media.server_addr().to_ip().unwrap().port());
let media_stop = stop.clone();
let deleted = Arc::new(Mutex::new(Vec::new()));
let media_deleted = deleted.clone();
let (creating_tx, creating_rx) = std::sync::mpsc::channel();
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
let media_worker = std::thread::spawn(move || {
let mut number = 0;
while !media_stop.load(Ordering::Relaxed) {
if let Ok(Some(r)) = media.recv_timeout(Duration::from_millis(50)) {
if r.method() == &Method::Post {
number += 1;
if number == 3 {
creating_tx.send(()).unwrap();
finish_rx.recv_timeout(Duration::from_secs(3)).unwrap();
}
r.respond(Response::from_string("answer").with_status_code(201)
.with_header(header("Location", &format!("/headset/whep/00000000-0000-0000-0000-{number:012}")))).unwrap();
} else if r.method() == &Method::Delete {
media_deleted.lock().unwrap().push(r.url().to_owned());
r.respond(Response::empty(StatusCode(204))).unwrap();
} else { r.respond(Response::empty(StatusCode(400))).unwrap(); }
}
}
});
agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":true}})).unwrap();
let token = agent.watch_access.lock().unwrap().token.clone().unwrap();
let client = ureq::AgentBuilder::new().redirects(0).timeout(Duration::from_secs(3)).build();
let origin = &agent.origin;
let grant = client.get(&format!("{origin}/cast/watch?token={token}")).call().unwrap();
assert_eq!(grant.status(), 303);
assert_eq!(grant.header("Location"), Some("/cast/watch"));
assert_eq!(grant.header("Referrer-Policy"), Some("no-referrer"));
let cookie_header = grant.header("Set-Cookie").unwrap();
assert!(cookie_header.contains("HttpOnly") && cookie_header.contains("SameSite=Strict"));
let cookie = cookie_header.split(';').next().unwrap().to_owned();
fs::remove_file(agent.state.join("network-password.json")).unwrap();
let watch = client.get(&format!("{origin}/cast/watch")).set("Cookie", &cookie).call().unwrap();
assert_eq!(watch.header("Cache-Control"), Some("no-store"));
assert!(watch.into_string().unwrap().contains("framely-cast-guest"));
for asset in ["app.js", "app.css"] {
assert!(client.get(&format!("{origin}/assets/{asset}")).set("Cookie", &cookie).call().is_ok());
}
fs::write(agent.state.join("network-password.json"), b"{}").unwrap();
let manager = client.get(&format!("{origin}/manager")).set("Cookie", &cookie).call().unwrap().into_string().unwrap();
assert!(manager.contains("action=\"/login\""));
for path in ["/api", "/manager-api/files", "/api/upload/a/0", "/host/poll"] {
assert!(client.post(&format!("{origin}{path}")).set("Cookie", &cookie).set("Origin", origin).send_json(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).is_err());
}
assert!(client.get(&format!("{origin}/cast/watch?token=invalid")).call().is_err());
assert!(client.post(&format!("{origin}/cast/whep")).set("Cookie", &cookie).set("Origin", "http://evil.test").send_string("a=rtpmap:96 H264/90000").is_err());
let guest = client.post(&format!("{origin}/cast/whep")).set("Cookie", &cookie).set("Origin", origin).send_string("a=rtpmap:96 H264/90000").unwrap();
assert_eq!(guest.status(), 201);
let guest_resource = guest.header("Location").unwrap().to_owned();
let manager_cookie = format!("framely-network={}", agent.network_key.lock().unwrap());
let logged_in = client.post(&format!("{origin}/cast/whep")).set("Cookie", &manager_cookie).set("Origin", origin).send_string("a=rtpmap:96 H264/90000").unwrap();
assert_eq!(logged_in.status(), 201);
assert_eq!(agent.watch_access.lock().unwrap().sessions.len(), 1);
agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).unwrap();
assert_eq!(deleted.lock().unwrap().as_slice(), &["/headset/whep/00000000-0000-0000-0000-000000000001"]);
assert!(client.get(&format!("{origin}/cast/access")).set("Cookie", &cookie).call().is_err());
assert!(client.get(&format!("{origin}/cast/watch?token={token}")).call().is_err());
assert!(client.delete(&format!("{origin}{guest_resource}")).set("Cookie", &cookie).set("Origin", origin).call().is_err());
assert_eq!(client.get(&format!("{origin}/cast/access")).set("Cookie", &manager_cookie).call().unwrap().status(), 204);
// Revoke while the upstream WHEP POST is in flight. Disabling must
// wait for registration and then delete that new session as well.
agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":true}})).unwrap();
let new_token = agent.watch_access.lock().unwrap().token.clone().unwrap();
assert_ne!(new_token, token);
let race_origin = origin.clone();
let creating = std::thread::spawn(move || {
ureq::post(&format!("{race_origin}/cast/whep")).set("Cookie", &format!("framely-watch={new_token}"))
.set("Origin", &race_origin).send_string("a=rtpmap:96 H264/90000").unwrap().status()
});
creating_rx.recv_timeout(Duration::from_secs(3)).unwrap();
assert!(agent.watch_access.try_lock().is_err());
let disabling_agent = agent.clone();
let disabling = std::thread::spawn(move || disabling_agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).unwrap());
finish_tx.send(()).unwrap();
assert_eq!(creating.join().unwrap(), 201);
disabling.join().unwrap();
assert_eq!(deleted.lock().unwrap().len(), 2);
assert!(deleted.lock().unwrap()[1].ends_with("000000000003"));
stop.store(true, Ordering::Relaxed);
worker.join().unwrap(); core_worker.join().unwrap(); media_worker.join().unwrap();
}
#[test]
fn network_panel_requires_login_and_same_origin_and_blocks_native_routes() {
let password = "密码".repeat(70);
@@ -3135,10 +3519,22 @@ mod tests {
let root = tempfile::tempdir().unwrap();
let server = Server::http("127.0.0.1:0").unwrap();
let agent = agent(&server, root.path());
// The native host parses the raw HTTP body as JSON. Exercise a
// framing command larger than tiny_http's default chunk threshold.
let framing=json!({"kind":"cast.framing.open","geometry":{"visibility":"f".repeat(48000)}});
agent.commands.lock().unwrap().push_back(framing.clone());
agent.casting.lock().unwrap().region.editor_id="closed-test".into();
let listener = std::os::unix::net::UnixListener::bind(&agent.socket).unwrap();
let socket_worker = std::thread::spawn(move || {
let mut core = crate::service::Service::load(&root.path().join("state"), 1000).unwrap();
for expected in [
"host.ui.visibility",
"host.notification.badge",
"status",
"launcher.gaze.calibration.get",
"host.notification.badge",
"status",
"launcher.gaze.calibration.get",
"host.ui.visibility",
"host.notification.badge",
"status",
@@ -3170,15 +3566,30 @@ mod tests {
.set("Cookie", &format!("framely={}", agent.web_key))
.send_json(json!({"menu":true}))
.is_err());
let poll: Value = ureq::post(&url)
let response = ureq::post(&url)
.set("X-Framely-Native", &agent.native_key)
.send_json(json!({"menu":true}))
.unwrap()
.into_json()
.unwrap();
assert!(response.header("Transfer-Encoding").is_none());
assert!(response.header("Content-Length").unwrap().parse::<usize>().unwrap()>48000);
let poll:Value=response.into_json().unwrap();
assert_eq!(poll["commands"],json!([framing]));
assert_eq!(poll["feedback"], json!({"haptics":true,"sounds":false}));
assert!(poll.get("gazeCalibration").is_some());
assert!(poll["launcher"].is_object());
assert_eq!(poll["framingEditor"],json!({"id":"closed-test","open":false}));
let retry:Value=ureq::get(&url).set("X-Framely-Native",&agent.native_key).call().unwrap().into_json().unwrap();
assert_eq!(retry["commands"],json!([]));
assert_eq!(retry["framingEditor"],poll["framingEditor"]);
// The real native host includes empty acknowledgement fields on every
// poll. An applying editor may own this lock for several seconds.
let cast=agent.casting.lock().unwrap();
let busy:Value=ureq::post(&url).timeout(Duration::from_secs(1))
.set("X-Framely-Native",&agent.native_key)
.send_json(json!({"views":{"menu":true},"stopRecording":"","framingClosed":""}))
.unwrap().into_json().unwrap();
assert!(busy["framingEditor"].is_null());
drop(cast);
assert!(agent
.api(json!({"method":"host.ui.visibility","params":{"menu":false}}))
.is_err());
+1 -1
View File
@@ -350,7 +350,7 @@ impl Updater {
};
let result = (|| -> Result<Value> {
report(json!({"phase":"downloading","received":0,"total":release.size,"version":release.version}))?;
let response = crate::http::get_with_proxy(&release.url, false, Duration::from_secs(120), &[], &proxy)?;
let response = crate::http::get_download_with_proxy(&release.url, false, Duration::from_secs(120), &[], &proxy)?;
if let Some(length) = response.header("Content-Length") {
ensure!(length.parse::<u64>()? == release.size, "下载长度与更新清单不一致");
}
+26
View File
@@ -0,0 +1,26 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
const listeners=new Map(),windowListeners=new Map();
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
globalThis.document={documentElement:{lang:''},addEventListener(name,fn){listeners.set(name,fn);}};
globalThis.window={addEventListener(name,fn){windowListeners.set(name,fn);}};
class Element {
constructor(attributes={},parentElement=null){this.attributes=attributes;this.parentElement=parentElement;}
closest(selector){for(let node=this;node;node=node.parentElement){const a=node.attributes;if(selector.startsWith('[inert]')){if(a.inert||a.disabled||a['aria-disabled']==='true')return node;}else if(a['data-framely-interactive']!==undefined||a.tabindex!==undefined)return node;}return null;}
}
globalThis.Element=Element;globalThis.getComputedStyle=()=>({cursor:'default'});
const result=await build({stdin:{contents:`import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {FramingEditor} from './ui/src/cast-framing-editor';export {installHoverFeedback} from './sdk/src/hover';export const render=()=>renderToStaticMarkup(<FramingEditor spatial settings={{width:1200,height:900,horizontalFov:60,centerX:0,centerY:0}} view={{tanHalfHorizontal:1.4,centerTanX:0,centerTanY:0,aspect:1}} onChange={()=>{}} onError={()=>{}}/>);`,loader:'tsx',resolveDir:process.cwd()},bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};new Function('module','exports','require',result.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const markup=module.exports.render();
assert.equal((markup.match(/data-framely-interactive=""/g)||[]).length,4,'all four corner markers use the shared haptic hook');
const pulses=[];module.exports.installHoverFeedback(kind=>pulses.push(kind));
const frame=new Element({tabindex:0}),nw=new Element({'data-framely-interactive':''},frame),ne=new Element({'data-framely-interactive':''},frame),child=new Element({},nw);
const over=target=>listeners.get('pointerover')({target});
const out=(target,relatedTarget)=>listeners.get('pointerout')({target,relatedTarget});
over(frame);out(frame,nw);over(nw);out(nw,child);over(child);out(child,ne);over(ne);
assert.deepEqual(pulses,['hover','hover','hover'],'entering each corner pulses once; children do not repeat');
frame.attributes.inert=true;out(ne,nw);over(nw);listeners.get('click')({target:nw});assert.equal(pulses.length,3,'pending/closed controls cannot pulse');
frame.attributes.inert=false;windowListeners.get('blur')();over(nw);listeners.get('click')({target:nw});assert.deepEqual(pulses.slice(-2),['hover','activate']);
console.log('PASS: four framing corners use existing preference-aware feedback, one hover pulse per corner, inert editors have no feedback');
+28
View File
@@ -0,0 +1,28 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
const result=await build({stdin:{contents:`export * from './ui/src/cast-framing-gesture';export {normalizeRegion} from './ui/src/cast-region';export {frameFromSettings} from './ui/src/cast-framing';`,resolveDir:process.cwd()},bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};new Function('module','exports','require',result.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const {FrameGesture,fitMinimumFrame,minimumFrameWidth,normalizeRegion,frameFromSettings}=module.exports;
const close=(a,b)=>assert.ok(Math.abs(a-b)<1e-10,`${a} != ${b}`);
for(const ratio of [4/3,3/4,16/9,9/16,1]){
const minimum=minimumFrameWidth(ratio,{width:.24,height:.28},.05);
const rect=fitMinimumFrame({x:.3,y:.3,width:.4,height:.4/ratio},ratio,minimum),cx=rect.x+rect.width/2,cy=rect.y+rect.height/2;
for(const corner of ['nw','ne','sw','se']){
const sx=corner.includes('w')?-1:1,sy=corner.includes('n')?-1:1,gesture=new FrameGesture();
gesture.begin(1,500,500,rect,corner);
const smaller=gesture.move(1,500-sx*40,500-sy*40,1,{width:1000,height:1000},ratio,minimum);
const larger=gesture.move(1,500+sx*40,500+sy*40,1,{width:1000,height:1000},ratio,minimum);
assert.ok(smaller.width<=rect.width&&larger.width>=rect.width);
for(const next of [smaller,larger]){close(next.x+next.width/2,cx);close(next.y+next.height/2,cy);assert.ok(next.width>=.24-1e-10&&next.height>=.28-1e-10);close(next.width/next.height,ratio);}
assert.equal(gesture.move(1,800,800,0,{width:1000,height:1000},ratio,minimum),null);
assert.equal(gesture.active(),false); // lost pointerup cannot leave a drag armed
assert.equal(gesture.move(1,300,300,1,{width:1000,height:1000},ratio,minimum),null);
gesture.begin(2,500,500,rect,corner);assert.equal(gesture.finish(1),false);assert.equal(gesture.finish(2),true);
assert.equal(gesture.move(2,300,300,0,{width:1000,height:1000},ratio,minimum),null);
}
const reference={tanHalfHorizontal:1.4,centerTanX:0,centerTanY:0,aspect:1};
const saved=normalizeRegion({width:Math.round(1600*ratio),height:1600,horizontalFov:10,centerX:0,centerY:0},reference,{width:.24,height:.28});
const displayed=frameFromSettings(saved,saved.width/saved.height,reference);assert.ok(displayed.width>=.24-1e-10&&displayed.height>=.28-1e-10);
}
console.log('PASS: all four corners grow/shrink around center, menu minimum survives save/ratio changes, release/cancel/hover cannot continue dragging');
+42
View File
@@ -0,0 +1,42 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {readFile} from 'node:fs/promises';
const bundle=await build({stdin:{contents:`export * from './ui/src/cast-framing-limits';export {normalizeRegion} from './ui/src/cast-region';export {frameFromSettings,settingsFromFrame} from './ui/src/cast-framing';`,resolveDir:process.cwd()},bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const {FramingLimits,framingMenuScale,normalizeRegion,frameFromSettings,settingsFromFrame}=module.exports;
const close=(a,b)=>assert.ok(Math.abs(a-b)<1e-8,`${a} != ${b}`);
for(const extent of [.9,1.695,1.734])close(framingMenuScale(extent)*extent,.9);
for(const eye of [0,1]){
const size=64,bits=[];for(let y=0;y<size;y++)for(let x=0;x<size;x++){
const u=(x+.5)/size,v=(y+.5)/size,lower=.1+Math.abs(v-.5)*.2,upper=.9-Math.abs(v-.5)*.5;
bits.push(v>.12&&v<.88&&(eye===0?u>lower&&u<upper:1-u>lower&&1-u<upper));
}
let cells='';for(let i=0;i<bits.length;i+=4)cells+=(+bits[i]+(+bits[i+1]<<1)+(+bits[i+2]<<2)+(+bits[i+3]<<3)).toString(16);
const limit=new FramingLimits({size,extent:1.7,cells},1.7);
for(const ratio of [4/3,3/4,16/9,9/16,1]){
const min=Math.max(.10,.12*ratio),reset=limit.reset(ratio,min);
assert.ok(limit.contains(reset));close(reset.width/reset.height,ratio);
const grown=limit.fit({...reset,x:reset.x-.2,y:reset.y-.2/ratio,width:reset.width+.4,height:reset.height+.4/ratio},ratio,min,reset);
assert.ok(limit.contains(grown));close(grown.x+grown.width/2,reset.x+reset.width/2);close(grown.y+grown.height/2,reset.y+reset.height/2);
const moved=limit.fit({...reset,x:-2},ratio,min,reset);assert.ok(limit.contains(moved));close(moved.width,reset.width);
const smaller=limit.fit({...reset,x:reset.x+reset.width/4,y:reset.y+reset.height/4,width:reset.width/2,height:reset.height/2},ratio,min,reset);
assert.ok(limit.contains(smaller));assert.ok(smaller.width>=min-1e-10);
const reference={tanHalfHorizontal:1.7,centerTanX:0,centerTanY:0,aspect:1};
const sizeSettings={width:Math.round(ratio*1600),height:1600,...settingsFromFrame(grown,reference)};
const saved=normalizeRegion(sizeSettings,reference,{width:.1,height:.12},limit.fit.bind(limit));
const displayed=frameFromSettings(saved,saved.width/saved.height,reference);assert.ok(limit.contains(displayed));
}
assert.throws(()=>limit.reset(1,.95));
}
if(process.env.FRAMELY_VIEW_FIXTURE){
const views=JSON.parse(await readFile(process.env.FRAMELY_VIEW_FIXTURE,'utf8'));
const checks=[];
for(const view of views){const limit=new FramingLimits(view.visibility,view.editorReferenceTan),reference={tanHalfHorizontal:view.editorReferenceTan,centerTanX:0,centerTanY:0,aspect:1};
for(const [width,height] of [[2560,1920],[1920,2560],[2560,1440],[1440,2560],[1920,1920]]){
const ratio=width/height,rect=limit.reset(ratio,Math.max(.1,.1*ratio));assert.ok(limit.contains(rect));
checks.push({eye:view.eye,width,height,...settingsFromFrame(rect,reference)});
}
}
console.log(JSON.stringify(checks));
}else console.log('PASS: menu angular size fixed; both-eye masks clamp growth/movement/reset and saved crops stay inside actual visibility');
+17
View File
@@ -0,0 +1,17 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {readFile} from 'node:fs/promises';
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
globalThis.document={documentElement:{lang:''}};
const bundle=await build({stdin:{contents:`import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {RecordingButton} from './ui/src/cast-recording';export const render=(recording,disabled=false)=>renderToStaticMarkup(<RecordingButton recording={recording} disabled={disabled} onClick={()=>{}}/>);`,resolveDir:process.cwd(),loader:'tsx'},bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const idle=module.exports.render({active:false});assert.ok(idle.includes('开始录制'));assert.ok(!idle.includes('停止录制'));
const active=module.exports.render({active:true,elapsedSeconds:125,path:'/home/steamos/Videos/framely/test.mp4'});
assert.ok(active.includes('停止录制'));assert.ok(active.includes('02:05'));assert.ok(active.includes('test.mp4'));assert.ok(active.includes(' recording'));
assert.ok(module.exports.render({active:true},true).includes('disabled=""'));
for(const locale of ['en-US','zh-CN']){
const {messages}=JSON.parse(await readFile(`ui/locales/${locale}.json`,'utf8'));
for(const key of ['开始录制','停止录制','录制保存到 /home/steamos/Videos/framely'])assert.ok(messages[key]);
}
console.log('PASS: recording button idle/active/disabled states, timer, path and translations');
+17
View File
@@ -0,0 +1,17 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
const bundle=await build({stdin:{contents:`export {normalizeRegion} from './ui/src/cast-region';export {frameFromSettings,settingsFromFrame,framePresets,frameRatios} from './ui/src/cast-framing';`,resolveDir:process.cwd()},bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const {normalizeRegion,frameFromSettings,settingsFromFrame,framePresets}=module.exports;
assert.ok(module.exports.frameRatios.some(r=>r.label==='3:4'&&r.value===3/4));
const reference={tanHalfHorizontal:1.4,centerTanX:0,centerTanY:0,aspect:1};
for(const aspect of [4/3,3/4,16/9,9/16,1])for(const size of framePresets(aspect)){
const input={...size,horizontalFov:72,centerX:12,centerY:-8};
const saved=normalizeRegion(input,reference),rect=frameFromSettings(saved,saved.width/saved.height,reference),effective=settingsFromFrame(rect,reference);
for(const key of ['horizontalFov','centerX','centerY'])assert.ok(Math.abs(saved[key]-effective[key])<1e-9,`${key}: displayed box differs from saved capture`);
assert.ok(Math.abs(rect.width/rect.height-saved.width/saved.height)<1e-10);
}
const fitted=normalizeRegion({width:1080,height:1920,horizontalFov:150,centerX:60,centerY:60},reference);
assert.ok(fitted.horizontalFov<150);assert.ok(fitted.centerX<=60&&fitted.centerY<=60);
console.log('PASS: frame ratio/resolution changes save exactly the displayed crop');
+73
View File
@@ -0,0 +1,73 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {resolve} from 'node:path';
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
globalThis.document={documentElement:{lang:'',classList:{add(){},remove(){}}}};
globalThis.window={addEventListener(){},removeEventListener(){}};
const reactPath=resolve('node_modules/react/index.js');
// A small hook runner exercises this page's async lifecycle without mounting
// video/CEF or adding a DOM-test dependency. Children remain React elements.
const hooks=`
import React from ${JSON.stringify(reactPath)};
export * from ${JSON.stringify(reactPath)};
export default React;
let values=[],cursor=0,effects=[];
const same=(a,b)=>a&&b&&a.length===b.length&&a.every((v,i)=>Object.is(v,b[i]));
export function useState(initial){const i=cursor++;if(!(i in values))values[i]=typeof initial==='function'?initial():initial;return [values[i],value=>{values[i]=typeof value==='function'?value(values[i]):value;}];}
export function useRef(initial){const i=cursor++;return values[i]??(values[i]={current:initial});}
export function useMemo(fn,deps){const i=cursor++;if(!same(values[i]?.deps,deps))values[i]={deps,value:fn()};return values[i].value;}
export function useEffect(fn,deps){const i=cursor++;if(!same(values[i]?.deps,deps)){const old=values[i];values[i]={deps};effects.push(()=>{old?.cleanup?.();values[i].cleanup=fn();});}}
export const useLayoutEffect=useEffect;
export function render(Component){cursor=0;return Component();}
export function flush(){const pending=effects;effects=[];for(const effect of pending)effect();}
export function reset(){for(const value of values)value?.cleanup?.();values=[];cursor=0;effects=[];}
`;
const api=`let handler;export const calls=[];export function setHandler(next){handler=next;calls.length=0;}export function api(method,params,signal){calls.push({method,params,signal});return Promise.resolve().then(()=>handler(method,params));}`;
const result=await build({stdin:{contents:`export {SpatialFraming} from './ui/src/cast-spatial-framing';export {render,flush,reset} from 'react';export {calls,setHandler} from './ui/src/api';`,resolveDir:process.cwd()},bundle:true,platform:'node',format:'cjs',loader:{'.css':'empty'},write:false,plugins:[{name:'page-harness',setup(build){build.onResolve({filter:/^react$/},()=>({path:'hooks',namespace:'test'}));build.onLoad({filter:/^hooks$/,namespace:'test'},()=>({contents:hooks,loader:'js',resolveDir:process.cwd()}));build.onLoad({filter:/\/ui\/src\/api\.ts$/},()=>({contents:api,loader:'js'}));}}]});
const module={exports:{}};new Function('module','exports','require',result.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const {SpatialFraming,render,flush,reset,calls,setHandler}=module.exports;
const geometry={id:'editor-1',referenceTan:1.4,settings:{width:1200,height:900,horizontalFov:60,centerX:0,centerY:0},view:{tanHalfHorizontal:.7,centerTanX:0,centerTanY:0,eye:1,visibility:{size:32,extent:1.4,cells:'f'.repeat(256)}}};
const tick=()=>new Promise(resolve=>setImmediate(resolve));
function walk(node,visit){if(!node||typeof node!=='object')return;visit(node);for(const child of [node.props?.children].flat(Infinity))walk(child,visit);}
function draw(){const tree=render(SpatialFraming);walk(tree,node=>{if(node.props?.ref){const panel=node.type==='section';node.props.ref.current={getBoundingClientRect:()=>({width:panel?340:1600,height:panel?300:1600})};}});flush();return tree;}
function button(tree,label){let found;walk(tree,node=>{if(node.type==='button'&&node.props.children===label)found=node;});assert.ok(found,label);return found;}
async function mount(finish){reset();setHandler((method,params)=>method==='status'?{database:{language:'zh-CN'}}:method==='language.list'?{packs:[]}:method==='cast.framing.get'?geometry:method==='cast.framing.reference'?true:finish(method,params));for(let i=0;i<4;i++){draw();await tick();}return draw();}
let resolveApply;
let tree=await mount(method=>{assert.equal(method,'cast.framing.apply');return new Promise(resolve=>{resolveApply=resolve;});});
const apply=button(tree,'应用取景').props.onClick;
apply();apply();await tick();tree=draw();
assert.equal(tree.props.inert,true);
assert.equal(calls.filter(call=>call.method==='cast.framing.apply').length,1,'double click cannot apply twice');
resolveApply(true);await tick();assert.equal(draw(),null,'successful apply immediately removes the stale editor');
apply();await tick();assert.equal(calls.filter(call=>call.method==='cast.framing.apply').length,1);
tree=await mount(()=>{throw Error('Fixture save rejected');});
button(tree,'应用取景').props.onClick();await tick();tree=draw();
assert.ok(tree,'save failure leaves the editor usable');assert.equal(tree.props.inert,false);
let error;walk(tree,node=>{if(node.props?.role==='alert')error=node.props.children;});assert.equal(error,'Fixture save rejected');
assert.equal(button(tree,'应用取景').props.disabled,false);
tree=await mount(()=>true);button(tree,'取消').props.onClick();await tick();assert.equal(draw(),null,'cancel has the same terminal state');
tree=await mount(()=>{throw Error('Saved, but restart failed');});
setHandler(method=>{if(method==='cast.framing.apply')throw Error('Saved, but restart failed');if(method==='cast.framing.get')throw Error('画幅调整窗口已关闭');throw Error(method);});
button(tree,'应用取景').props.onClick();await tick();assert.equal(draw(),null,'post-save restart failure cannot revive a closed editor');
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
function frame(tree){let found;walk(tree,node=>{if(node.props?.spatial&&node.props?.settings&&node.props?.onChange)found=node;});assert.ok(found,'framing editor');return found;}
tree=await mount(()=>true);await delay(120);
let references=calls.filter(call=>call.method==='cast.framing.reference');
assert.equal(references.length,1);assert.deepEqual(references[0].params,{id:geometry.id,centerX:0,centerY:0});
frame(tree).props.onChange({centerX:5,centerY:-3});tree=draw();
frame(tree).props.onChange({centerX:6,centerY:-4});tree=draw();await delay(120);
references=calls.filter(call=>call.method==='cast.framing.reference');
assert.equal(references.length,2,'rapid committed centers are debounced');
assert.ok(Math.abs(references[1].params.centerX-6)<1e-8&&Math.abs(references[1].params.centerY+4)<1e-8,'live draft is sent in degrees');
assert.ok(references[0].signal.aborted,'previous reference request is canceled after a new center');
frame(tree).props.onChange({centerX:7});tree=draw();
button(tree,'取消').props.onClick();await tick();assert.equal(draw(),null);await delay(120);
assert.equal(calls.filter(call=>call.method==='cast.framing.reference').length,2,'closing cancels the pending depth-probe update');
console.log('PASS: terminal editor lifecycle, error recovery, debounced live-center depth updates and closing cancellation');
+24
View File
@@ -0,0 +1,24 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {readFile} from 'node:fs/promises';
Object.defineProperty(globalThis,'navigator',{value:{languages:['en-US']},configurable:true});
globalThis.document={documentElement:{lang:''},addEventListener(){}};
globalThis.window={addEventListener(){}};
const bundle=await build({stdin:{contents:`import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {SpatialFraming} from './ui/src/cast-spatial-framing';export {loadSpatialFraming} from './ui/src/cast-spatial-framing';export const render=()=>renderToStaticMarkup(<SpatialFraming/>);`,loader:'tsx',resolveDir:process.cwd()},bundle:true,platform:'node',format:'cjs',loader:{'.css':'empty'},write:false});
const module={exports:{}};new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const calls=[];
const geometry={id:'test'};
await module.exports.loadSpatialFraming(async method=>{calls.push(method);return method==='status'?{database:{language:'zh-CN'}}:method==='language.list'?{packs:[]}:geometry;});
assert.deepEqual(calls.sort(),['cast.framing.get','language.list','status']);
assert.equal(document.documentElement.lang,'zh-CN');
const chinese=module.exports.render();
for(const text of ['画幅调整','画面比例','录制分辨率','应用取景','重置大小'])assert.ok(chinese.includes(text),text);
assert.ok(!chinese.includes('<select'));
assert.equal((chinese.match(/aria-haspopup="listbox"/g)||[]).length,2);
await module.exports.loadSpatialFraming(async method=>method==='status'?{database:{language:'en-US'}}:method==='language.list'?{packs:[]}:geometry);
assert.ok(module.exports.render().includes('Adjust recording frame'));
const css=await readFile('ui/src/casting.css','utf8');
assert.match(css,/\.cast-quick-framing-row\{display:flex;align-items:center;justify-content:space-between/);
assert.match(css,/\.cast-quick-framing-row \.switch-row\{width:auto;min-width:0/);
console.log('PASS: spatial frame loads configured language and uses two in-page dropdowns; quick controls share one row');
+20
View File
@@ -0,0 +1,20 @@
import {build} from 'esbuild';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
globalThis.document={documentElement:{lang:''},addEventListener(){}};
globalThis.window={addEventListener(){}};
const bundle=await build({stdin:{contents:`import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {Transfer} from './ui/src/store';export const render=(job)=>renderToStaticMarkup(<Transfer job={job} close={()=>{}}/>);`,resolveDir:process.cwd(),loader:'tsx'},bundle:true,platform:'node',format:'cjs',write:false});
const module={exports:{}};
new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
const render=(received,total,phase='downloading')=>module.exports.render({id:'test',kind:'inspect',phase,received,total});
for(const received of [995,999,1000,1001]){
const html=render(received,1000);
assert.ok(html.includes('99%'));
assert.ok(!html.includes('100%'));
assert.ok(html.includes('value="99"'));
}
assert.ok(render(500,1000).includes('50%'));
assert.ok(!render(500,undefined).includes('NaN'));
assert.ok(!render(1000,1000,'verifying').includes('<progress'));
console.log('PASS: incomplete downloads never display 100%, including rounded and final-byte progress');
+13
View File
@@ -0,0 +1,13 @@
#include "framing_lifecycle.h"
#include <cassert>
#include <iostream>
int main(){
using nlohmann::json;
assert(!framing_editor_should_close(nullptr,"current"));
assert(!framing_editor_should_close(json::object(),"current"));
assert(!framing_editor_should_close({{"id","current"},{"open",true}},"current"));
assert(framing_editor_should_close({{"id","current"},{"open",false}},"current"));
assert(!framing_editor_should_close({{"id","old"},{"open",false}},"current"));
assert(framing_editor_should_close({{"id","new"},{"open",true}},"current"));
std::cout<<"PASS: persistent framing state repairs lost closes and ignores stale acknowledgements\n";
}
+23
View File
@@ -0,0 +1,23 @@
#include "openvr.h"
#include "recording_indicator.h"
#include "launcher_input.h"
#include <cassert>
int main(){
RecordingIndicatorClick click;
assert(!click.update(true,true));assert(!click.update(false,true));
assert(!click.update(true,false));assert(!click.update(false,true));
assert(!click.update(true,true));assert(click.update(false,true));assert(!click.update(false,true));
assert(!click.update(true,true));assert(!click.update(false,true,false));assert(!click.update(false,true));
EntryAnalogTrigger trigger;RecordingIndicatorClick analog_click;
assert(!analog_click.update(trigger.update(0.f),true));
assert(!analog_click.update(trigger.update(.6f),true));
assert(!analog_click.update(trigger.update(.46f),true));
assert(analog_click.update(trigger.update(.44f),true));
for(auto seconds:{0ULL,59ULL,60ULL,3600ULL,9999999ULL}){
auto normal=recording_indicator_pixels(seconds,false),hover=recording_indicator_pixels(seconds,true);
assert(normal.size()==320*80*4&&hover.size()==normal.size());
assert(normal!=hover);assert(normal[3]==0);
auto red=(40*320+32)*4;assert(normal[red]>200&&normal[red+1]<100);
auto stop=(40*320+280)*4;assert(normal[stop]==245&&normal[stop+3]==255);
}
}
+25
View File
@@ -0,0 +1,25 @@
#include "openvr.h"
#include "recording_region.h"
#include <cassert>
#include <iostream>
int main(){
for(double distance:{.5,1.,2.,4.})for(double roll:{-.2,0.,.2})for(double yaw:{-.3,0.,.3})for(double aspect:{4./3,16./9,9./16,1.}){
double c=cos(roll),s=sin(roll),cy=cos(yaw),sy=sin(yaw);
RegionJson view={{"captureToHead",{{c*cy,-s, c*sy,-.032},{s*cy,c,s*sy,.003},{-sy,0,cy,.01}}}};
const double tan=.8,cx=.15,center_y=-.08;
RecordingRegionPlane plane;assert(recording_region_plane(view,tan,cx,center_y,aspect,plane,distance));
// Undo the exported head basis: each overlay corner must be exactly the
// tangent ray consumed at the corresponding output-LUT corner.
for(double u:{0.,.25,1.})for(double v:{0.,.75,1.}){
double world[3];for(int i=0;i<3;i++)world[i]=plane.transform.m[i][3]+plane.transform.m[i][0]*(u-.5)*plane.width+plane.transform.m[i][1]*(.5-v)*plane.width/aspect-view["captureToHead"][i][3].get<double>();
double ray[3]{};for(int j=0;j<3;j++)for(int i=0;i<3;i++)ray[j]+=view["captureToHead"][i][j].get<double>()*world[i]/distance;
assert(std::abs(ray[0]-(cx+(2*u-1)*tan))<1e-6);
assert(std::abs(ray[1]-(-center_y+(1-2*v)*tan/aspect))<1e-6);
assert(std::abs(ray[2]+1.)<1e-6);
}
}
auto pixels=recording_region_pixels(100,80);assert(pixels[(40*100+50)*4+3]==0);assert(pixels[3]>0&&pixels[3]<100);
auto colored=recording_region_pixels(100,80,{12,34,56});assert(colored[0]==12&&colored[1]==34&&colored[2]==56&&colored[3]==72);
RecordingRegionPlane bad;assert(!recording_region_plane(RegionJson::object(),1,0,0,1,bad));
std::cout<<"PASS: recording border projection matches capture rays, including roll/yaw/eye offset and aspect ratios\n";
}
+86
View File
@@ -0,0 +1,86 @@
#include "recording_region_depth.h"
#include <cassert>
#include <iostream>
#include <limits>
using namespace recording_region_depth_detail;
static vr::HmdMatrix34_t pose(double yaw=0,double pitch=0,double roll=0,Point t={0,0,0}){
const double cy=cos(yaw),sy=sin(yaw),cp=cos(pitch),sp=sin(pitch),cr=cos(roll),sr=sin(roll);
vr::HmdMatrix34_t m{{
{float(cy*cr+sy*sp*sr),float(-cy*sr+sy*sp*cr),float(sy*cp),float(t[0])},
{float(cp*sr),float(cp*cr),float(-sp),float(t[1])},
{float(-sy*cr+cy*sp*sr),float(sy*sr+cy*sp*cr),float(cy*cp),float(t[2])}}};return m;
}
static vr::HmdMatrix34_t multiply(const vr::HmdMatrix34_t& a,const vr::HmdMatrix34_t& b){
vr::HmdMatrix34_t m{};for(int i=0;i<3;i++){
for(int j=0;j<3;j++)for(int k=0;k<3;k++)m.m[i][j]+=a.m[i][k]*b.m[k][j];
m.m[i][3]=a.m[i][3];for(int k=0;k<3;k++)m.m[i][3]+=a.m[i][k]*b.m[k][3];
}return m;
}
static void near(double a,double b,double tolerance=1e-5){assert(std::abs(a-b)<tolerance);}
int main(){
for(double ipd:{0.055,0.064,0.075})for(auto eye:{vr::Eye_Left,vr::Eye_Right}){
const auto left=pose(0,0,0,{-ipd/2,0,0.012}),right=pose(0,0,0,{ipd/2,0,0.012});
auto capture=pose(0.12,-0.08,0.04,{0,0,0});const auto before=capture;
assert(recording_region_refresh_eye_origin(eye,left,right,capture));
for(int i=0;i<3;i++)for(int j=0;j<3;j++)assert(capture.m[i][j]==before.m[i][j]);
const auto selected=eye==vr::Eye_Left?left:right;
for(int i=0;i<3;i++)near(capture.m[i][3],selected.m[i][3]);
for(double depth:{0.5,1.,2.,4.})for(double yaw:{-0.35,0.,0.35})for(double pitch:{-0.25,0.,0.25}){
const auto hmd=pose(0.4,-0.1,0.05,{0.5,1.6,-0.2});
const double cx=0.16,cy=-0.09;
const auto origin=add(translation(hmd),rotate(hmd,translation(capture)));
const auto direction=rotate(hmd,rotate(capture,{cx,-cy,-1.}));
const Point intersection={origin[0]+depth*direction[0],origin[1]+depth*direction[1],origin[2]+depth*direction[2]};
auto window=multiply(hmd,pose(yaw,pitch,0.07));for(int i=0;i<3;i++)window.m[i][3]=float(intersection[i]);
const auto result=recording_region_window_depth(hmd,window,capture,cx,cy);
assert(result);near(*result,depth);
const vr::HmdVector3_t hit{{float(intersection[0]),float(intersection[1]),float(intersection[2])}};
const auto hit_depth=recording_region_hit_depth(hmd,capture,cx,cy,hit);
assert(hit_depth);near(*hit_depth,depth);
}
// Any finite plane distance preserves selected-eye ray bounds. The other
// eye's shift is baseline/D, symmetric for left and right recordings.
for(double depth:{0.5,1.,2.,4.})for(double tx:{-0.7,0.,0.7}){
auto parallel=pose(0,0,0,{0,0,0});assert(recording_region_refresh_eye_origin(eye,left,right,parallel));
const auto origin=translation(parallel),other=translation(eye==vr::Eye_Left?right:left);
const Point point={origin[0]+depth*tx,origin[1],origin[2]-depth};
near((point[0]-origin[0])/(origin[2]-point[2]),tx);
const double baseline=origin[0]-other[0];
const double actual_other=(point[0]-other[0])/(other[2]-point[2]);
near(actual_other,tx+baseline/depth);
const double old_other=tx+baseline/2.;
near(actual_other-old_other,baseline*(1./depth-0.5));
if(depth!=2.)assert(std::abs(actual_other-old_other)>0.001);
}
}
// Changing IPD refreshes a stale exported origin without altering ray basis.
auto stale=pose(0,0,0,{0.032,0,0});
assert(recording_region_refresh_eye_origin(vr::Eye_Right,pose(0,0,0,{-0.0375,0,0}),pose(0,0,0,{0.0375,0,0}),stale));
near(stale.m[0][3],0.0375);
const auto identity=pose(),left=pose(0,0,0,{-0.032,0,0}),right=pose(0,0,0,{0.032,0,0});
auto unchanged=identity;
assert(!recording_region_refresh_eye_origin(vr::Eye_Right,pose(0,0,0,{-0.1,0,0}),pose(0,0,0,{0.1,0,0}),unchanged));
assert(!recording_region_refresh_eye_origin(vr::Eye_Right,right,left,unchanged));
assert(unchanged.m[0][3]==0);
auto invalid=identity;invalid.m[0][0]=std::numeric_limits<float>::quiet_NaN();
assert(!recording_region_refresh_eye_origin(vr::Eye_Right,invalid,right,unchanged));
assert(!recording_region_window_depth(invalid,pose(0,0,0,{0,0,-2}),identity,0,0));
assert(!recording_region_window_depth(vr::HmdMatrix34_t{},pose(0,0,0,{0,0,-2}),identity,0,0));
assert(!recording_region_window_depth(identity,pose(acos(-1.)/2,0,0,{0,0,-2}),identity,0,0));
for(double z:{-0.1,-8.,1.})assert(!recording_region_window_depth(identity,pose(0,0,0,{0,0,z}),identity,0,0));
assert(!recording_region_window_depth(identity,pose(0,0,0,{0,0,-2}),identity,std::numeric_limits<double>::infinity(),0));
// Eye Z matters: head-relative depth alone would return 2, but D=2.012.
const auto zdepth=recording_region_window_depth(identity,pose(0,0,0,{0,0,-2}),right,0,0);
assert(zdepth);near(*zdepth,2.0); // This fixture's right eye has Z=0.
const auto elevated=pose(0,0,0,{0.032,0,0.012});
const auto exact=recording_region_window_depth(identity,pose(0,0,0,{0,0,-2}),elevated,0,0);
assert(exact);near(*exact,2.012);
for(double depth:{0.25,0.5,1.,2.,4.,6.}){
const vr::HmdVector3_t hit{{0,0,float(-depth)}};
const auto result=recording_region_hit_depth(identity,identity,0,0,hit);assert(result);near(*result,depth);
}
for(const auto& hit:std::array<vr::HmdVector3_t,4>{{{{0.02f,0,-2}},{{0,0,2}},{{0,0,-8}},{{std::numeric_limits<float>::quiet_NaN(),0,-2}}}})
assert(!recording_region_hit_depth(identity,identity,0,0,hit));
assert(!recording_region_hit_depth(invalid,identity,0,0,vr::HmdVector3_t{{0,0,-2}}));
std::cout<<"PASS: current eye origin, exact center-ray UI depth, both-eye parallax and invalid-pose guards\n";
}
+37
View File
@@ -0,0 +1,37 @@
// Isolated device fixture: visible fiducials, never part of the installed app.
#include "openvr.h"
#include "recording_region.h"
#include <fstream>
#include <thread>
#include <chrono>
#include <filesystem>
#include <iostream>
int main(int argc,char** argv){
if(argc!=4)return 2;RegionJson view;std::ifstream(argv[1])>>view;
vr::EVRInitError error{};vr::VR_Init(&error,vr::VRApplication_Overlay);if(error)return 3;
auto* overlays=vr::VROverlay();vr::VROverlayHandle_t handle=0;
if(overlays->CreateOverlay("framely.region.alignment.fixture","Recording alignment fixture",&handle))return 4;
RecordingRegionPlane plane;if(!recording_region_plane(view,view.at("tanHalfHorizontal"),view.at("centerTanX"),view.at("centerTanY"),view.at("aspect"),plane))return 5;
const int w=view.at("width"),h=view.at("height"),texture=512;std::vector<unsigned char> pixels(texture*texture*4);
const int positions[5][2]={{16,16},{w-16,16},{16,h-16},{w-16,h-16},{w/2,h/2}};
const int colors[5][3]={{255,0,0},{0,255,0},{0,0,255},{255,0,255},{255,255,255}};
for(int y=0;y<texture;y++)for(int x=0;x<texture;x++)for(int p=0;p<5;p++){
double ox=(x+.5)*w/texture,oy=(y+.5)*h/texture;
if(ox<positions[p][0]-16||ox>=positions[p][0]+16||oy<positions[p][1]-16||oy>=positions[p][1]+16)continue;
auto i=(y*texture+x)*4;for(int c=0;c<3;c++)pixels[i+c]=colors[p][c];pixels[i+3]=255;
}
overlays->SetOverlayInputMethod(handle,vr::VROverlayInputMethod_None);
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,false);
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_VisibleInDashboard,true);
recording_region_foreground(overlays,handle);
overlays->SetOverlayWidthInMeters(handle,plane.width);
overlays->SetOverlayTexelAspect(handle,view.at("aspect"));
overlays->SetOverlayTransformTrackedDeviceRelative(handle,vr::k_unTrackedDeviceIndex_Hmd,&plane.transform);
overlays->SetOverlayRaw(handle,pixels.data(),texture,texture,4);overlays->ShowOverlay(handle);
std::ofstream(argv[2])<<"ready";
auto until=std::chrono::steady_clock::now()+std::chrono::seconds(40);
while(std::chrono::steady_clock::now()<until&&!std::filesystem::exists(argv[3])){
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
overlays->DestroyOverlay(handle);vr::VR_Shutdown();return 0;
}
+116
View File
@@ -0,0 +1,116 @@
// Isolated, opt-in wearer diagnostic. Never part of the installed app.
// Usage: helper geometry.json ready-file stop-file
#include "openvr.h"
#include "recording_region.h"
#include <fstream>
#include <thread>
#include <chrono>
#include <filesystem>
#include <iostream>
#include <stdexcept>
namespace {
constexpr int texture_size=512;
constexpr double aspect=4./3.;
constexpr int fovs[]{80,75,70,65,60};
constexpr std::array<unsigned char,3> colors[]{
{255,48,48},{255,230,32},{32,255,255},{48,255,64},{255,255,255}
};
// Five columns, seven rows; each row's most significant bit is the left pixel.
constexpr unsigned char digits[10][7]{
{14,17,19,21,25,17,14},{4,12,4,4,4,4,14},
{14,17,1,2,4,8,31},{30,1,1,14,1,1,30},
{2,6,10,18,31,2,2},{31,16,16,30,1,1,30},
{14,16,16,30,17,17,14},{31,1,2,4,8,8,8},
{14,17,17,14,17,17,14},{14,17,17,15,1,1,14}
};
std::vector<unsigned char> pixels(int fov,const std::array<unsigned char,3>& color){
std::vector<unsigned char> image(texture_size*texture_size*4);
auto put=[&](int x,int y){
auto index=(y*texture_size+x)*4;
for(int c=0;c<3;c++)image[index+c]=color[c];
image[index+3]=255;
};
constexpr int stroke=3;
for(int y=0;y<texture_size;y++)for(int x=0;x<texture_size;x++){
if(x<stroke||x>=texture_size-stroke||y<stroke||y>=texture_size-stroke)put(x,y);
}
// Center the FOV number just inside the top edge. Large block digits remain
// readable without CEF or a font installation; all other pixels are clear.
// Keep the label shorter than the angular gap to the next rectangle, so
// an inner horizontal border cannot strike through the outer FOV number.
constexpr int scale=2,digit_width=5*scale,gap=2*scale;
constexpr int start_x=(texture_size-(2*digit_width+gap))/2,start_y=5;
for(int digit=0;digit<2;digit++){
int value=digit==0?fov/10:fov%10;
for(int row=0;row<7;row++)for(int column=0;column<5;column++){
if(!(digits[value][row]&(1<<(4-column))))continue;
for(int dy=0;dy<scale;dy++)for(int dx=0;dx<scale;dx++)
put(start_x+digit*(digit_width+gap)+column*scale+dx,start_y+row*scale+dy);
}
}
return image;
}
void check(vr::IVROverlay* api,vr::EVROverlayError error,const char* operation){
if(error!=vr::VROverlayError_None)
throw std::runtime_error(std::string(operation)+": "+api->GetOverlayErrorNameFromEnum(error));
}
struct Cleanup {
vr::IVROverlay* api=nullptr;
std::vector<vr::VROverlayHandle_t> handles;
bool initialized=false;
~Cleanup(){
for(auto handle:handles)if(api){
auto hidden=api->HideOverlay(handle),destroyed=api->DestroyOverlay(handle);
if(hidden||destroyed)std::cerr<<"Diagnostic overlay cleanup error: "<<hidden<<", "<<destroyed<<"\n";
}
if(initialized)vr::VR_Shutdown();
}
};
}
int main(int argc,char** argv){
if(argc!=4){std::cerr<<"Usage: recording-visibility-device geometry.json ready-file stop-file\n";return 2;}
Cleanup cleanup;
try{
RegionJson view;std::ifstream input(argv[1]);if(!input)throw std::runtime_error("Cannot open geometry");input>>view;
const double cx=view.at("centerTanX"),cy=view.at("centerTanY");
vr::EVRInitError error{};vr::VR_Init(&error,vr::VRApplication_Overlay);
if(error)throw std::runtime_error(vr::VR_GetVRInitErrorAsEnglishDescription(error));
cleanup.initialized=true;cleanup.api=vr::VROverlay();
if(!cleanup.api)throw std::runtime_error("OpenVR overlay interface unavailable");
auto* api=cleanup.api;
for(int i=0;i<5;i++){
RecordingRegionPlane plane;
const double tangent=std::tan(fovs[i]*std::acos(-1.)/360.);
if(!recording_region_plane(view,tangent,cx,cy,aspect,plane))throw std::runtime_error("Invalid capture geometry");
vr::VROverlayHandle_t handle=0;
const std::string key="framely.recording.visibility.fixture."+std::to_string(fovs[i]);
check(api,api->CreateOverlay(key.c_str(),("Recording visibility "+std::to_string(fovs[i])).c_str(),&handle),"CreateOverlay");
cleanup.handles.push_back(handle);
check(api,api->SetOverlayInputMethod(handle,vr::VROverlayInputMethod_None),"SetOverlayInputMethod");
check(api,api->SetOverlayFlag(handle,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,false),"Disable interactive overlay");
check(api,api->SetOverlayFlag(handle,vr::VROverlayFlags_HideLaserIntersection,true),"Hide laser intersection");
check(api,api->SetOverlayFlag(handle,vr::VROverlayFlags_VisibleInDashboard,true),"VisibleInDashboard");
check(api,api->SetOverlayFlag(handle,vr::VROverlayFlags_SortWithNonSceneOverlays,true),"Foreground sort flag");
check(api,api->SetOverlaySortOrder(handle,1000+i),"SetOverlaySortOrder");
check(api,api->SetOverlayWidthInMeters(handle,plane.width),"SetOverlayWidthInMeters");
check(api,api->SetOverlayTexelAspect(handle,aspect),"SetOverlayTexelAspect");
check(api,api->SetOverlayTransformTrackedDeviceRelative(handle,vr::k_unTrackedDeviceIndex_Hmd,&plane.transform),"SetOverlayTransformTrackedDeviceRelative");
auto image=pixels(fovs[i],colors[i]);
check(api,api->SetOverlayRaw(handle,image.data(),texture_size,texture_size,4),"SetOverlayRaw");
check(api,api->ShowOverlay(handle),"ShowOverlay");
}
std::ofstream ready(argv[2]);if(!ready)throw std::runtime_error("Cannot open ready file");
ready<<"ready\n";ready.close();if(!ready)throw std::runtime_error("Cannot write ready file");
std::cout<<"Visibility diagnostic ready: eye="<<view.at("eye")<<", center="<<cx<<","<<cy
<<", 4:3 FOVs red80 yellow75 cyan70 green65 white60; auto-hide after 90 seconds\n"<<std::flush;
const auto until=std::chrono::steady_clock::now()+std::chrono::seconds(90);
while(std::chrono::steady_clock::now()<until&&!std::filesystem::exists(argv[3]))
std::this_thread::sleep_for(std::chrono::milliseconds(20));
return 0;
}catch(const std::exception& error){std::cerr<<"Recording visibility diagnostic: "<<error.what()<<"\n";return 1;}
}
+82
View File
@@ -0,0 +1,82 @@
import importlib.util
import io
import pathlib
import struct
import tarfile
import tempfile
import unittest
from unittest.mock import patch
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / 'tools/package-q2f.py'
SPEC = importlib.util.spec_from_file_location('package_q2f', SCRIPT)
PACKAGE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(PACKAGE)
def elf(machine, elf_class):
data = bytearray(1024)
data[:6] = b'\x7fELF' + bytes((elf_class, 1))
struct.pack_into('<H', data, 18, machine)
return bytes(data)
class FrameBridgePackageTests(unittest.TestCase):
def test_wraps_both_abis_and_keeps_corresponding_source(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
payload = root / 'payload'
adapters = {}
for abi, machine in PACKAGE.FRAMEBRIDGE_ABIS.items():
lib = payload / 'libraries' / PACKAGE.LIBRARIES_VERSION / 'lib' / abi
lib.mkdir(parents=True)
(lib / 'libopenxr_loader_generic.so').write_bytes(b'original ' + abi.encode())
adapter = root / f'{abi}.so'
adapter.write_bytes(elf(machine, 2 if abi == 'arm64-v8a' else 1))
adapters[abi] = adapter
source = root / 'frameport.tar.gz'
prefix = f'frameport-{PACKAGE.FRAMEBRIDGE_COMMIT}/'
with tarfile.open(source, 'w:gz') as archive:
for name in ('LICENSE', 'THIRD_PARTY_NOTICES.md', 'native/README.md',
'native/build.py', 'native/adapter/frame_adapter.c'):
content = name.encode()
entry = tarfile.TarInfo(prefix + name)
entry.size = len(content)
archive.addfile(entry, io.BytesIO(content))
with patch.object(PACKAGE, 'FRAMEBRIDGE_SOURCE_SHA256', PACKAGE.digest(source)):
PACKAGE.install_framebridge(payload, source, adapters)
self.assertEqual((payload / 'FRAMEBRIDGE_LICENSE').read_bytes(), b'LICENSE')
with tarfile.open(payload / 'FRAMEBRIDGE_SOURCE.tar.gz') as archive:
self.assertIn('framebridge/native/adapter/frame_adapter.c', archive.getnames())
self.assertIn('framebridge/native/build.py', archive.getnames())
for abi in adapters:
lib = payload / 'libraries' / PACKAGE.LIBRARIES_VERSION / 'lib' / abi
self.assertEqual((lib / 'libopenxr_loader_original.so').read_bytes(), b'original ' + abi.encode())
self.assertEqual((lib / 'libopenxr_loader_generic.so').read_bytes(), adapters[abi].read_bytes())
self.assertIn(b'controller_fix=1', (lib / 'libframe_settings.so').read_bytes())
def test_rejects_wrong_adapter_architecture_before_replacing_loader(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
payload = root / 'payload'
source = root / 'source.tar.gz'
prefix = f'frameport-{PACKAGE.FRAMEBRIDGE_COMMIT}/'
with tarfile.open(source, 'w:gz') as archive:
for name in ('LICENSE', 'THIRD_PARTY_NOTICES.md', 'native/README.md',
'native/build.py', 'native/adapter/frame_adapter.c'):
content = name.encode()
entry = tarfile.TarInfo(prefix + name)
entry.size = len(content)
archive.addfile(entry, io.BytesIO(content))
lib = payload / 'libraries' / PACKAGE.LIBRARIES_VERSION / 'lib/arm64-v8a'
lib.mkdir(parents=True)
(lib / 'libopenxr_loader_generic.so').write_bytes(b'original')
adapter = root / 'wrong.so'
adapter.write_bytes(elf(40, 1))
with patch.object(PACKAGE, 'FRAMEBRIDGE_SOURCE_SHA256', PACKAGE.digest(source)):
with self.assertRaisesRegex(ValueError, 'wrong architecture'):
PACKAGE.install_framebridge(payload, source, {'arm64-v8a': adapter, 'armeabi-v7a': adapter})
self.assertEqual((lib / 'libopenxr_loader_generic.so').read_bytes(), b'original')
if __name__ == '__main__':
unittest.main()
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-only
import com.android.apksig.ApkVerifier;
import java.io.File;
/** Verify an OVRPort-produced APK before handing it to Framely's install review. */
public final class FramelyQ2FVerify {
public static void main(String[] args) throws Exception {
if (args.length != 1) {
throw new IllegalArgumentException("Expected one APK path");
}
ApkVerifier.Result result = new ApkVerifier.Builder(new File(args[0])).build().verify();
if (!result.isVerified()) {
for (ApkVerifier.IssueWithParams issue : result.getErrors()) {
System.err.println(issue);
}
throw new IllegalStateException("Quest converter produced an invalid APK signature");
}
}
}
+2
View File
@@ -18,6 +18,8 @@ cargo build --release --locked --manifest-path media/capture/Cargo.toml
mkdir -p media/bin
capture_target=$(cargo metadata --locked --no-deps --format-version 1 --manifest-path media/capture/Cargo.toml | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')
cp "$capture_target/release/framely-capture" "$capture_target/release/framely-panel-grab" media/bin/
# Incremental release builds may reuse unchanged AirPlay/MediaMTX artifacts.
[[ ${FRAMELY_CAPTURE_ONLY:-0} != 1 ]] || exit 0
# Native dependencies use the target system's GStreamer and GUPnP libraries.
python3 tools/fetch-media.py
bash tools/build-gst-libav.sh
+64 -2
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3
"""Build an independently downloadable Quest conversion runtime."""
import argparse
import gzip
import hashlib
import json
import pathlib
import shutil
import struct
import subprocess
import tarfile
import tempfile
import zipfile
@@ -14,6 +17,9 @@ LIBRARIES_VERSION = '3.4.3-23204ea'
CLI_SHA256 = '307d58a69a5c9722f912d1b6566b0dd1e0ebe1440c6d1e56cab6cb883d74fef2'
LIBRARIES_SHA256 = '6cec6c176e1aeee8d9ec4f9b4e3982f71da7b44a36a293249620073a06f00e5e'
SOURCE_SHA256 = '2db2c0f597a8be01d9742180019627b4c23fd0213f04f5034fbad53f0d296eb3'
FRAMEBRIDGE_COMMIT = '80ef9ca6a77eed1ca6b2cc6f746d9897e4178b4e'
FRAMEBRIDGE_SOURCE_SHA256 = 'ea90ea8dd2f0fe49d54d2df5cc61c1876c9562e55fb40fce2651b1d5594dab7c'
FRAMEBRIDGE_ABIS = {'arm64-v8a': 183, 'armeabi-v7a': 40}
def digest(path):
@@ -37,6 +43,55 @@ def safe_zip_extract(source, destination):
shutil.copyfileobj(data, output)
def framebridge_source(source, destination):
"""Ship the exact adapter source and its build instructions with the runtime."""
prefix = f'frameport-{FRAMEBRIDGE_COMMIT}/'
fixed = {'LICENSE', 'THIRD_PARTY_NOTICES.md', 'native/README.md', 'native/build.py'}
with tarfile.open(source, 'r:gz') as upstream, destination.open('wb') as output, \
gzip.GzipFile(filename='', mode='wb', fileobj=output, mtime=0) as compressed, \
tarfile.open(fileobj=compressed, mode='w') as selected:
files = [m for m in upstream.getmembers() if m.name.startswith(prefix)
and (m.name[len(prefix):] in fixed or m.name[len(prefix):].startswith('native/adapter/'))
and m.isfile()]
if not fixed.issubset({m.name[len(prefix):] for m in files}) or len(files) > 100:
raise ValueError('Incomplete FrameBridge corresponding source')
for member in sorted(files, key=lambda m: m.name):
relative = member.name[len(prefix):]
if member.size > 2 * 1024 * 1024 or '..' in pathlib.PurePosixPath(relative).parts:
raise ValueError('Invalid FrameBridge source entry')
copy = tarfile.TarInfo(f'framebridge/{relative}')
copy.size, copy.mode, copy.mtime = member.size, 0o644, 0
selected.addfile(copy, upstream.extractfile(member))
def install_framebridge(payload, source, adapters):
if digest(source) != FRAMEBRIDGE_SOURCE_SHA256:
raise ValueError('Pinned FrameBridge source checksum mismatch')
with tarfile.open(source, 'r:gz') as upstream:
license_file = upstream.extractfile(f'frameport-{FRAMEBRIDGE_COMMIT}/LICENSE')
if license_file is None:
raise ValueError('FrameBridge license missing')
(payload / 'FRAMEBRIDGE_LICENSE').write_bytes(license_file.read())
framebridge_source(source, payload / 'FRAMEBRIDGE_SOURCE.tar.gz')
(payload / 'FRAMEBRIDGE_SOURCE').write_text(
f'https://github.com/spoopyghosty0/frameport/tree/{FRAMEBRIDGE_COMMIT}\n')
for abi, machine in FRAMEBRIDGE_ABIS.items():
adapter = pathlib.Path(adapters[abi])
data = adapter.read_bytes()
elf_class = 2 if abi == 'arm64-v8a' else 1
if (len(data) < 1024 or data[:6] != b'\x7fELF' + bytes((elf_class, 1))
or struct.unpack_from('<H', data, 18)[0] != machine):
raise ValueError(f'FrameBridge {abi} binary has the wrong architecture')
lib = payload / 'libraries' / LIBRARIES_VERSION / 'lib' / abi
generic = lib / 'libopenxr_loader_generic.so'
original = lib / 'libopenxr_loader_original.so'
if not generic.is_file() or original.exists():
raise ValueError(f'OVRPort {abi} generic loader cannot be wrapped')
generic.rename(original)
generic.write_bytes(data)
(lib / 'libframe_settings.so').write_text('controller_fix=1\nprofile_remap=1\n')
def package(args):
cli, libraries, java, source = map(pathlib.Path, (args.cli_jar, args.libraries_zip, args.java_home, args.source_archive))
if digest(cli) != CLI_SHA256 or digest(libraries) != LIBRARIES_SHA256 or digest(source) != SOURCE_SHA256:
@@ -49,7 +104,12 @@ def package(args):
payload = pathlib.Path(scratch) / 'payload'
shutil.copytree(java, payload / 'java', symlinks=False)
shutil.copy2(cli, payload / 'overportcli.jar')
verifier = pathlib.Path(__file__).with_name('FramelyQ2FVerify.java')
subprocess.run(['javac', '-cp', str(cli), '-d', str(payload), str(verifier)], check=True)
shutil.copy2(verifier, payload / verifier.name)
safe_zip_extract(libraries, payload / 'libraries' / LIBRARIES_VERSION)
install_framebridge(payload, pathlib.Path(args.framebridge_source), {
'arm64-v8a': args.framebridge_arm64, 'armeabi-v7a': args.framebridge_arm32})
(payload / 'overport-installed.json').write_text(json.dumps({'releases': [
{'version': LIBRARIES_VERSION, 'app_required': '1.2.0', 'is_experimental': True}
]}) + '\n')
@@ -58,7 +118,7 @@ def package(args):
(payload / 'SOURCE').write_text('https://github.com/ovrport/app/tree/c790854\n')
entries = sorted(p for p in payload.rglob('*') if p.is_file())
checksums = ''.join(f'{digest(p)} {p.relative_to(payload).as_posix()}\n' for p in entries)
identity = f'overport-{CLI_VERSION}-{LIBRARIES_VERSION}-{hashlib.sha256(checksums.encode()).hexdigest()[:12]}'
identity = f'overport-{CLI_VERSION}-{LIBRARIES_VERSION}-framebridge-{FRAMEBRIDGE_COMMIT[:8]}-{hashlib.sha256(checksums.encode()).hexdigest()[:12]}'
(payload / 'VERSION').write_text(identity + '\n')
(payload / 'SHA256SUMS').write_text(checksums)
archive = output / f'framely-q2f-{identity}-linux-arm64.tar.gz'
@@ -66,6 +126,7 @@ def package(args):
tar.add(payload, arcname=f'framely-q2f-{identity}')
requirement = {'schemaVersion': 1, 'id': identity,
'cliVersion': CLI_VERSION, 'librariesVersion': LIBRARIES_VERSION,
'framebridgeCommit': FRAMEBRIDGE_COMMIT,
'archive': archive.name, 'size': archive.stat().st_size,
'sha256': digest(archive),
'url': f'https://github.com/{args.repo}/releases/download/{args.tag}/{archive.name}'}
@@ -76,6 +137,7 @@ def package(args):
if __name__ == '__main__':
parser = argparse.ArgumentParser()
for name in ('stage', 'output', 'java-home', 'cli-jar', 'libraries-zip', 'license', 'source-archive', 'repo', 'tag'):
for name in ('stage', 'output', 'java-home', 'cli-jar', 'libraries-zip', 'license', 'source-archive',
'framebridge-source', 'framebridge-arm64', 'framebridge-arm32', 'repo', 'tag'):
parser.add_argument('--' + name, required=True)
package(parser.parse_args())
+12 -2
View File
@@ -10,7 +10,12 @@ npm ci --no-audit --no-fund
npm run build
bash tools/build-native.sh "$cef"
if [[ ${FRAMELY_USE_PREBUILT_MEDIA:-0} == 1 ]]; then
[[ -z $(git status --porcelain -- media) ]] || { echo 'Media sources changed; rebuild media.' >&2; exit 1; }
if [[ ${FRAMELY_REBUILD_CAPTURE_ONLY:-0} == 1 ]]; then
[[ -z $(git status --porcelain -- media ':!media/capture') ]] || { echo 'Non-capture media sources changed; rebuild all media.' >&2; exit 1; }
FRAMELY_CAPTURE_ONLY=1 bash tools/build-media.sh
else
[[ -z $(git status --porcelain -- media) ]] || { echo 'Media sources changed; rebuild media.' >&2; exit 1; }
fi
for file in media/bin/framely-panel-grab media/bin/framely-capture media/bin/mediamtx media/bin/uxplay media/bin/framely-receiver media/bin/libgstframely.so media/bin/libgstlibav.so media/bin/LICENSE media/bin/LICENSE.uxplay media/bin/LICENSE.gst-libav media/bin/LICENSE.llhttp; do
[[ -s $file ]] || { echo "Missing prebuilt media: $file" >&2; exit 1; }
done
@@ -24,7 +29,10 @@ q2f_cli=${Q2F_CLI_JAR:-target/overportcli-1.2.3-all.jar}
q2f_libraries=${Q2F_LIBRARIES_ZIP:-target/overport-libraries-3.4.3-23204ea.zip}
q2f_license=${Q2F_LICENSE:-target/overport-source/LICENSE}
q2f_source=${Q2F_SOURCE_ARCHIVE:-target/q2f-source.tar.gz}
build_id="$version-$(find "$binary" target/native media/bin ui/dist sdk templates/plugin examples/showcase packaging tools docs assets/branding assets/search README.md README.zh-CN.md LICENSE "$q2f_java" "$q2f_cli" "$q2f_libraries" "$q2f_license" "$q2f_source" -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)"
framebridge_source=${Q2F_FRAMEBRIDGE_SOURCE:-target/q2f-framebridge/source.tar.gz}
framebridge_arm64=${Q2F_FRAMEBRIDGE_ARM64:-target/q2f-framebridge/artifacts/arm64-v8a/libopenxr_loader_generic.so}
framebridge_arm32=${Q2F_FRAMEBRIDGE_ARM32:-target/q2f-framebridge/artifacts/armeabi-v7a/libopenxr_loader_generic.so}
build_id="$version-$(find "$binary" target/native media/bin ui/dist sdk templates/plugin examples/showcase packaging tools docs assets/branding assets/search README.md README.zh-CN.md LICENSE "$q2f_java" "$q2f_cli" "$q2f_libraries" "$q2f_license" "$q2f_source" "$framebridge_source" "$framebridge_arm64" "$framebridge_arm32" -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)"
stage="$base/release/framely-$build_id"
[[ ! -e $stage ]] || { echo 'Release already exists.' >&2; exit 1; }
mkdir -p "$stage"/{bin,lib/cef,lib/openvr,lib/media,share/ui,share/licenses,tools}
@@ -88,6 +96,8 @@ python3 tools/package-q2f.py --stage "$stage" --output release \
--java-home "$q2f_java" --cli-jar "$q2f_cli" \
--libraries-zip "$q2f_libraries" --license "$q2f_license" \
--source-archive "$q2f_source" \
--framebridge-source "$framebridge_source" \
--framebridge-arm64 "$framebridge_arm64" --framebridge-arm32 "$framebridge_arm32" \
--repo "${RELEASE_REPO:-SteamFramelyHomebrew/framely}" --tag "${RELEASE_TAG:-v$version}"
python3 tools/package-runtime.py "$stage" "$cef" "${RELEASE_REPO:-SteamFramelyHomebrew/framely}" "${RELEASE_TAG:-v$version}"
echo "Package: release/framely-$build_id-linux-arm64.tar.gz"
+11
View File
@@ -43,6 +43,17 @@ def validate(root, identity):
for required in ('java/bin/java', 'overportcli.jar', 'overport-installed.json', 'OVR_PORT_LICENSE'):
if required not in expected:
raise ValueError('Incomplete q2f runtime: ' + required)
if '-framebridge-' in identity:
for required in ('FRAMEBRIDGE_LICENSE', 'FRAMEBRIDGE_SOURCE', 'FRAMEBRIDGE_SOURCE.tar.gz',
'FramelyQ2FVerify.java', 'FramelyQ2FVerify.class'):
if required not in expected:
raise ValueError('Incomplete q2f runtime: ' + required)
releases = root / 'libraries'
for abi in ('arm64-v8a', 'armeabi-v7a'):
libs = list(releases.glob(f'*/lib/{abi}'))
if len(libs) != 1 or not all((libs[0] / name).is_file() for name in (
'libopenxr_loader_generic.so', 'libopenxr_loader_original.so', 'libframe_settings.so')):
raise ValueError('Incomplete FrameBridge libraries: ' + abi)
class HTTPSRedirect(urllib.request.HTTPRedirectHandler):
+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; 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; do
"${CXX:-g++}" -std=c++17 -O2 -pthread -Inative -Inative/vendor/openvr "tests/$test.cpp" -ldl -o "target/tests/$test"
"target/tests/$test"
done
+35 -1
View File
@@ -3,6 +3,38 @@
"locale": "en-US",
"name": "English",
"messages": {
"重置大小": "Reset size",
"录制眼已改变,请重新打开画幅调整": "The recording eye has changed. Reopen frame adjustment.",
"保留比例和分辨率,恢复居中有效画幅": "Keep the aspect ratio and resolution, and restore the centered valid frame.",
"菜单尺寸超过当前可录制范围": "The menu is larger than the available recording area.",
"缺少有效的面板校准数据,无法显示准确录制区域": "Valid panel calibration is unavailable; an accurate recording region cannot be displayed.",
"取景范围超出头显可见区域,请缩小或调整位置": "The recording region extends beyond the headset's visible area. Reduce its size or adjust its position.",
"自定义": "Custom",
"画幅调整": "Adjust recording frame",
"录制区域显示": "Show recording region",
"框内即录制区域,拖动移动,拖动四角等比例缩放。": "The area inside the frame is recorded. Drag to move; drag a corner to resize proportionally.",
"录制分辨率": "Recording resolution",
"录制区域显示仅支持屏幕采集的单眼画面": "Recording region display requires single-eye screen capture.",
"画幅调整仅支持屏幕采集的单眼画面": "Frame adjustment requires single-eye screen capture.",
"请先关闭画幅调整窗口": "Close frame adjustment first.",
"画幅调整窗口已关闭": "The frame adjustment window has closed.",
"无法读取实际录制区域,请更新采集程序": "Cannot read the actual recording region. Update the capture worker.",
"无法读取实际录制区域,请确认 SteamVR 已启动": "Cannot read the actual recording region. Make sure SteamVR is running.",
"读取录制区域超时": "Reading the recording region timed out.",
"安卓权限请求": "Android permission request",
"正在读取权限请求": "Reading permission request",
"安卓权限选择": "Android permission selection",
"安卓原生权限对话框": "Native Android permission dialog",
"正在等待安卓权限画面…": "Waiting for the Android permission screen…",
"使用手柄激光选择原生权限选项": "Use the controller laser to select a native permission option",
"开始录制": "Start recording",
"停止录制": "Stop recording",
"录制保存到 /home/steamos/Videos/framely": "Recordings are saved to /home/steamos/Videos/framely",
"免验证": "Password-free viewing",
"持有此链接的人无需密码即可观看。": "Anyone with this link can watch without a password.",
"观看链接已失效": "This viewing link has expired",
"正在读取串流信息…": "Reading stream information…",
"当前浏览器实际接收并解码的串流信息": "Actual stream received and decoded by this browser",
"L2 / R2 按键触发行程": "L2 / R2 button activation threshold",
"默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。": "Default: 80%. Buttons activate at this trigger travel. Analog axes retain their full range; saving applies immediately.",
"更新应用并保留现有存档和设置。": "Update the app while keeping your saves and settings.",
@@ -1299,6 +1331,8 @@
"转换完成,请确认安装。": "Conversion complete. Review the APK before installing.",
"正在检查转换工具": "Checking converter",
"工具包已缓存": "Converter cached",
"首次使用需下载 {0} 工具包": "First use downloads the {0} converter"
"首次使用需下载 {0} 工具包": "First use downloads the {0} converter",
"Quest 转换": "Quest conversion",
"要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "To use an updated converter, select the original Quest APK and install it again."
}
}
+35 -1
View File
@@ -3,6 +3,38 @@
"locale": "zh-CN",
"name": "简体中文",
"messages": {
"重置大小": "重置大小",
"录制眼已改变,请重新打开画幅调整": "录制眼已改变,请重新打开画幅调整",
"保留比例和分辨率,恢复居中有效画幅": "保留比例和分辨率,恢复居中有效画幅",
"菜单尺寸超过当前可录制范围": "菜单尺寸超过当前可录制范围",
"缺少有效的面板校准数据,无法显示准确录制区域": "缺少有效的面板校准数据,无法显示准确录制区域",
"取景范围超出头显可见区域,请缩小或调整位置": "取景范围超出头显可见区域,请缩小或调整位置",
"自定义": "自定义",
"画幅调整": "画幅调整",
"录制区域显示": "录制区域显示",
"框内即录制区域,拖动移动,拖动四角等比例缩放。": "框内即录制区域,拖动移动,拖动四角等比例缩放。",
"录制分辨率": "录制分辨率",
"录制区域显示仅支持屏幕采集的单眼画面": "录制区域显示仅支持屏幕采集的单眼画面",
"画幅调整仅支持屏幕采集的单眼画面": "画幅调整仅支持屏幕采集的单眼画面",
"请先关闭画幅调整窗口": "请先关闭画幅调整窗口",
"画幅调整窗口已关闭": "画幅调整窗口已关闭",
"无法读取实际录制区域,请更新采集程序": "无法读取实际录制区域,请更新采集程序",
"无法读取实际录制区域,请确认 SteamVR 已启动": "无法读取实际录制区域,请确认 SteamVR 已启动",
"读取录制区域超时": "读取录制区域超时",
"安卓权限请求": "安卓权限请求",
"正在读取权限请求": "正在读取权限请求",
"安卓权限选择": "安卓权限选择",
"安卓原生权限对话框": "安卓原生权限对话框",
"正在等待安卓权限画面…": "正在等待安卓权限画面…",
"使用手柄激光选择原生权限选项": "使用手柄激光选择原生权限选项",
"开始录制": "开始录制",
"停止录制": "停止录制",
"录制保存到 /home/steamos/Videos/framely": "录制保存到 /home/steamos/Videos/framely",
"免验证": "免验证",
"持有此链接的人无需密码即可观看。": "持有此链接的人无需密码即可观看。",
"观看链接已失效": "观看链接已失效",
"正在读取串流信息…": "正在读取串流信息…",
"当前浏览器实际接收并解码的串流信息": "当前浏览器实际接收并解码的串流信息",
"L2 / R2 按键触发行程": "L2 / R2 按键触发行程",
"默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。": "默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。",
"安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。": "安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。",
@@ -1299,6 +1331,8 @@
"转换完成,请确认安装。": "转换完成,请确认安装。",
"正在检查转换工具": "正在检查转换工具",
"工具包已缓存": "工具包已缓存",
"首次使用需下载 {0} 工具包": "首次使用需下载 {0} 工具包"
"首次使用需下载 {0} 工具包": "首次使用需下载 {0} 工具包",
"Quest 转换": "Quest 转换",
"要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "要使用新版转换运行包,请重新选择原始 Quest APK 安装。"
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import {t} from './i18n';
import {installKeyboard} from '../../sdk/src/keyboard';
import {installHoverFeedback} from '../../sdk/src/hover';
export async function api<T=any>(method:string,params:unknown={},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]==='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 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}));
+3 -3
View File
@@ -6,14 +6,14 @@ import {Switch} from './switch';
import {t} from './i18n';
type Metadata={name:string;package:string;version:string;versionCode:number;minSdk:number;icon?:string;activities:string[];abis:string[]};
type Props={metadata:Metadata;size:string;previous?:Metadata;updating:boolean;containerName:string;running:boolean;displayMode:string;busy:boolean;error:string;converted:boolean;q2f?:{cached:boolean;size:string};q2fError?:string;onDisplayMode:(value:string)=>void;onStop:()=>void;onCancel:()=>void;onConvert:()=>void;onRevert:()=>void;onConfirm:()=>void};
export function ApkInstallReview({metadata,size,previous,updating,containerName,running,displayMode,busy,error,converted,q2f,q2fError,onDisplayMode,onStop,onCancel,onConvert,onRevert,onConfirm}:Props){
type Props={metadata:Metadata;size:string;previous?:Metadata;updating:boolean;containerName:string;running:boolean;displayMode:string;busy:boolean;error:string;converted:boolean;questEnabled:boolean;q2f?:{cached:boolean;size:string};q2fError?:string;onDisplayMode:(value:string)=>void;onStop:()=>void;onCancel:()=>void;onQuestChange:(checked:boolean)=>void;onConfirm:()=>void};
export function ApkInstallReview({metadata,size,previous,updating,containerName,running,displayMode,busy,error,converted,questEnabled,q2f,q2fError,onDisplayMode,onStop,onCancel,onQuestChange,onConfirm}:Props){
const mismatch=!!previous&&previous.package!==metadata.package;
return <div className="apk-install-review">
<h2>{t(updating?'确认更新 APK':'确认安装 APK')}</h2>
<p className="apk-install-intro">{t(updating?'更新应用并保留现有存档和设置。':'核对应用信息,选择合适的显示方式。')}</p>
<header className="apk-install-app"><PluginImage kind="lepton" name={metadata.name} src={metadata.icon} size={60}/><div><h3>{metadata.name}</h3><code>{metadata.package}</code><p>{t('版本')} {previous&&`${previous.version} → `}{metadata.version||t('未声明')} <span>·</span> {size}</p></div></header>
<section className="apk-install-option apk-quest-option"><IconBrandMeta size={25} stroke={1.7} aria-hidden="true"/><div><b>{t('这是一个 Quest 游戏?')}</b><small>{t('打开此选项将尝试进行兼容转换,但不保证一定成功')}</small>{!converted&&<small>{q2fError?q2fError:!q2f?t('正在检查转换工具'):q2f.cached?t('工具包已缓存'):t('首次使用需下载 {0} 工具包',{0:q2f.size})}</small>}</div><Switch label={t('这是一个 Quest 游戏?')} checked={converted} disabled={busy||(!converted&&(!q2f||!!q2fError))} onChange={checked=>checked?onConvert():onRevert()}/></section>
<section className="apk-install-option apk-quest-option"><IconBrandMeta size={25} stroke={1.7} aria-hidden="true"/><div><b>{t('这是一个 Quest 游戏?')}</b><small>{t('打开此选项将尝试进行兼容转换,但不保证一定成功')}</small>{questEnabled&&!converted&&<small>{q2fError?q2fError:!q2f?t('正在检查转换工具'):q2f.cached?t('工具包已缓存'):t('首次使用需下载 {0} 工具包',{0:q2f.size})}</small>}</div><Switch label={t('这是一个 Quest 游戏?')} checked={questEnabled||converted} disabled={busy} onChange={onQuestChange}/></section>
<section className="apk-install-option apk-install-location" aria-label={t('目标容器')}><IconFolder size={23} aria-hidden="true"/><div><b>{t(updating?'保留原容器':'创建独立容器')}</b>{updating&&<code>{containerName}</code>}<small>{t('每个新应用使用独立容器,更新保留原容器。')}</small></div><Switch label={t(updating?'保留原容器':'创建独立容器')} checked disabled onChange={()=>{}}/></section>
<div className="apk-install-display"><div><b>{t('显示方式')}</b><small>{t(displayMode==='vr'?'隐藏二维窗口,应用本身需要支持 VR。':'通过二维窗口显示并操作应用。')}</small></div><Select label={t('显示方式')} value={displayMode} disabled={busy} onChange={onDisplayMode} options={[{value:'flat',label:t('平面窗口')},{value:'vr',label:t('VR 应用')}]}/></div>
<details className="apk-install-technical"><summary>{t('查看 APK 详情')}</summary><dl className="apk-meta"><dt>{t('版本代码')}</dt><dd>{metadata.versionCode}</dd><dt>{t('最低 Android SDK')}</dt><dd>{metadata.minSdk||t('未声明')}</dd><dt>{t('架构')}</dt><dd>{metadata.abis.join(', ')||t('未声明')}</dd><dt>{t('启动入口')}</dt><dd>{metadata.activities.length||t('未发现启动入口')}</dd></dl></details>
+2
View File
@@ -24,3 +24,5 @@
.apk-trigger-setting{display:flex;align-items:center;gap:24px;padding:18px 0}.apk-trigger-setting>div:first-child{flex:1;min-width:0}.apk-trigger-setting label{margin:0}.apk-trigger-setting b{font-size:19px}.apk-trigger-setting small{display:block;font-size:17px;color:var(--muted);line-height:1.55;margin-top:5px}.apk-trigger-controls{display:flex;align-items:center;gap:16px;flex:0 0 390px}.apk-trigger-controls>div{display:flex;align-items:center;gap:12px;flex:1;min-width:0}.apk-trigger-controls input[type=range]{width:100%;min-width:0;accent-color:var(--blue);padding:0;margin:0;min-height:44px;cursor:pointer}.apk-trigger-controls output{font-size:18px;min-width:48px;text-align:right;font-variant-numeric:tabular-nums}.apk-trigger-controls button{margin:0}@media(max-width:700px){.apk-trigger-setting{flex-wrap:wrap;gap:12px}.apk-trigger-setting>div:first-child{flex-basis:100%}.apk-trigger-controls{flex:1 1 100%}}
.apk-steam-names{width:100%;margin-bottom:24px}.apk-steam-names>p{color:var(--muted);line-height:1.55}.apk-steam-name{display:flex;align-items:center;gap:24px;padding:18px 0;border-bottom:1px solid var(--line);width:100%}.apk-steam-name .apk-copy code{display:block;margin-top:6px;color:var(--muted)}.apk-steam-name-controls{display:flex;align-items:center;gap:12px;flex:0 1 50%;min-width:0}.apk-steam-name-controls input{flex:1;min-width:0;margin:0}.apk-steam-name-controls button{flex:none;margin:0}@media(max-width:700px){.apk-steam-name{flex-wrap:wrap;gap:12px}.apk-steam-name-controls{flex-basis:100%}}
.apk-meta dd small{display:block;margin-top:6px;color:var(--muted);line-height:1.5}
+10 -10
View File
@@ -13,7 +13,7 @@ import {ApkUninstall} from './apk-uninstall';
import {ApkInstallReview} from './apk-install-review';
import {GamepadTriggerSetting} from './gamepad-trigger-setting';
type Metadata={package:string;name:string;version:string;versionCode:number;minSdk:number;activities:string[];declaredActivities:string[];icon?:string;abis:string[];vr:boolean};
type App={steamName?:string|null;steamLaunch?:boolean;steamRegistered?:boolean;steamRegistrationError?:string;stateKnown?:boolean;id:string;context:string;metadata:Metadata;installed:boolean;running:boolean;managed:boolean;steam:boolean;size:number;pending?:string;activity?:string;showWindow?:boolean|null;orientation?:string|null;framebufferCompatibility?:boolean};
type App={steamName?:string|null;steamLaunch?:boolean;steamRegistered?:boolean;steamRegistrationError?:string;stateKnown?:boolean;id:string;context:string;metadata:Metadata;installed:boolean;running:boolean;managed:boolean;steam:boolean;size:number;pending?:string;activity?:string;showWindow?:boolean|null;orientation?:string|null;framebufferCompatibility?:boolean;q2fVersion?:string|null};
type Container={acceptsAdditionalApps?:boolean;id:string;name:string;baked:string;running:boolean;managed:boolean;steam:boolean};
type Data={apps:App[];containers:Container[];warnings:string[];roots:string[];available:boolean;retainSteamEntries?:boolean;gamepadEnabled?:boolean;gamepadSource?:string;gamepadRumble?:boolean;gamepadTriggerThreshold?:number;autoStopContainer?:boolean;stopContainerOnClose?:boolean};
type Cleanup={id:string;kind:string;name:string;bytes:number;sensitive:boolean;app?:string;context?:string;restorable?:boolean;unknown?:boolean};
@@ -32,25 +32,25 @@ function SteamNameSetting({app,busy,onSave}:{app:App;busy:boolean;onSave:(name:s
</form>;
}
export function ApkManager({view:externalView,onViewChange}:{view?:string;onViewChange?:(view:string)=>void}={}){
const[data,setData]=useState<Data>({apps:[],containers:[],warnings:[],roots:[],available:true}),[loading,setLoading]=useState(true),[localView,setLocalView]=useState('apps'),[filter,setFilter]=useState('installed'),[query,setQuery]=useState(''),[error,setError]=useState(''),[review,setReview]=useState<Review|null>(null),[originalReview,setOriginalReview]=useState<Review|null>(null),[q2f,setQ2f]=useState<Q2fStatus|null>(null),[q2fError,setQ2fError]=useState(''),[target,setTarget]=useState(''),[displayMode,setDisplayMode]=useState('flat'),[updating,setUpdating]=useState<App|null>(null),[details,setDetails]=useState<App|null>(null),[confirm,setConfirm]=useState<Confirm|null>(null),[job,setJob]=useState<(Job&{cancellable?:boolean;operation?:string})|null>(null),[busy,setBusy]=useState(false),[cleanup,setCleanup]=useState<Cleanup[]>([]),[selected,setSelected]=useState<string[]>([]),[log,setLog]=useState<{name:string;text:string}|null>(null),[path,setPath]=useState(''),[contextName,setContextName]=useState(''),[notice,setNotice]=useState('');
const[data,setData]=useState<Data>({apps:[],containers:[],warnings:[],roots:[],available:true}),[loading,setLoading]=useState(true),[localView,setLocalView]=useState('apps'),[filter,setFilter]=useState('installed'),[query,setQuery]=useState(''),[error,setError]=useState(''),[review,setReview]=useState<Review|null>(null),[originalReview,setOriginalReview]=useState<Review|null>(null),[questEnabled,setQuestEnabled]=useState(false),[q2f,setQ2f]=useState<Q2fStatus|null>(null),[q2fError,setQ2fError]=useState(''),[target,setTarget]=useState(''),[displayMode,setDisplayMode]=useState('flat'),[updating,setUpdating]=useState<App|null>(null),[details,setDetails]=useState<App|null>(null),[confirm,setConfirm]=useState<Confirm|null>(null),[job,setJob]=useState<(Job&{cancellable?:boolean;operation?:string})|null>(null),[busy,setBusy]=useState(false),[cleanup,setCleanup]=useState<Cleanup[]>([]),[selected,setSelected]=useState<string[]>([]),[log,setLog]=useState<{name:string;text:string}|null>(null),[path,setPath]=useState(''),[contextName,setContextName]=useState(''),[notice,setNotice]=useState('');
const view=externalView??localView;const setView=(value:string)=>{setLocalView(value);onViewChange?.(value);};
useEffect(()=>{if(view==='cleanup')void loadCleanup();},[view]);
useEffect(()=>{if(!review||review.q2fVersion){setQ2f(null);setQ2fError('');return;}let active=true;setQ2f(null);setQ2fError('');void api<Q2fStatus>('apk.q2f.status').then(value=>{if(active)setQ2f(value);}).catch(e=>{if(active)setQ2fError(String(e));});return()=>{active=false;};},[review?.ticket]);
const input=useRef<HTMLInputElement>(null),controller=useRef<AbortController|null>(null),selection=useRef<{app:string;uninstall:boolean}|null>(null),alive=useRef(true),busyRef=useRef(false);
const updateJob=(v:Job)=>{if(alive.current)setJob(v);};
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');let saved:{job:string;app:App|null;context:string;review?:Review}|null=null;try{saved=JSON.parse(sessionStorage.getItem('framely.apk.q2f.job')||'null');}catch{}const id=active.job||saved?.job;if(!id||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean;operation?:string}>('job.status',{job:id});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(saved?.job===id)sessionStorage.removeItem('framely.apk.q2f.job');if(value.phase==='failed')setError(t(value.error??'操作失败'));else if(value.result?.q2fVersion){setReview(value.result);setOriginalReview(saved?.review??null);setDisplayMode('vr');setUpdating(saved?.app??null);setTarget(saved?.context??'');setNotice(t('转换完成,请确认安装。'));}else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(String(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}}
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');let saved:{job:string;app:App|null;context:string;review?:Review;displayMode?:string}|null=null;try{saved=JSON.parse(sessionStorage.getItem('framely.apk.q2f.job')||'null');}catch{}const id=active.job||saved?.job;if(!id||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean;operation?:string}>('job.status',{job:id});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(saved?.job===id)sessionStorage.removeItem('framely.apk.q2f.job');if(value.phase==='failed'||value.phase==='cancelled'){if(saved?.review){setReview(saved.review);setQuestEnabled(true);setDisplayMode(saved.displayMode??'vr');setUpdating(saved.app);setTarget(saved.context);}setError(t(value.phase==='cancelled'?'操作已取消':value.error??'操作失败'));}else if(value.result?.q2fVersion){setReview(value.result);setOriginalReview(saved?.review??null);setQuestEnabled(true);setDisplayMode(saved?.displayMode??'vr');setUpdating(saved?.app??null);setTarget(saved?.context??'');if(saved?.review)await perform('install',{ticket:value.result.ticket,context:saved.context,app:saved.app?.id,showWindow:saved.displayMode==='flat',approve:true},saved.review.ticket);else setNotice(t('转换完成,请确认安装。'));}else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(String(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}}
useEffect(()=>{if(data.retainSteamEntries===false||!data.apps.some(a=>a.installed&&!a.steam&&a.stateKnown!==false&&!a.pending&&(a.metadata.activities.length||a.activity)&&(a.steamLaunch??true)&&!a.steamRegistered))return;const timer=setInterval(()=>{if(!busyRef.current)void refresh();},20000);return()=>clearInterval(timer);},[data.apps,data.retainSteamEntries]);
async function refresh(){setLoading(true);try{const r=await runJob('apk.list.start');if(alive.current){setData(r.result);setDetails(old=>old?r.result.apps.find((a:App)=>a.id===old.id)??null:null);}}catch(e){if(alive.current)setError(String(e));}finally{if(alive.current)setLoading(false);}}
useEffect(()=>{alive.current=true;void refresh();void resumeOperation();const select=(e:Event)=>{const v=(e as CustomEvent).detail;if(v?.kind==='apk.selection'){selection.current={app:v.app,uninstall:!!v.uninstall};void refresh();}};window.addEventListener('framely.event',select);return()=>{alive.current=false;window.removeEventListener('framely.event',select);controller.current?.abort();};},[]);
useEffect(()=>{if(selection.current){const a=data.apps.find(a=>a.id===selection.current!.app);if(a){if(a.steam){setError(t('请通过 Steam 管理此应用。'));selection.current=null;return;}if(selection.current.uninstall)askUninstall(a);else setDetails(a);selection.current=null;}}},[data]);
useEffect(()=>{const close=(e:Event)=>{if(e instanceof KeyboardEvent&&e.key!=='Escape')return;if(busyRef.current)return;if(confirm)setConfirm(null);else if(log)setLog(null);else if(review)void discard();else setDetails(null);};window.addEventListener('keydown',close);window.addEventListener('framely.back',close);return()=>{window.removeEventListener('keydown',close);window.removeEventListener('framely.back',close);};},[confirm,log,review]);
useEffect(()=>{const dialog=document.querySelector<HTMLElement>('.apk-dialog');if(!dialog)return;const prev=document.activeElement as HTMLElement;const controls=()=>Array.from(dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input,select,a[href]')).filter(e=>e.getClientRects().length);controls()[0]?.focus();const key=(e:KeyboardEvent)=>{if(e.key!=='Tab')return;const a=controls();if(!a.length)return;if(e.shiftKey&&(document.activeElement===a[0]||!dialog.contains(document.activeElement))){e.preventDefault();a.at(-1)?.focus();}else if(!e.shiftKey&&(document.activeElement===a.at(-1)||!dialog.contains(document.activeElement))){e.preventDefault();a[0].focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);prev?.focus();};},[review,details,confirm,log,job?.id]);
async function discard(){if(review)await api('apk.review.drop',{ticket:review.ticket}).catch(()=>{});if(originalReview)await api('apk.review.drop',{ticket:originalReview.ticket}).catch(()=>{});setReview(null);setOriginalReview(null);setUpdating(null);}
async function convert(){if(!review||review.q2fVersion)return;setBusy(true);busyRef.current=true;setError('');try{const started=await api<{job:string}>('apk.q2f.start',{ticket:review.ticket});sessionStorage.setItem('framely.apk.q2f.job',JSON.stringify({job:started.job,app:updating,context:target,review}));const result=await waitJob(started.job,updateJob);sessionStorage.removeItem('framely.apk.q2f.job');setOriginalReview(review);setReview(result.result);setDisplayMode('vr');}catch(e){sessionStorage.removeItem('framely.apk.q2f.job');setError(String(e));}finally{setJob(null);setBusy(false);busyRef.current=false;}}
async function revertConversion(){if(!review?.q2fVersion||!originalReview)return;setBusy(true);try{await api('apk.review.drop',{ticket:review.ticket});setReview(originalReview);setOriginalReview(null);setDisplayMode((updating?.showWindow??!originalReview.metadata.vr)?'flat':'vr');setError('');}catch(e){setError(String(e));}finally{setBusy(false);}}
async function inspect(file:File|string,selectedApp:App|null=updating){if(!(typeof file==='string'?file:file.name).toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob(typeof file==='string'?{id:'local',kind:'apk',phase:'inspecting',received:0,cancellable:false}:{id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{let result;if(typeof file==='string'){const response=await fetch('/host/files/apk-inspect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[file]}),signal:c.signal});if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));const data=await response.json();if(data.error)throw new Error(t(data.error));result=await waitJob(data.result.job,updateJob);}else{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});result=await runJob('apk.inspect.start',{upload},updateJob);}if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setOriginalReview(null);setReview(result.result);setDisplayMode((selectedApp?.showWindow??!result.result.metadata.vr)?'flat':'vr');setTarget(selectedApp?.context??'');setJob(null);}catch(e){setJob(null);setError(String(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}}
async function discard(){if(review)await api('apk.review.drop',{ticket:review.ticket}).catch(()=>{});if(originalReview)await api('apk.review.drop',{ticket:originalReview.ticket}).catch(()=>{});setReview(null);setOriginalReview(null);setQuestEnabled(false);setUpdating(null);}
async function installReviewed(){if(!review)return;const original=review;if(!questEnabled||review.q2fVersion){await perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true});return;}setBusy(true);busyRef.current=true;setError('');setNotice('');try{const started=await api<{job:string}>('apk.q2f.start',{ticket:original.ticket});sessionStorage.setItem('framely.apk.q2f.job',JSON.stringify({job:started.job,app:updating,context:target,review:original,displayMode}));const result=await waitJob(started.job,updateJob);sessionStorage.removeItem('framely.apk.q2f.job');const converted=result.result as Review;setOriginalReview(original);setReview(converted);await perform('install',{ticket:converted.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true},original.ticket);}catch(e){sessionStorage.removeItem('framely.apk.q2f.job');setError(String(e));}finally{setJob(null);setBusy(false);busyRef.current=false;}}
async function revertConversion(){if(!review?.q2fVersion||!originalReview)return;setBusy(true);try{await api('apk.review.drop',{ticket:review.ticket});setReview(originalReview);setOriginalReview(null);setQuestEnabled(false);setDisplayMode((updating?.showWindow??!originalReview.metadata.vr)?'flat':'vr');setError('');}catch(e){setError(String(e));}finally{setBusy(false);}}
async function inspect(file:File|string,selectedApp:App|null=updating){if(!(typeof file==='string'?file:file.name).toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob(typeof file==='string'?{id:'local',kind:'apk',phase:'inspecting',received:0,cancellable:false}:{id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{let result;if(typeof file==='string'){const response=await fetch('/host/files/apk-inspect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[file]}),signal:c.signal});if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));const data=await response.json();if(data.error)throw new Error(t(data.error));result=await waitJob(data.result.job,updateJob);}else{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});result=await runJob('apk.inspect.start',{upload},updateJob);}if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setOriginalReview(null);setQuestEnabled(false);setReview(result.result);setDisplayMode((selectedApp?.showWindow??!result.result.metadata.vr)?'flat':'vr');setTarget(selectedApp?.context??'');setJob(null);}catch(e){setJob(null);setError(String(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}}
function choose(a?:App){setUpdating(a??null);if(input.current)openLocalFileInput(input.current,path=>{if(alive.current)void inspect(path,a??null);});}
async function perform(operation:string,params:Record<string,unknown>={}){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){if(originalReview)await api('apk.review.drop',{ticket:originalReview.ticket}).catch(()=>{});setOriginalReview(null);setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(String(e));await refresh();}finally{setBusy(false);busyRef.current=false;}}
async function perform(operation:string,params:Record<string,unknown>={},originalTicket=originalReview?.ticket){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){if(originalTicket)await api('apk.review.drop',{ticket:originalTicket}).catch(()=>{});setOriginalReview(null);setQuestEnabled(false);setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(String(e));await refresh();}finally{setBusy(false);busyRef.current=false;}}
function canDeleteContainer(a:App){const members=data.apps.filter(other=>other.context===a.context);const container=data.containers.find(c=>c.id===a.context);return !!container&&!container.steam&&members.length===1&&members[0].id===a.id&&a.stateKnown!==false&&!a.pending;}
function askUninstall(a:App){setError('');setConfirm({operation:a.installed?'uninstall':'purge',app:a,title:a.installed?t('卸载应用'):t('清除保留数据'),description:'',purge:!a.installed,deleteContainer:!a.installed&&canDeleteContainer(a)});}
async function loadCleanup(){try{const items=await api<Cleanup[]>('apk.cleanup.list');setCleanup(items);setSelected([]);}catch(e){setError(String(e));}}
@@ -74,8 +74,8 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
{view==='containers'&&<><div className="apk-list">{data.containers.map(c=><article className="apk-container" key={c.id}><div className="apk-copy"><h2><IconFolder size={24}/>{containerTitle(c)}</h2><code>{c.baked}</code><p>{t(c.steam?'Steam 管理':c.managed?'Framely 管理':'已有侧载容器')} · {t(c.running?'运行中':'已停止')}</p>{!data.apps.some(a=>a.context===c.id&&a.installed)&&<p>{t('未发现已安装的应用,启动容器不会安装 APK。')}</p>}</div><div className="apk-actions"><button disabled={busy} onClick={()=>void showLog(c.id)}><IconFileText size={20}/>{t('日志')}</button>{!c.steam&&<><button disabled={busy} onClick={()=>containerAsk(c,c.running?'container.stop':'container.start')}>{t(c.running?'停止容器':'启动容器')}</button><button className="danger" disabled={busy} onClick={()=>containerAsk(c,'container.delete')}>{t('删除容器')}</button></>}</div></article>)}</div><section className="apk-locations"><h2>{t('其他数据位置')}</h2><p>{t('添加其他工具使用的 baked 数据目录,不移动原有应用。')}</p><div className="apk-filters"><input value={path} placeholder="/path/to/compatdata/baked" aria-label={t('Lepton 数据目录')} onChange={e=>setPath(e.target.value)}/><input value={contextName} placeholder={t('原容器名称(可选)')} aria-label={t('原容器名称(可选)')} onChange={e=>setContextName(e.target.value)}/><button disabled={busy||!path.trim()} onClick={()=>void perform('root.add',{path,context:contextName}).then(()=>setPath(''))}>{t('添加')}</button></div>{data.roots.map(p=><div className="apk-location" key={p}><code>{p}</code><button disabled={busy} onClick={()=>void perform('root.remove',{path:p})}>{t('移除扫描位置')}</button></div>)}</section></>}
{view==='cleanup'&&<><div className="apk-cleanup-heading"><div><h2>{t('数据清理')}</h2><p>{t('存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。')}</p></div><button className="danger" disabled={busy||!selected.length} onClick={()=>setConfirm({operation:'cleanup',items:selected,title:t('确认数据清理'),description:cleanup.filter(i=>selected.includes(i.id)).map(i=>`${i.name} · ${bytes(i.bytes)}`).join('\n')+`\n${t('选中的文件将永久删除,无法撤销。')}`})}><IconTrash size={22}/>{t('清理所选')}</button></div>{cleanup.map(i=><article className="apk-cleanup-row" key={i.id}><Switch checked={selected.includes(i.id)} label={`${t('选择')} ${i.name}`} disabled={busy||i.unknown} onChange={checked=>setSelected(s=>checked?[...s,i.id]:s.filter(v=>v!==i.id))}/><div className="apk-copy"><b>{i.name}</b><p>{t(i.kind==='unknown'?'未知归属的数据':i.kind==='data'?'保留的应用数据':i.kind==='backups'?'恢复备份':(i.kind==='reviews'||i.kind==='uploads')?'过期上传文件':'旧 APK')} · {bytes(i.bytes)}</p>{i.unknown?<small>{t('无法确认归属,不提供自动删除。')}</small>:i.sensitive&&<small>{t('删除后不可恢复')}</small>}</div>{i.restorable&&<button disabled={busy} onClick={()=>setConfirm({operation:'restore',container:contextFor(i.context!),backup:i.name,title:t('恢复容器备份'),description:t('恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。')})}>{t('恢复')}</button>}</article>)}{!cleanup.length&&<p className="empty">{t('没有可清理的数据。')}</p>}</>}
{!!data.warnings.length&&<details className="apk-warnings"><summary>{t('部分容器无法读取')}</summary>{data.warnings.map((w,i)=><p key={i}>{w}</p>)}</details>}
{details&&!confirm&&!review&&!log&&!job&&dialog(<><header className="apk-dialog-heading"><PluginImage kind="lepton" name={details.metadata.name} src={details.metadata.icon} size={64}/><div><h2>{details.metadata.name}</h2><code>{details.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{details.metadata.version} ({details.metadata.versionCode})</dd><dt>{t('容器')}</dt><dd>{contextFor(details.context)?.name??details.context}</dd><dt>{t('空间占用')}</dt><dd>{bytes(details.size)}</dd></dl>{details.installed&&details.stateKnown!==false&&!details.steam&&<section className="apk-steam-names"><h3>{t('自定义 Steam 名称')}</h3><p>{t('分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。')}</p><SteamNameSetting app={details} busy={busy} onSave={steamName=>void perform('settings',{app:details.id,steamName})}/></section>}{details.pending&&<><p className="error">{t(details.pending)}</p><button disabled={busy} onClick={()=>void perform('reconcile',{app:details.id})}>{t('核对实际安装状态')}</button></>}<div className="apk-actions">{details.installed&&details.stateKnown!==false&&!details.steam&&<><button disabled={busy||!details.metadata.activities.length&&!details.activity} onClick={()=>void perform('launch',{app:details.id})}>{t('打开')}</button><button disabled={busy} onClick={()=>void perform('close',{app:details.id})}><IconPlayerStop size={20}/>{t('关闭应用')}</button><button disabled={busy} onClick={()=>choose(details)}>{t('安装新版 APK')}</button><button className="danger" disabled={busy} onClick={()=>setConfirm({operation:'clear',app:details,title:t('清除应用数据'),description:t('将永久删除该应用的存档和设置,无法撤销。')})}>{t('清除应用数据')}</button></>}{!details.installed&&details.stateKnown!==false&&!details.steam&&<button disabled={busy} onClick={()=>choose(details)}>{t('重新安装 APK')}</button>}<button disabled={busy} onClick={()=>void showLog(details)}><IconFileText size={20}/>{t('日志')}</button><button className="danger" disabled={busy||!!details.pending||details.stateKnown===false||details.steam} onClick={()=>askUninstall(details)}>{t(details.installed?'卸载应用':'清除保留数据')}</button></div>{details.installed&&details.stateKnown!==false&&!details.steam&&<details className="apk-advanced"><summary>{t('高级设置')}</summary><SwitchRow label={t('通过 Steam 启动')} description={t('通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。')} checked={details.steamLaunch??true} disabled={busy} onChange={enabled=>void perform('steam.settings',{app:details.id,enabled})}/>{data.retainSteamEntries!==false&&(details.steamLaunch??true)&&!details.steamRegistered&&<p role="status">{t('等待 Steam 注册,Steam 就绪后会自动重试。')}</p>}{details.steamRegistrationError&&<p className="error">{details.steamRegistrationError}</p>}<p>{t('默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。')}</p><SwitchRow label={t('自动处理窗口显示')} checked={details.showWindow==null} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked?null:true})}/>{details.showWindow!=null&&<SwitchRow label={t('显示安卓窗口')} checked={details.showWindow} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked})}/>}<p>{t('窗口设置将在下次打开应用时应用;必要时自动重启容器。')}</p><SwitchRow label={t('帧缓冲兼容修复')} description={t('仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。')} checked={details.framebufferCompatibility??false} disabled={busy} onChange={enabled=>void perform('settings',{app:details.id,framebufferCompatibility:enabled})}/><label>{t('窗口方向')}<Select label={t('窗口方向')} value={details.orientation??'auto'} disabled={busy} onChange={orientation=>void perform('settings',{app:details.id,orientation})} options={[{value:'auto',label:t('自动选择')},{value:'landscape',label:t('横屏')},{value:'portrait',label:t('竖屏')}]}/></label><label>{t('启动入口')}<Select label={t('启动入口')} value={details.activity??''} disabled={busy} onChange={activity=>void perform('settings',{app:details.id,activity})} options={[{value:'',label:t('自动选择')},...details.metadata.declaredActivities.map(a=>({value:a,label:a}))]}/></label></details>}<footer><button onClick={()=>setDetails(null)}>{t('完成')}</button></footer></>,t('管理应用'))}
{review&&!confirm&&!job&&dialog(<ApkInstallReview metadata={review.metadata} size={bytes(review.bytes)} previous={updating?.metadata} displayMode={displayMode} onDisplayMode={setDisplayMode} containerName={installContainer?containerTitle(installContainer):target} updating={!!updating} running={!!installContainer?.running&&!installContainer.steam} busy={busy} error={error} converted={!!review.q2fVersion} q2f={q2f?{cached:q2f.cached,size:bytes(q2f.bytes)}:undefined} q2fError={q2fError} onStop={()=>installContainer&&containerAsk(installContainer,'container.stop')} onCancel={()=>void discard()} onConvert={()=>void convert()} onRevert={()=>void revertConversion()} onConfirm={()=>void perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true})}/>,t('安装 APK'))}
{details&&!confirm&&!review&&!log&&!job&&dialog(<><header className="apk-dialog-heading"><PluginImage kind="lepton" name={details.metadata.name} src={details.metadata.icon} size={64}/><div><h2>{details.metadata.name}</h2><code>{details.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{details.metadata.version} ({details.metadata.versionCode})</dd><dt>{t('容器')}</dt><dd>{contextFor(details.context)?.name??details.context}</dd><dt>{t('空间占用')}</dt><dd>{bytes(details.size)}</dd>{details.installed&&details.q2fVersion&&<><dt>{t('Quest 转换')}</dt><dd><code>{details.q2fVersion}</code><small>{t('要使用新版转换运行包,请重新选择原始 Quest APK 安装。')}</small></dd></>}</dl>{details.installed&&details.stateKnown!==false&&!details.steam&&<section className="apk-steam-names"><h3>{t('自定义 Steam 名称')}</h3><p>{t('分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。')}</p><SteamNameSetting app={details} busy={busy} onSave={steamName=>void perform('settings',{app:details.id,steamName})}/></section>}{details.pending&&<><p className="error">{t(details.pending)}</p><button disabled={busy} onClick={()=>void perform('reconcile',{app:details.id})}>{t('核对实际安装状态')}</button></>}<div className="apk-actions">{details.installed&&details.stateKnown!==false&&!details.steam&&<><button disabled={busy||!details.metadata.activities.length&&!details.activity} onClick={()=>void perform('launch',{app:details.id})}>{t('打开')}</button><button disabled={busy} onClick={()=>void perform('close',{app:details.id})}><IconPlayerStop size={20}/>{t('关闭应用')}</button><button disabled={busy} onClick={()=>choose(details)}>{t('安装新版 APK')}</button><button className="danger" disabled={busy} onClick={()=>setConfirm({operation:'clear',app:details,title:t('清除应用数据'),description:t('将永久删除该应用的存档和设置,无法撤销。')})}>{t('清除应用数据')}</button></>}{!details.installed&&details.stateKnown!==false&&!details.steam&&<button disabled={busy} onClick={()=>choose(details)}>{t('重新安装 APK')}</button>}<button disabled={busy} onClick={()=>void showLog(details)}><IconFileText size={20}/>{t('日志')}</button><button className="danger" disabled={busy||!!details.pending||details.stateKnown===false||details.steam} onClick={()=>askUninstall(details)}>{t(details.installed?'卸载应用':'清除保留数据')}</button></div>{details.installed&&details.stateKnown!==false&&!details.steam&&<details className="apk-advanced"><summary>{t('高级设置')}</summary><SwitchRow label={t('通过 Steam 启动')} description={t('通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。')} checked={details.steamLaunch??true} disabled={busy} onChange={enabled=>void perform('steam.settings',{app:details.id,enabled})}/>{data.retainSteamEntries!==false&&(details.steamLaunch??true)&&!details.steamRegistered&&<p role="status">{t('等待 Steam 注册,Steam 就绪后会自动重试。')}</p>}{details.steamRegistrationError&&<p className="error">{details.steamRegistrationError}</p>}<p>{t('默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。')}</p><SwitchRow label={t('自动处理窗口显示')} checked={details.showWindow==null} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked?null:true})}/>{details.showWindow!=null&&<SwitchRow label={t('显示安卓窗口')} checked={details.showWindow} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked})}/>}<p>{t('窗口设置将在下次打开应用时应用;必要时自动重启容器。')}</p><SwitchRow label={t('帧缓冲兼容修复')} description={t('仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。')} checked={details.framebufferCompatibility??false} disabled={busy} onChange={enabled=>void perform('settings',{app:details.id,framebufferCompatibility:enabled})}/><label>{t('窗口方向')}<Select label={t('窗口方向')} value={details.orientation??'auto'} disabled={busy} onChange={orientation=>void perform('settings',{app:details.id,orientation})} options={[{value:'auto',label:t('自动选择')},{value:'landscape',label:t('横屏')},{value:'portrait',label:t('竖屏')}]}/></label><label>{t('启动入口')}<Select label={t('启动入口')} value={details.activity??''} disabled={busy} onChange={activity=>void perform('settings',{app:details.id,activity})} options={[{value:'',label:t('自动选择')},...details.metadata.declaredActivities.map(a=>({value:a,label:a}))]}/></label></details>}<footer><button onClick={()=>setDetails(null)}>{t('完成')}</button></footer></>,t('管理应用'))}
{review&&!confirm&&!job&&dialog(<ApkInstallReview metadata={review.metadata} size={bytes(review.bytes)} previous={updating?.metadata} displayMode={displayMode} onDisplayMode={setDisplayMode} containerName={installContainer?containerTitle(installContainer):target} updating={!!updating} running={!!installContainer?.running&&!installContainer.steam} busy={busy} error={error} converted={!!review.q2fVersion} questEnabled={questEnabled} q2f={q2f?{cached:q2f.cached,size:bytes(q2f.bytes)}:undefined} q2fError={q2fError} onStop={()=>installContainer&&containerAsk(installContainer,'container.stop')} onCancel={()=>void discard()} onQuestChange={checked=>{if(!checked&&review.q2fVersion)void revertConversion();else setQuestEnabled(checked);}} onConfirm={()=>void installReviewed()}/>,t('安装 APK'))}
{confirm?.app&&!job&&['uninstall','purge'].includes(confirm.operation)&&dialog(<ApkUninstall app={confirm.app} size={bytes(confirm.app.size)} purge={!!confirm.purge} deleteContainer={!!confirm.deleteContainer&&!!confirm.purge&&canDeleteContainer(confirm.app)} exclusive={canDeleteContainer(confirm.app)} busy={busy} error={error} onPurge={purge=>setConfirm(c=>c?.app?{...c,purge,deleteContainer:purge&&canDeleteContainer(c.app)}:c)} onDeleteContainer={deleteContainer=>setConfirm(c=>c?{...c,deleteContainer}:c)} onCancel={()=>setConfirm(null)} onConfirm={()=>void perform(confirm.operation,{app:confirm.app!.id,purge:confirm.purge,deleteContainer:!!confirm.deleteContainer&&!!confirm.purge&&canDeleteContainer(confirm.app!),approve:true})}/>,confirm.title)}
{confirm&&!job&&!(confirm.app&&['uninstall','purge'].includes(confirm.operation))&&dialog(<><h2>{confirm.title}</h2><p className="apk-confirm-copy">{confirm.description}</p>{confirm.app&&<p><b>{confirm.app.metadata.name}</b><br/><code>{confirm.app.metadata.package}</code></p>}{deletesData&&<p>{t('永久删除的数据无法撤销。')}</p>}{error&&<p className="error">{error}</p>}<footer><button disabled={busy} onClick={()=>setConfirm(null)}>{t('取消')}</button><button className={deletesData||confirm.operation==='uninstall'?'danger':'primary'} disabled={busy} onClick={()=>void perform(confirm.operation,{app:confirm.app?.id,context:confirm.container?.id,backup:confirm.backup,items:confirm.items,purge:confirm.purge,approve:true})}>{t('确认')}</button></footer></>,confirm.title)}
{job&&dialog(<><h2>{t(job.phase==='preparing'&&job.operation?operationLabels[job.operation]??phases[job.phase]:phases[job.phase]??job.phase)}</h2><p>{t('操作进行中,请等待实际结果。')}</p><progress value={job.phase==='uploading'?job.received:undefined} max={job.phase==='uploading'?job.total:undefined}/>{job.phase==='uploading'&&<p>{bytes(job.received)} / {bytes(job.total??0)}</p>}{job.cancellable===false?<p>{t('操作已开始,请等待完成;此阶段无法取消。')}</p>:<button onClick={()=>{if(job.id==='upload')controller.current?.abort();else void api('job.cancel',{job:job.id}).catch(e=>setError(String(e)));}}>{t('取消')}</button>}</>,t('APK 操作进度'))}
+21 -21
View File
@@ -1,42 +1,42 @@
import React,{useEffect,useRef} from 'react';
import {IconMinus,IconPlus,IconRestore} from '@tabler/icons-react';
import {CastVideo} from './cast-viewer';
import {clamp,fitFrame,frameFromSettings,settingsFromFrame,type FrameRect,type ReferenceView} from './cast-framing';
import {fitFrame,frameFromSettings,settingsFromFrame,type FrameRect,type ReferenceView} from './cast-framing';
import type {CastSettings} from './casting';
import {CastRange,updateRangeFill} from './cast-range';
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}:{settings:CastSettings;view:ReferenceView|null;onChange:(patch:Partial<CastSettings>)=>void;onError:(error:string)=>void}){
export function FramingEditor({settings,view,onChange,onError,spatial=false,minimumSize={width:0,height:0},limit}:{settings:CastSettings;view:ReferenceView|null;onChange:(patch:Partial<CastSettings>)=>void;onError:(error:string)=>void;spatial?:boolean;minimumSize?:MinimumFrame;limit?:FrameLimiter}){
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<{x:number;y:number;rect:FrameRect;corner:string|null}|null>(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 aspect=settings.width/settings.height;
const maxWidth=view?Math.min(1,aspect/view.aspect):1,minWidth=view?Math.min(maxWidth,Math.tan(5*Math.PI/180)/view.tanHalfHorizontal):.1;
const ratio=aspect/(view?.aspect??1),maxWidth=Math.min(1,ratio),minWidth=minimumFrameWidth(ratio,minimumSize,view?Math.tan(5*Math.PI/180)/view.tanHalfHorizontal:.1);
function draw(rect:FrameRect){
try{if(limit)rect=limit(rect,ratio,minWidth,current.current);}catch(e){if(box.current)box.current.style.visibility='hidden';onError((e as Error).message);return null;}
current.current=rect;
if(box.current){box.current.style.left=`${rect.x*100}%`;box.current.style.top=`${rect.y*100}%`;box.current.style.width=`${rect.width*100}%`;box.current.style.height=`${rect.height*100}%`;}
if(box.current){box.current.style.visibility='visible';box.current.style.left=`${rect.x*100}%`;box.current.style.top=`${rect.y*100}%`;box.current.style.width=`${rect.width*100}%`;box.current.style.height=`${rect.height*100}%`;}
if(view){const fov=settingsFromFrame(rect,view).horizontalFov;if(zoom.current){zoom.current.value=String(maxWidth>minWidth?(rect.width-minWidth)/(maxWidth-minWidth)*100:100);updateRangeFill(zoom.current);}if(readout.current)readout.current.textContent=`${Math.round(fov)}°`;}
return rect;
}
function commit(){if(view)onChange(settingsFromFrame(current.current,view));}
useEffect(()=>{if(view)draw(frameFromSettings(settings,aspect,view));},[view?.tanHalfHorizontal,view?.centerTanX,view?.centerTanY,aspect,settings.horizontalFov,settings.centerX,settings.centerY]);
function scale(width:number){if(!view)return;const r=current.current;const next=fitFrame({...r,width,x:r.x+(r.width-width)/2,y:r.y+(r.height-width*view.aspect/aspect)/2},aspect,view.aspect,Math.tan(5*Math.PI/180)/view.tanHalfHorizontal);draw(next);}
function down(e:React.PointerEvent<HTMLDivElement>){if(!view||e.button!==0)return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);gesture.current={x:e.clientX,y:e.clientY,rect:{...current.current},corner:(e.target as HTMLElement).closest<HTMLElement>('[data-corner]')?.dataset.corner??null};}
function move(e:React.PointerEvent<HTMLDivElement>){const g=gesture.current,bounds=stage.current?.getBoundingClientRect();if(!g||!bounds||!view)return;const dx=(e.clientX-g.x)/bounds.width,dy=(e.clientY-g.y)/bounds.height;
if(!g.corner){draw(fitFrame({...g.rect,x:g.rect.x+dx,y:g.rect.y+dy},aspect,view.aspect));return;}
const left=g.corner.includes('w'),top=g.corner.includes('n'),ratio=aspect/view.aspect;
const delta=Math.abs(dx)>Math.abs(dy*ratio)?dx*(left?-1:1):dy*ratio*(top?-1:1);
const anchorX=left?g.rect.x+g.rect.width:g.rect.x,anchorY=top?g.rect.y+g.rect.height:g.rect.y;
const maximum=Math.min(left?anchorX:1-anchorX,(top?anchorY:1-anchorY)*ratio);
const width=clamp(g.rect.width+delta,Math.min(maximum,Math.tan(5*Math.PI/180)/view.tanHalfHorizontal),maximum),height=width/ratio;
draw({x:left?anchorX-width:anchorX,y:top?anchorY-height:anchorY,width,height});
function commit(){if(view&&box.current?.style.visibility!=='hidden')onChange(settingsFromFrame(current.current,view));}
useEffect(()=>{if(view){const old=frameFromSettings(settings,aspect,view),next=draw(fitMinimumFrame(old,ratio,minWidth));if(next&&(Math.abs(next.width-old.width)>1e-9||Math.abs(next.x-old.x)>1e-9||Math.abs(next.y-old.y)>1e-9))commit();}},[view?.tanHalfHorizontal,view?.centerTanX,view?.centerTanY,aspect,settings.horizontalFov,settings.centerX,settings.centerY,minWidth]);
function scale(width:number){if(view)draw(resizeFrameCentered(current.current,width,ratio,minWidth));}
function finish(id?:number){if(!gesture.current.finish(id))return;const captured=pointer.current;pointer.current=null;try{if(captured!==null&&box.current?.hasPointerCapture(captured))box.current.releasePointerCapture(captured);}catch{}commit();}
release.current=finish;
useEffect(()=>{const up=(e:PointerEvent)=>release.current(e.pointerId),cancel=()=>release.current();document.addEventListener('pointerup',up,true);document.addEventListener('pointercancel',up,true);window.addEventListener('blur',cancel);window.addEventListener('framely.pointerRelease',cancel);return()=>{document.removeEventListener('pointerup',up,true);document.removeEventListener('pointercancel',up,true);window.removeEventListener('blur',cancel);window.removeEventListener('framely.pointerRelease',cancel);gesture.current.finish();};},[]);
function down(e:React.PointerEvent<HTMLDivElement>){if(!view||e.button!==0)return;e.preventDefault();finish();gesture.current.begin(e.pointerId,e.clientX,e.clientY,current.current,(e.target as HTMLElement).closest<HTMLElement>('[data-corner]')?.dataset.corner??null);pointer.current=e.pointerId;try{e.currentTarget.setPointerCapture(e.pointerId);}catch{}}
function move(e:React.PointerEvent<HTMLDivElement>){if(!gesture.current.active(e.pointerId))return;if(!(e.buttons&1)){finish(e.pointerId);return;}const bounds=stage.current?.getBoundingClientRect();if(!bounds||!view)return;const next=gesture.current.move(e.pointerId,e.clientX,e.clientY,e.buttons,bounds,ratio,minWidth);if(next)draw(next);
}
function end(e:React.PointerEvent<HTMLDivElement>){if(!gesture.current)return;gesture.current=null;if(e.currentTarget.hasPointerCapture(e.pointerId))e.currentTarget.releasePointerCapture(e.pointerId);commit();}
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}>
<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}>
{!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} className={`cast-crop-handle ${corner}`} 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"/>)}
</div>}
</div>
<div className="cast-zoom-controls">
+33
View File
@@ -0,0 +1,33 @@
import {clamp,fitFrame,type FrameRect} from './cast-framing';
export type MinimumFrame={width:number;height:number};
export function minimumFrameWidth(ratio:number,minimum:MinimumFrame,fovMinimum=0){
return Math.min(1,ratio,Math.max(fovMinimum,minimum.width,minimum.height*ratio));
}
export function fitMinimumFrame(rect:FrameRect,ratio:number,minimum:number):FrameRect{
const width=clamp(rect.width,minimum,Math.min(1,ratio)),height=width/ratio;
const cx=clamp(rect.x+rect.width/2,width/2,1-width/2),cy=clamp(rect.y+rect.height/2,height/2,1-height/2);
return {x:cx-width/2,y:cy-height/2,width,height};
}
export function resizeFrameCentered(rect:FrameRect,width:number,ratio:number,minimum:number):FrameRect{
const cx=rect.x+rect.width/2,cy=rect.y+rect.height/2;
const maximum=Math.min(2*cx,2*(1-cx),2*cy*ratio,2*(1-cy)*ratio);
width=clamp(width,Math.min(minimum,maximum),maximum);
return {x:cx-width/2,y:cy-width/ratio/2,width,height:width/ratio};
}
export class FrameGesture {
private drag:{id:number;x:number;y:number;rect:FrameRect;corner:string|null}|null=null;
begin(id:number,x:number,y:number,rect:FrameRect,corner:string|null){this.drag={id,x,y,rect:{...rect},corner};}
active(id?:number){return !!this.drag&&(id===undefined||this.drag.id===id);}
finish(id?:number){if(!this.active(id))return false;this.drag=null;return true;}
move(id:number,x:number,y:number,buttons:number,stage:{width:number;height:number},ratio:number,minimum:number):FrameRect|null{
const g=this.drag;if(!g||g.id!==id||stage.width<=0||stage.height<=0)return null;
if(!(buttons&1)){this.finish(id);return null;}
const dx=(x-g.x)/stage.width,dy=(y-g.y)/stage.height;
if(!g.corner)return fitFrame({...g.rect,x:g.rect.x+dx,y:g.rect.y+dy},ratio,1,minimum);
const sx=g.corner.includes('w')?-1:1,sy=g.corner.includes('n')?-1:1;
// Project pointer travel onto the corner's aspect-constrained diagonal.
// Both sides move equally: no opposite-corner/top-left anchoring.
const delta=2*(sx*dx+sy*dy/ratio)/(1+1/(ratio*ratio));
return resizeFrameCentered(g.rect,g.rect.width+delta,ratio,minimum);
}
}
+53
View File
@@ -0,0 +1,53 @@
import type {FrameRect} from './cast-framing';
import {fitMinimumFrame,resizeFrameCentered} from './cast-framing-gesture';
export type VisibilityGrid={size:number;extent:number;cells:string};
export const framingMenuScale=(referenceTan:number)=>0.9/referenceTan;
export class FramingLimits {
private readonly prefix:Int32Array;
readonly size:number;
constructor(grid:VisibilityGrid,referenceTan:number){
const n=grid?.size;
if(!Number.isInteger(n)||n<8||n>256||n%4||grid.extent!==referenceTan||grid.cells?.length!==n*n/4||!/^[0-9a-f]+$/i.test(grid.cells))throw Error('无法读取实际录制区域,请更新采集程序');
this.size=n;this.prefix=new Int32Array((n+1)*(n+1));
for(let y=0;y<n;y++)for(let x=0;x<n;x++){
const i=y*n+x,invalid=(parseInt(grid.cells[i>>2],16)&(1<<(i&3)))?0:1,p=(y+1)*(n+1)+x+1;
this.prefix[p]=invalid+this.prefix[p-1]+this.prefix[p-n-1]-this.prefix[p-n-2];
}
}
contains(rect:FrameRect){
const n=this.size;
// Include a one-cell halo: the visible outline is outside the capture
// boundary, and must not straddle an invalid lens/panel cell.
const x0=Math.floor(rect.x*n+1e-9)-1,y0=Math.floor(rect.y*n+1e-9)-1,x1=Math.ceil((rect.x+rect.width)*n-1e-9)+1,y1=Math.ceil((rect.y+rect.height)*n-1e-9)+1;
if(![rect.x,rect.y,rect.width,rect.height].every(Number.isFinite)||rect.width<=0||rect.height<=0||x0<0||y0<0||x1>n||y1>n)return false;
const stride=n+1,p=this.prefix;
return p[y1*stride+x1]-p[y0*stride+x1]-p[y1*stride+x0]+p[y0*stride+x0]===0;
}
fit(rect:FrameRect,ratio:number,minimum:number,previous?:FrameRect):FrameRect{
rect=fitMinimumFrame(rect,ratio,minimum);
if(this.contains(rect))return rect;
const centered=(cx:number,cy:number,width:number):FrameRect=>({x:cx-width/2,y:cy-width/ratio/2,width,height:width/ratio});
let cx=rect.x+rect.width/2,cy=rect.y+rect.height/2;
if(previous&&Math.abs(previous.width/previous.height-ratio)<1e-9&&previous.width>=minimum-1e-9&&this.contains(previous)){
if(Math.abs(rect.width-previous.width)<1e-9){
let lo=0,hi=1;for(let k=0;k<36;k++){const t=(lo+hi)/2,next={...rect,x:previous.x+(rect.x-previous.x)*t,y:previous.y+(rect.y-previous.y)*t};if(this.contains(next))lo=t;else hi=t;}
return {...rect,x:previous.x+(rect.x-previous.x)*lo,y:previous.y+(rect.y-previous.y)*lo};
}
}
if(!this.contains(centered(cx,cy,minimum))){
const targetX=cx,targetY=cy;
let distance=Infinity,found=false;
for(let y=0;y<this.size;y++)for(let x=0;x<this.size;x++){
const nx=(x+.5)/this.size,ny=(y+.5)/this.size,d=(nx-targetX)**2+(ny-targetY)**2;
if(d<distance&&this.contains(centered(nx,ny,minimum))){distance=d;found=true;cx=nx;cy=ny;}
}
if(!found)throw Error('菜单尺寸超过当前可录制范围');
}
let lo=minimum,hi=rect.width;
for(let k=0;k<36;k++){const width=(lo+hi)/2;if(this.contains(centered(cx,cy,width)))lo=width;else hi=width;}
return centered(cx,cy,lo);
}
reset(ratio:number,minimum:number){
return this.fit(resizeFrameCentered({x:0,y:0,width:1,height:1},Math.min(1,ratio),ratio,minimum),ratio,minimum);
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
export type FrameRect={x:number;y:number;width:number;height:number};
export type ReferenceView={tanHalfHorizontal:number;centerTanX:number;centerTanY:number;aspect:number};
export const clamp=(v:number,min:number,max:number)=>Math.max(min,Math.min(max,v));
export const frameRatios=[{label:'16:9',value:16/9},{label:'16:10',value:16/10},{label:'4:3',value:4/3},{label:'1:1',value:1},{label:'9:16',value:9/16}];
export const frameRatios=[{label:'16:9',value:16/9},{label:'16:10',value:16/10},{label:'4:3',value:4/3},{label:'3:4',value:3/4},{label:'1:1',value:1},{label:'9:16',value:9/16}];
export function framePresets(aspect:number){
return [1280,1920,2560,3840].map(edge=>aspect>=1?{width:edge,height:Math.round(edge/aspect/2)*2}:{width:Math.round(edge*aspect/2)*2,height:edge});
}
@@ -13,7 +13,7 @@ export function fitFrame(rect:FrameRect,aspect:number,referenceAspect=1,minWidth
}
export function frameFromSettings(settings:{horizontalFov:number|null;centerX:number;centerY:number},aspect:number,view:ReferenceView):FrameRect{
const maxWidth=Math.min(1,aspect/view.aspect);
const width=settings.horizontalFov===null?maxWidth:Math.tan(settings.horizontalFov*Math.PI/360)/view.tanHalfHorizontal;
const width=clamp(settings.horizontalFov===null?maxWidth:Math.tan(settings.horizontalFov*Math.PI/360)/view.tanHalfHorizontal,Math.min(maxWidth,Math.tan(5*Math.PI/180)/view.tanHalfHorizontal),maxWidth);
const height=width*view.aspect/aspect;
const x=.5+(Math.tan(settings.centerX*Math.PI/180)-view.centerTanX)/(2*view.tanHalfHorizontal)-width/2;
const y=.5+(Math.tan(settings.centerY*Math.PI/180)-view.centerTanY)*view.aspect/(2*view.tanHalfHorizontal)-height/2;
+11 -4
View File
@@ -1,11 +1,12 @@
import React,{useEffect,useState} from 'react';
import {IconCast,IconPlayerPlay,IconPlayerStop,IconArrowUpRight,IconDeviceVisionPro} from '@tabler/icons-react';
import {IconCast,IconPlayerPlay,IconPlayerStop,IconArrowUpRight,IconDeviceVisionPro,IconCrop} from '@tabler/icons-react';
import {api} from './api';
import {t} from './i18n';
import {Switch} from './switch';
import {Switch,SwitchRow} from './switch';
import type {CastSettings} from './casting';
import {RecordingButton,type RecordingStatus} from './cast-recording';
type Runtime={running:boolean;error?:string;receiverError?:string;watchAddresses?:string[]};
type Runtime={running:boolean;recording?:RecordingStatus;recordingRegion?:{editing:boolean;error?:string};preview?:boolean;error?:string;receiverError?:string;watchAddresses?:string[]};
export function CastQuick({config,refresh}:{config:CastSettings;refresh:()=>Promise<void>}){
const [runtime,setRuntime]=useState<Runtime|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState('');
useEffect(()=>{let alive=true;const poll=()=>void api<Runtime>('cast.status').then(v=>{if(alive)setRuntime(v);}).catch(e=>{if(alive)setError(e.message);});poll();const timer=setInterval(poll,1000);return()=>{alive=false;clearInterval(timer);};},[]);
@@ -13,7 +14,13 @@ export function CastQuick({config,refresh}:{config:CastSettings;refresh:()=>Prom
const running=!!runtime?.running;
return <section className="cast-quick">
<header><IconCast size={25} stroke={1.8}/><h1>{t('串流')}</h1><button aria-label={t('投屏设置')} onClick={()=>void run(()=>api('host.manager.open',{page:'casting'}))}><IconArrowUpRight size={22}/></button></header>
<div className="cast-quick-stream"><IconDeviceVisionPro size={40} stroke={1.5}/><h2>{running?t('正在串流'):t('头显画面')}</h2><p>{runtime?`${config.width} × ${config.height} / ${config.fps} FPS`:t('正在连接')}</p><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></div>
<div className="cast-quick-stream"><IconDeviceVisionPro size={40} stroke={1.5}/><h2>{running?t('正在串流'):t('头显画面')}</h2><p>{runtime?`${config.width} × ${config.height} / ${config.fps} FPS`:t('正在连接')}</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><p className="cast-record-path">{t('录制保存到 /home/steamos/Videos/framely')}</p></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>
<SwitchRow label={t('录制区域显示')} checked={config.recordingRegionVisible??false} disabled={busy||!runtime||(!(config.source==='screen'&&config.output==='eye')&&!config.recordingRegionVisible)} onChange={enabled=>void run(()=>api('cast.region.visible.save',{enabled}))}/>
</div>{(config.source!=='screen'||config.output!=='eye')&&<p className="cast-help">{t('录制区域显示仅支持屏幕采集的单眼画面')}</p>}
{runtime?.recordingRegion?.error&&<p className="error" role="alert">{runtime.recordingRegion.error}</p>}
</div>
{running&&runtime?.watchAddresses?.[0]&&<div className="cast-quick-address"><span>{t('网页观看')}</span><code>{runtime.watchAddresses[0]}</code></div>}
<div className="cast-quick-receive"><h2>{t('接收投屏')}</h2>{(['airplay','dlna'] as const).map(protocol=><label key={protocol}><span>{protocol==='airplay'?'AirPlay':'DLNA'}</span><Switch label={protocol==='airplay'?'AirPlay':'DLNA'} checked={config[protocol]} disabled={busy} onChange={value=>void run(()=>api('cast.settings.save',{...config,[protocol]:value}))}/></label>)}</div>
{(error||runtime?.error||runtime?.receiverError)&&<p className="error" role="alert">{error||runtime?.error||runtime?.receiverError}</p>}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import {IconCircleFilled,IconPlayerStop} from '@tabler/icons-react';
import {t} from './i18n';
export type RecordingStatus={active:boolean;elapsedSeconds?:number;path?:string|null;error?:string|null};
export function RecordingButton({recording,disabled,onClick}:{recording?:RecordingStatus;disabled:boolean;onClick:()=>void}){
const seconds=recording?.elapsedSeconds??0;
const elapsed=`${Math.floor(seconds/60).toString().padStart(2,'0')}:${(seconds%60).toString().padStart(2,'0')}`;
return <button className={`cast-record-button${recording?.active?' recording':''}`} disabled={disabled} onClick={onClick} title={recording?.path??'/home/steamos/Videos/framely'}>{recording?.active?<IconPlayerStop size={21}/>:<IconCircleFilled size={18}/>}<span>{t(recording?.active?'停止录制':'开始录制')}{recording?.active&&` · ${elapsed}`}</span></button>;
}
+15
View File
@@ -0,0 +1,15 @@
import {frameFromSettings,settingsFromFrame,clamp,type ReferenceView,type FrameRect} from './cast-framing';
import type {CastSettings} from './casting';
import {fitMinimumFrame,minimumFrameWidth,type MinimumFrame} from './cast-framing-gesture';
// Canonicalize the displayed box BEFORE saving; ratio changes must not leave
// a clamped visual rectangle paired with different capture parameters.
export type FrameLimiter=(rect:FrameRect,ratio:number,minimum:number,previous?:FrameRect)=>FrameRect;
export function normalizeRegion(settings:CastSettings,reference:ReferenceView,minimum:MinimumFrame={width:0,height:0},limit?:FrameLimiter,previous?:CastSettings):CastSettings{
const bounded={...settings,horizontalFov:clamp(settings.horizontalFov??90,10,150),centerX:clamp(settings.centerX,-60,60),centerY:clamp(settings.centerY,-60,60)};
const ratio=settings.width/settings.height/reference.aspect;
const minWidth=minimumFrameWidth(ratio,minimum,Math.tan(5*Math.PI/180)/reference.tanHalfHorizontal);
let rect=fitMinimumFrame(frameFromSettings(bounded,settings.width/settings.height,reference),ratio,minWidth);
if(limit)rect=limit(rect,ratio,minWidth,previous?frameFromSettings(previous,previous.width/previous.height,reference):undefined);
const next=settingsFromFrame(rect,reference);
return {...bounded,...next,centerX:clamp(next.centerX,-60,60),centerY:clamp(next.centerY,-60,60)};
}
+11
View File
@@ -0,0 +1,11 @@
html.cast-framing-view,html.cast-framing-view body,html.cast-framing-view #root{background:transparent!important;width:100%;height:100%;overflow:hidden;margin:0;padding:0}
.cast-native-framing{position:fixed;inset:0;background:transparent;isolation:isolate}
.cast-native-framing .cast-framing-stage{position:absolute;inset:0;width:100%;height:100%;max-width:none;border-radius:0;background:transparent}
.cast-native-framing .cast-crop-box{border:0;outline:2px solid #c1e0ef;outline-offset:0;background:rgb(80 110 130/.035);box-shadow:0 0 0 2000px rgb(0 0 0/.10);border-radius:0}
.cast-native-framing .cast-crop-grid{opacity:.14}
.cast-native-framing .cast-zoom-controls,.cast-native-framing .cast-help{display:none}
.cast-native-framing-controls{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) scale(var(--framing-menu-scale,1));width:340px;padding:24px;border:1px solid rgb(255 255 255/.22);border-radius:14px;background:rgb(20 24 28/.78);color:#f4f7fa;box-shadow:0 8px 32px rgb(0 0 0/.16)}
.cast-native-framing-controls .cast-framing-reset{width:100%;margin:12px 0 0;min-height:44px}
.cast-native-framing-controls h1{font-size:23px;margin:0 0 10px}.cast-native-framing-controls p{font-size:14px;line-height:1.6;color:#c2ced6}
.cast-native-framing-field{display:flex;align-items:center;justify-content:space-between;gap:14px;font-size:15px;margin:16px 0}.cast-native-framing-field .framely-select{width:175px}.cast-native-framing-field .framely-select>button{margin:0;background:#252c32}.cast-native-framing-field [role=listbox]{z-index:20!important;background:#252c32!important}.cast-native-framing-field [role=option]{margin:0;min-height:44px}
.cast-native-framing-controls footer{display:flex;gap:12px;margin-top:20px}.cast-native-framing-controls footer button{flex:1}
+79
View File
@@ -0,0 +1,79 @@
import React,{useEffect,useMemo,useRef,useState} from 'react';
import {api} from './api';
import {t,configureLanguage,type LanguagePack} from './i18n';
import {Select} from './localized-select';
import type {CastSettings} from './casting';
import {FramingEditor} from './cast-framing-editor';
import {framePresets,frameRatios,settingsFromFrame,type ReferenceView} from './cast-framing';
import {normalizeRegion} from './cast-region';
import {FramingLimits,framingMenuScale,type VisibilityGrid} from './cast-framing-limits';
import {minimumFrameWidth} from './cast-framing-gesture';
import './casting.css';
import './cast-spatial-framing.css';
type Geometry={id:string;settings:CastSettings;referenceTan:number;view:ReferenceView&{visibility:VisibilityGrid;eye:0|1}};
export async function loadSpatialFraming(request:typeof api=api){
const [geometry,status,language]=await Promise.all([request<Geometry>('cast.framing.get'),request<{database:{language?:string}}>('status'),request<{packs?:LanguagePack[]}>('language.list')]);
configureLanguage(status.database.language??'auto',language.packs??[]);
return geometry;
}
export function SpatialFraming(){
const [geometry,setGeometry]=useState<Geometry|null>(null),[draft,setDraft]=useState<CastSettings|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[closed,setClosed]=useState(false);
const finishing=useRef(false),finished=useRef(false);
const root=useRef<HTMLElement>(null),menu=useRef<HTMLElement>(null),[minimum,setMinimum]=useState({width:0,height:0,referenceTan:0});
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: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);};},[geometry?.referenceTan]);
const reference:ReferenceView={tanHalfHorizontal:geometry?.referenceTan??1,centerTanX:0,centerTanY:0,aspect:1};
const limits=useMemo(()=>geometry?new FramingLimits(geometry.view.visibility,geometry.referenceTan):null,[geometry]);
const limit=useMemo(()=>limits?.fit.bind(limits),[limits]);
useEffect(()=>{let alive=true;document.documentElement.classList.add('cast-framing-view');
void loadSpatialFraming().then(g=>{if(!alive)return;setGeometry(g);setDraft({...g.settings,horizontalFov:2*Math.atan(g.view.tanHalfHorizontal)*180/Math.PI,centerX:Math.atan(g.view.centerTanX)*180/Math.PI,centerY:Math.atan(g.view.centerTanY)*180/Math.PI});}).catch(e=>{if(alive)setError(String(e.message??e));});
return()=>{alive=false;document.documentElement.classList.remove('cast-framing-view');};
},[]);
function change(patch:Partial<CastSettings>){if(!draft||finishing.current||finished.current||minimum.referenceTan!==geometry?.referenceTan)return false;try{setDraft(normalizeRegion({...draft,...patch},reference,minimum,limit,draft));return true;}catch(e){setError(t((e as Error).message));return false;}}
function resetSize(){if(!draft||!limits)return;try{const ratio=draft.width/draft.height,minWidth=minimumFrameWidth(ratio,minimum,Math.tan(5*Math.PI/180)/reference.tanHalfHorizontal);if(change(settingsFromFrame(limits.reset(ratio,minWidth),reference)))setError('');}catch(e){setError(t((e as Error).message));}}
function closeEditor(){finished.current=true;setClosed(true);setGeometry(null);setDraft(null);}
async function finish(apply:boolean){if(!geometry||finishing.current||finished.current)return;finishing.current=true;setBusy(true);setError('');try{
await api(apply?'cast.framing.apply':'cast.framing.cancel',apply&&draft?{id:geometry.id,expectedEye:geometry.view.eye,...normalizeRegion(draft,reference,minimum,limit,draft)}:{id:geometry.id});
// The native host closes asynchronously. Never leave the completed editor
// interactive while it is waiting for that close command or status update.
closeEditor();
}catch(e){setError(String((e as Error).message??e));
// Saving can succeed even if restarting the stream fails afterwards. The
// backend has already closed that editor, so do not revive its stale id.
try{const latest=await api<Geometry>('cast.framing.get');if(latest.id!==geometry.id)closeEditor();}
catch(stateError){if((stateError as Error).message===t('画幅调整窗口已关闭'))closeEditor();}
}finally{finishing.current=false;setBusy(false);}}
useEffect(()=>{const close=()=>void finish(false);window.addEventListener('framely.back',close);return()=>window.removeEventListener('framely.back',close);},[geometry,busy]);
const aspect=draft?draft.width/draft.height:4/3;
const ready=!!geometry&&minimum.referenceTan===geometry.referenceTan;
useEffect(()=>{
if(!ready||!geometry||!draft||busy||closed||finishing.current||finished.current)return;
const controller=new AbortController(),params={id:geometry.id,centerX:draft.centerX,centerY:draft.centerY};
// Draft centers commit after a drag ends. Update only the native depth probe,
// not capture settings, and coalesce rapid keyboard/ratio/reset adjustments.
const timer=setTimeout(()=>{
if(finishing.current||finished.current)return;
void api('cast.framing.reference',params,controller.signal).catch(()=>{});
},100);
// This is presentation-only, so aborted/stale updates must not overwrite a
// real save error or revive an editor whose native window is closing.
return()=>{clearTimeout(timer);controller.abort();};
},[ready,geometry?.id,draft?.centerX,draft?.centerY,busy,closed]);
if(closed)return null;
return <main ref={root} className="cast-native-framing" aria-label={t('画幅调整')} inert={busy}>
{draft&&geometry&&ready&&<FramingEditor spatial limit={limit} minimumSize={minimum} settings={draft} view={reference} onChange={change} onError={message=>setError(t(message))}/>}
<section ref={menu} className="cast-native-framing-controls" style={{'--framing-menu-scale':framingMenuScale(reference.tanHalfHorizontal)} as React.CSSProperties}>
<h1>{t('画幅调整')}</h1><p>{t('框内即录制区域,拖动移动,拖动四角等比例缩放。')}</p>
<div className="cast-native-framing-field"><span>{t('画面比例')}</span><Select label={t('画面比例')} disabled={busy||!draft} value={String(frameRatios.find(r=>Math.abs(r.value-aspect)<.004)?.value??aspect)} onChange={value=>{const ratio=Number(value),edge=Math.max(draft!.width,draft!.height);change(ratio>=1?{width:edge,height:Math.round(edge/ratio/2)*2}:{width:Math.round(edge*ratio/2)*2,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>
<div className="cast-native-framing-field"><span>{t('录制分辨率')}</span><Select label={t('录制分辨率')} disabled={busy||!draft} value={draft?`${draft.width}x${draft.height}`:''} onChange={value=>{const [width,height]=value.split('x').map(Number);change({width,height});}} options={[
...(draft&&!framePresets(aspect).some(s=>s.width===draft.width&&s.height===draft.height)?[{value:`${draft.width}x${draft.height}`,label:`${draft.width} × ${draft.height}`}]:[]),
...framePresets(aspect).filter(s=>s.width<=4096&&s.height<=4096&&s.width*s.height<=8847360).map(s=>({value:`${s.width}x${s.height}`,label:`${s.width} × ${s.height}`}))
]}/></div>
{error&&<p role="alert" className="error">{error}</p>}
<button className="cast-framing-reset" title={t('保留比例和分辨率,恢复居中有效画幅')} disabled={busy||!draft||!limits||!ready} onClick={resetSize}>{t('重置大小')}</button>
<footer><button disabled={busy||!geometry} onClick={()=>void finish(false)}>{t('取消')}</button><button className="primary" disabled={busy||!draft||!ready} onClick={()=>void finish(true)}>{t('应用取景')}</button></footer>
</section>
</main>;
}
+46 -5
View File
@@ -3,21 +3,60 @@ import {IconArrowLeft,IconPlayerPause,IconPlayerPlay,IconVolume,IconVolumeOff,Ic
import {CastRange} from './cast-range';
import {t} from './i18n';
export function CastVideo({active,onError,controls=true,aspectRatio,videoRef,onState,showStatus=true}:{active:boolean;onError?:(error:string)=>void;controls?:boolean;aspectRatio?:number;videoRef?:React.RefObject<HTMLVideoElement|null>;onState?:(state:'connecting'|'playing'|'failed')=>void;showStatus?:boolean}){
export type CastStreamInfo={width:number|null;height:number|null;codec:string|null;fps:number|null};
export function CastVideo({active,onError,controls=true,aspectRatio,videoRef,onState,onStreamInfo,showStatus=true}:{active:boolean;onError?:(error:string)=>void;controls?:boolean;aspectRatio?:number;videoRef?:React.RefObject<HTMLVideoElement|null>;onState?:(state:'connecting'|'playing'|'failed')=>void;onStreamInfo?:(info:CastStreamInfo|null)=>void;showStatus?:boolean}){
const localVideo=useRef<HTMLVideoElement>(null),video=videoRef??localVideo,failure=useRef(onError);failure.current=onError;
const streamInfo=useRef(onStreamInfo);streamInfo.current=onStreamInfo;
const [state,setState]=useState<'connecting'|'playing'|'failed'>('connecting');
useEffect(()=>{onState?.(state);},[state,onState]);
const [ratio,setRatio]=useState(16/9),[retry,setRetry]=useState(0);
useEffect(()=>{
if(!active)return;
const peer=new RTCPeerConnection(),abort=new AbortController();let resource:string|undefined,closed=false;
let previous:{id:string;frames:number;timestamp:number}|undefined,statsBusy=false;
streamInfo.current?.(null);
async function reportStreamInfo(){
if(closed||statsBusy||!streamInfo.current)return;
if(peer.connectionState!=='connected'){previous=undefined;streamInfo.current?.(null);return;}
statsBusy=true;
try{
const stats=await peer.getStats();if(closed)return;
let inbound:RTCInboundRtpStreamStats|undefined;
stats.forEach(report=>{if(report.type==='inbound-rtp'&&(report.kind==='video'||report.mediaType==='video'))inbound=report;});
if(!inbound){previous=undefined;streamInfo.current?.(null);return;}
const {id,framesDecoded,timestamp,frameWidth,frameHeight,codecId}=inbound;
let fps:number|null=null;
if(typeof framesDecoded==='number'){
if(previous?.id===id&&framesDecoded>=previous.frames&&timestamp>previous.timestamp)fps=(framesDecoded-previous.frames)*1000/(timestamp-previous.timestamp);
previous={id,frames:framesDecoded,timestamp};
}
let mime:string|undefined;
stats.forEach(report=>{if(report.id===codecId&&report.type==='codec')mime=report.mimeType;});
const name=typeof mime==='string'?mime.replace(/^video\//i,''):null;
const codec=name?/^(H265|HEVC)$/i.test(name)?'H.265':/^H264$/i.test(name)?'H.264':name.toUpperCase():null;
streamInfo.current?.({width:typeof frameWidth==='number'&&frameWidth>0?frameWidth:video.current?.videoWidth||null,height:typeof frameHeight==='number'&&frameHeight>0?frameHeight:video.current?.videoHeight||null,codec,fps});
}catch{if(!closed){previous=undefined;streamInfo.current?.(null);}}
finally{statsBusy=false;}
}
const statsTimer=setInterval(()=>void reportStreamInfo(),1000);
const guest=!!document.querySelector('meta[name="framely-cast-guest"]');
let accessBusy=false;
const accessTimer=guest?setInterval(()=>{
if(closed||accessBusy)return;accessBusy=true;
void fetch('/cast/access',{cache:'no-store',signal:abort.signal}).then(response=>{
if(!closed&&response.headers.get('X-Framely-Watch-Expired')==='1'){
closed=true;abort.abort();peer.close();if(video.current)video.current.srcObject=null;
setState('failed');failure.current?.(t('观看链接已失效'));
}
}).catch(()=>{}).finally(()=>{accessBusy=false;});
},2000):undefined;
setState('connecting');
for(const kind of ['video','audio']){
const receiver=peer.addTransceiver(kind,{direction:'recvonly'}).receiver;
if('jitterBufferTarget' in receiver){try{(receiver as RTCRtpReceiver&{jitterBufferTarget:number|null}).jitterBufferTarget=0;}catch{}}
}
peer.ontrack=event=>{if(video.current){const stream=video.current.srcObject as MediaStream|null??new MediaStream();stream.addTrack(event.track);video.current.srcObject=stream;void video.current.play().catch(()=>{});}};
peer.onconnectionstatechange=()=>{if(peer.connectionState==='failed'){setState('failed');failure.current?.(t('观看连接失败,请重新连接。'));}};
peer.onconnectionstatechange=()=>{if(peer.connectionState==='failed'){previous=undefined;streamInfo.current?.(null);setState('failed');failure.current?.(t('观看连接失败,请重新连接。'));}};
void(async()=>{
try{
await peer.setLocalDescription(await peer.createOffer());
@@ -28,13 +67,14 @@ export function CastVideo({active,onError,controls=true,aspectRatio,videoRef,onS
peer.addEventListener('icegatheringstatechange',check);abort.signal.addEventListener('abort',cancel,{once:true});check();
});
const response=await fetch('/cast/whep',{method:'POST',headers:{'Content-Type':'application/sdp'},body:peer.localDescription?.sdp,signal:abort.signal});
if(response.headers.get('X-Framely-Watch-Expired')==='1')throw Error(t('观看链接已失效'));
if(!response.ok)throw Error(await response.text()||t('串流尚未就绪,请稍后重试。'));
resource=response.headers.get('Location')??undefined;
if(closed){if(resource)void fetch(resource,{method:'DELETE'});return;}
await peer.setRemoteDescription({type:'answer',sdp:await response.text()});
}catch(error){if(!abort.signal.aborted){setState('failed');failure.current?.(String(error));}}
})();
return()=>{closed=true;abort.abort();peer.close();if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});if(video.current)video.current.srcObject=null;};
return()=>{closed=true;clearInterval(statsTimer);clearInterval(accessTimer);streamInfo.current?.(null);abort.abort();peer.close();if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});if(video.current)video.current.srcObject=null;};
},[active,retry]);
return <div className="cast-video-stage" style={{aspectRatio:aspectRatio??ratio}}><video ref={video} autoPlay playsInline muted controls={controls} onLoadedMetadata={()=>{if(video.current?.videoHeight)setRatio(video.current.videoWidth/video.current.videoHeight);}} onPlaying={()=>setState('playing')}/>{showStatus&&active&&state==='connecting'&&<span role="status">{t('正在连接画面…')}</span>}{showStatus&&active&&state==='failed'&&<div className="cast-video-failure"><p>{t('观看连接失败,请重新连接。')}</p><button onClick={()=>setRetry(v=>v+1)}>{t('重新连接')}</button></div>}{!active&&<span>{t('开始串流后显示头显画面。')}</span>}</div>;
}
@@ -46,13 +86,14 @@ export function CastViewer(){
useEffect(()=>{const changed=()=>setFullscreen(!!document.fullscreenElement);document.addEventListener('fullscreenchange',changed);return()=>document.removeEventListener('fullscreenchange',changed);},[]);
useEffect(()=>{const el=video.current;if(!el)return;const play=()=>setPaused(false),pause=()=>setPaused(true);el.addEventListener('play',play);el.addEventListener('pause',pause);return()=>{el.removeEventListener('play',play);el.removeEventListener('pause',pause);};},[key]);
useEffect(()=>{if(video.current){video.current.muted=muted;video.current.volume=volume;}},[muted,volume,key,state]);
function reconnect(){setError('');setPaused(false);setKey(v=>v+1);reveal();}
const expired=error.includes(t('观看链接已失效'));
function reconnect(){if(expired)return;setError('');setPaused(false);setKey(v=>v+1);reveal();}
function togglePlay(){const el=video.current;if(!el)return;if(el.paused)void el.play().catch(()=>setError(t('无法播放,请重新连接。')));else el.pause();reveal();}
async function toggleFullscreen(){try{if(document.fullscreenElement)await document.exitFullscreen();else if(root.current?.requestFullscreen)await root.current.requestFullscreen();else (video.current as HTMLVideoElement&{webkitEnterFullscreen?:()=>void})?.webkitEnterFullscreen?.();}catch{setError(t('此浏览器暂不支持全屏。'));}reveal();}
return <main ref={root} tabIndex={-1} className={`cast-watch ${visible||state!=='playing'||paused?'controls-visible':''}`} onPointerMove={e=>{if(e.pointerType==='mouse')reveal();}} onClick={e=>{if(!(e.target as HTMLElement).closest('button,a,input')){reveal();}}} onFocusCapture={reveal} onBlurCapture={reveal} onKeyDown={e=>{if((e.target as HTMLElement).closest('button,a,input'))return;if(e.code==='Space'){e.preventDefault();togglePlay();}else if(e.key==='Escape')reveal();}}>
<CastVideo key={key} active controls={false} videoRef={video} onState={setState} onError={setError} showStatus={false}/>
<header className="cast-watch-top cast-watch-overlay"><a href="/manager#casting" className="cast-watch-icon" aria-label={t('返回投屏设置')} title={t('返回投屏设置')}><IconArrowLeft size={22}/></a><strong className="cast-watch-title">{t('头显画面')}</strong><span className="cast-watch-connection" role="status">{state==='playing'?t('实时画面'):state==='connecting'?t('正在连接'):t('连接已断开')}</span></header>
{state!=='playing'&&<div className="cast-watch-message" role={state==='failed'?'alert':'status'}>{state==='connecting'?<><span className="cast-spinner"/><h1>{t('正在连接头显')}</h1><p>{t('画面即将开始,请稍候。')}</p></>:<><h1>{t('暂时无法观看')}</h1><p>{error||t('确认头显已开始串流,然后重新连接。')}</p><button className="primary" onClick={reconnect}><IconRefresh size={20}/>{t('重新连接')}</button></>}</div>}
{state!=='playing'&&<div className="cast-watch-message" role={state==='failed'?'alert':'status'}>{state==='connecting'?<><span className="cast-spinner"/><h1>{t('正在连接头显')}</h1><p>{t('画面即将开始,请稍候。')}</p></>:<><h1>{t(expired?'观看链接已失效':'暂时无法观看')}</h1><p>{error||t('确认头显已开始串流,然后重新连接。')}</p>{!expired&&<button className="primary" onClick={reconnect}><IconRefresh size={20}/>{t('重新连接')}</button>}</>}</div>}
{error&&state==='playing'&&<p className="cast-watch-toast" role="alert">{error}</p>}
<footer className="cast-watch-bottom cast-watch-overlay"><div className="cast-watch-controls"><button className="cast-watch-icon" aria-label={t(paused?'播放':'暂停')} title={t(paused?'播放':'暂停')} disabled={state!=='playing'} onClick={togglePlay}>{paused?<IconPlayerPlay size={24}/>:<IconPlayerPause size={24}/>}</button><div className="cast-watch-sound"><button className="cast-watch-icon" aria-label={t(muted?'开启声音':'静音')} title={t(muted?'开启声音':'静音')} aria-pressed={!muted} onClick={()=>{setMuted(v=>!v);reveal();}}>{muted||volume===0?<IconVolumeOff size={23}/>:<IconVolume size={23}/>}</button><CastRange aria-label={t('音量')} min={0} max={1} step={.05} value={muted?0:volume} onChange={e=>{setVolume(+e.target.value);setMuted(false);reveal();}}/></div><span className="cast-watch-live">{t('实时')}</span><div className="cast-watch-tools"><button className="cast-watch-icon" aria-label={t('重新连接')} title={t('重新连接')} onClick={reconnect}><IconRefresh size={22}/></button><button className="cast-watch-icon" aria-label={t(fullscreen?'退出全屏':'全屏')} title={t(fullscreen?'退出全屏':'全屏')} onClick={()=>void toggleFullscreen()}>{fullscreen?<IconMinimize size={22}/>:<IconMaximize size={22}/>}</button></div></div></footer>
</main>;
+4
View File
@@ -12,5 +12,9 @@
.cast-choice-group small{white-space:normal;line-height:1.45;max-width:100%;text-align:center}.cast-framing-section{scroll-margin-top:16px}.cast-framing-stage{max-width:min(640px,calc(100dvh - 330px))}@media(max-width:767px){.cast-choice-group button{min-width:0}.cast-framing-stage{max-width:100%}.cast-watch-icon{min-height:44px}}
.cast-framing-stage{max-width:min(640px,calc(100dvh - 400px))}.cast-editor-actions{position:sticky;bottom:-24px;background:transparent;padding:14px 0;z-index:6}@media(max-width:767px){.cast-choice-group button>span{white-space:normal}.cast-framing-stage{max-width:100%}.cast-editor-actions{bottom:-16px}}
.cast-protocol-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:6px}.cast-protocol-heading h2{margin:0}.cast-protocol-heading .ui-switch{flex-shrink:0}.cast-range{--range-fill:0%;appearance:none;-webkit-appearance:none;display:block;min-height:32px!important;height:32px!important;padding:0!important;margin:0;background:transparent!important;cursor:pointer;border:0!important;outline-offset:3px;touch-action:pan-y}.cast-range::-webkit-slider-runnable-track{height:6px;border-radius:999px;background:linear-gradient(to right,var(--blue) var(--range-fill),#484848 var(--range-fill))}.cast-range::-webkit-slider-thumb{appearance:none;-webkit-appearance:none;width:28px;height:16px;margin-top:-5px;border:0;border-radius:999px;background:var(--blue);box-shadow:0 1px 3px #0005}.cast-range::-moz-range-track{height:6px;border-radius:999px;background:#484848}.cast-range::-moz-range-progress{height:6px;border-radius:999px;background:var(--blue)}.cast-range::-moz-range-thumb{width:28px;height:16px;border:0;border-radius:999px;background:var(--blue)}.cast-range:disabled{opacity:.4;cursor:default}
.cast-page-heading{flex-wrap:wrap}.cast-status{font-variant-numeric:tabular-nums;white-space:normal;text-align:right;margin-left:auto}
.cast-stream-bar>.cast-stream-actions,.cast-stream-actions{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;gap:10px}.cast-quick-stream .cast-stream-actions{justify-content:center}.cast-record-button>svg{color:#ee565e}.cast-record-button.recording{border-color:#ee565e;color:#ffb4b8}.cast-record-path{font-size:12px;color:var(--muted);overflow-wrap:anywhere;margin:0 0 18px}.cast-record-button span{font-variant-numeric:tabular-nums}@media(max-width:767px){.cast-stream-bar{flex-wrap:wrap}.cast-stream-bar>.cast-stream-actions{width:100%}.cast-stream-actions>button{flex:1;white-space:nowrap}}
.cast-stream-bar>.cast-stream-actions span{color:inherit;font-size:inherit;display:initial}.cast-quick-stream .cast-record-path{font-size:12px;margin:16px 0 0}
/* Viewer: one restrained floating toolbar; the video always remains complete. */
.cast-watch video{object-fit:contain!important}.cast-watch-top{padding-bottom:40px;gap:12px;background:linear-gradient(rgb(0 0 0/.55),transparent)}.cast-watch-title{font-size:14px;font-weight:500;color:#e9e9e9}.cast-watch-connection{font-size:12px;color:#c6c6c6}.cast-watch-bottom{padding-top:32px;background:transparent;display:flex;justify-content:center}.cast-watch-controls{width:auto;max-width:100%;padding:6px 12px;gap:8px;background:rgb(18 18 18/.88);border:1px solid rgb(255 255 255/.12);border-radius:999px;box-shadow:0 8px 28px #0005;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px)}.cast-watch .cast-watch-icon{width:40px;height:40px;min-height:40px;border:0;border-radius:50%;padding:8px;background:transparent;color:#ededed}.cast-watch .cast-watch-icon:hover{background:#ffffff12}.cast-watch-top .cast-watch-icon{background:transparent}.cast-watch-top .cast-watch-icon:hover{background:#ffffff12}.cast-watch-sound{gap:6px}.cast-watch-sound input{width:80px}.cast-watch-live{margin:0 12px 0 8px;font-size:11px;color:#bcbcbc;letter-spacing:.02em}.cast-watch-tools{gap:4px;padding-left:8px;border-left:1px solid #ffffff1f}.cast-watch .cast-range{--blue:#e7e7e7}.cast-watch .cast-range::-webkit-slider-thumb{width:20px;height:12px;margin-top:-3px}.cast-watch .cast-range::-moz-range-thumb{width:20px;height:12px}.cast-watch-message h1{font-size:22px;font-weight:500}.cast-watch-message p{font-size:13px}.cast-watch-message button{border-radius:999px;min-height:40px;font-size:13px;padding:10px 18px}@media(max-width:767px){.cast-watch-bottom{padding-bottom:calc(16px + env(safe-area-inset-bottom))}.cast-watch-controls{gap:4px;padding:5px 9px}.cast-watch-top{padding-top:calc(12px + env(safe-area-inset-top));padding-bottom:32px}.cast-watch-live{display:inline;margin:0 10px 0 4px}.cast-watch-title{font-size:13px}.cast-watch-tools{padding-left:6px;gap:2px}}@media(prefers-reduced-transparency:reduce){.cast-watch-controls{background:#181818;backdrop-filter:none;-webkit-backdrop-filter:none}}
.cast-quick-framing{padding:16px 0;border-top:1px solid var(--line)}.cast-quick-framing-row{display:flex;align-items:center;justify-content:space-between;gap:16px}.cast-quick-framing-row>button{display:flex;gap:10px;align-items:center;margin:0;flex:none}.cast-quick-framing-row .switch-row{width:auto;min-width:0;margin:0;gap:10px;padding:0;flex:1}.cast-quick-framing-row .switch-row b{font-size:16px}.cast-quick-framing-row .ui-switch{width:60px;min-width:60px;flex:none;margin:0}
+14 -9
View File
@@ -5,24 +5,26 @@ import {api} from './api';
import {Switch,SwitchRow} from './switch';
import {CastRange} from './cast-range';
import {t} from './i18n';
import {CastVideo} from './cast-viewer';
import {CastVideo,type CastStreamInfo} from './cast-viewer';
import {FramingEditor} from './cast-framing-editor';
import {framePresets,frameRatios,type ReferenceView} from './cast-framing';
import './casting.css';
import {RecordingButton,type RecordingStatus} from './cast-recording';
export type CastSettings={source:'screen'|'steamVR';codec:'h264'|'h265';output:'eye'|'raw';eye:'steamVR'|'left'|'right';width:number;height:number;fps:number;bitrateMbps:number;horizontalFov:number|null;centerX:number;centerY:number;systemAudio:boolean;microphone:boolean;airplay:boolean;dlna:boolean;receiverName:string};
export type CastSettings={source:'screen'|'steamVR';codec:'h264'|'h265';output:'eye'|'raw';eye:'steamVR'|'left'|'right';width:number;height:number;fps:number;bitrateMbps:number;horizontalFov:number|null;centerX:number;centerY:number;systemAudio:boolean;microphone:boolean;airplay:boolean;dlna:boolean;receiverName:string;recordingRegionVisible?:boolean};
type CastSession={id:string;protocol:string;accepted:boolean;width?:number;height?:number};
type Device={id:string;name:string};
type Runtime={running:boolean;error:string|null;dominantEye:number|null;watchPath:string|null;elapsedSeconds:number|null;preview?:boolean;view?:ReferenceView|null;receiverError?:string|null;sessions?:CastSession[];watchAddresses?:string[]};
type Runtime={running:boolean;recording?:RecordingStatus;error:string|null;dominantEye:number|null;watchPath:string|null;watchAccessEnabled?:boolean;elapsedSeconds:number|null;preview?:boolean;view?:ReferenceView|null;receiverError?:string|null;sessions?:CastSession[];watchAddresses?:string[]};
export type CastPage='casting'|'casting-picture'|'casting-receive'|'casting-devices';
const titles:Record<CastPage,[string,string]>={casting:['网页观看','把头显画面分享给同一网络中的手机或电脑。'],'casting-picture':['画面与声音','选择画面来源,直接调整取景和输出画质。'],'casting-receive':['接收投屏','接收其他设备的音频和视频,在头显中打开。'],'casting-devices':['投到设备','选择同一网络中的设备,播放当前头显画面。']};
function Choices<T extends string|number>({label,value,options,onChange,disabled=false}:{label:string;value:T;options:{value:T;label:string;description?:string}[];onChange:(value:T)=>void;disabled?:boolean}){
return <div className="cast-choice-group" role="radiogroup" aria-label={label}>{options.map((option,i)=><button type="button" role="radio" aria-checked={value===option.value} className={value===option.value?'selected':''} disabled={disabled} key={option.value} onClick={()=>onChange(option.value)} onKeyDown={e=>{if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();const next=(i+(['ArrowLeft','ArrowUp'].includes(e.key)?-1:1)+options.length)%options.length;onChange(options[next].value);(e.currentTarget.parentElement?.children[next] as HTMLElement)?.focus();}}}><span>{option.label}</span>{option.description&&<small>{option.description}</small>}</button>)}</div>;
}
export function Casting({config,refresh,network,page='casting'}:{config:CastSettings;refresh:()=>Promise<void>;network:NetworkPanel;page?:CastPage}){
const [streamInfo,setStreamInfo]=useState<CastStreamInfo|null>(null);
const [draft,setDraft]=useState(config),[runtime,setRuntime]=useState<Runtime|null>(null),[pending,setPending]=useState(''),[error,setError]=useState(''),[saved,setSaved]=useState(false),[epoch,setEpoch]=useState(0),[devices,setDevices]=useState<Device[]>([]),[sending,setSending]=useState<string>(''),[copied,setCopied]=useState(false),[editing,setEditing]=useState(false),[searched,setSearched]=useState(false);
const beforeFraming=useRef<Pick<CastSettings,'horizontalFov'|'centerX'|'centerY'>|null>(null),editorActive=useRef(false),deviceScan=useRef(0),alive=useRef(true);
const captureSettings=({airplay,dlna,receiverName,...capture}:CastSettings)=>capture;
const captureSettings=({airplay,dlna,receiverName,recordingRegionVisible,...capture}:CastSettings)=>capture;
const captureDirty=JSON.stringify(captureSettings(draft))!==JSON.stringify(captureSettings(config)),dirty=JSON.stringify(draft)!==JSON.stringify(config),busy=!!pending,screen=draft.source==='screen',eye=screen&&draft.output==='eye';
const aspect=screen&&draft.output==='raw'?2:draft.width/draft.height;
const ratio=frameRatios.find(r=>Math.abs(r.value-aspect)<.004)?.label;
@@ -37,17 +39,20 @@ export function Casting({config,refresh,network,page='casting'}:{config:CastSett
useEffect(()=>{if(page!=='casting-picture'&&editorActive.current)void action('preview',cancelFraming);},[page]);
async function scan(){const token=++deviceScan.current;setSearched(true);for(let i=0;i<3;i++){const list=await api<Device[]>('cast.devices');if(token!==deviceScan.current||!alive.current)return;setDevices(list);if(i<2)await new Promise(resolve=>setTimeout(resolve,1000));}}
useEffect(()=>{if(page==='casting-devices')void action('scan',scan);},[page]);
async function copy(){const address=['localhost','127.0.0.1','[::1]'].includes(location.hostname)?runtime?.watchAddresses?.[0]??new URL('/cast/watch',location.origin).href:new URL('/cast/watch',location.origin).href;if(navigator.clipboard)await navigator.clipboard.writeText(address);else{const input=document.createElement('textarea');input.value=address;document.body.append(input);input.select();document.execCommand('copy');input.remove();}setCopied(true);setTimeout(()=>setCopied(false),2500);}
const watchAddress=['localhost','127.0.0.1','[::1]'].includes(location.hostname)?runtime?.watchAddresses?.[0]??new URL(runtime?.watchPath??'/cast/watch',location.origin).href:new URL(runtime?.watchPath??'/cast/watch',location.origin).href;
async function copy(){if(navigator.clipboard)await navigator.clipboard.writeText(watchAddress);else{const input=document.createElement('textarea');input.value=watchAddress;document.body.append(input);input.select();document.execCommand('copy');input.remove();}setCopied(true);setTimeout(()=>setCopied(false),2500);}
const errorMessage=error||(page==='casting-receive'||page==='casting-devices'?runtime?.receiverError:runtime?.error);
const presets=framePresets(aspect);
function chooseAspect(value:number){const edge=Math.max(draft.width,draft.height);const size=value>=1?{width:edge,height:Math.round(edge/value/2)*2}:{width:Math.round(edge*value/2)*2,height:edge};change(size);}
return <div className="casting cast-workspace">
<header className="cast-page-heading"><div><h1>{t(titles[page][0])}</h1><p>{t(titles[page][1])}</p></div>{page==='casting'&&<span className={`cast-status ${runtime?.running?'live':''}`} role="status">{pending==='start'?t('正在启动…'):runtime?.running?t('正在串流'):t('未开始串流')}</span>}</header>
<header className="cast-page-heading"><div><h1>{t(titles[page][0])}</h1><p>{t(titles[page][1])}</p></div>{page==='casting'&&<span className={`cast-status ${runtime?.running?'live':''}`} title={t('当前浏览器实际接收并解码的串流信息')} role="status">{pending==='start'?t('正在启动…'):runtime?.running?streamInfo?<>{streamInfo.width&&streamInfo.height?`${streamInfo.width} × ${streamInfo.height}`:'—'} · {streamInfo.codec??'—'} · {streamInfo.fps===null?'—':streamInfo.fps.toFixed(1)} fps</>:t('正在读取串流信息…'):t('未开始串流')}</span>}</header>
{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}/>{!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><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></div>
{runtime?.running&&<section className="cast-share"><div><IconWifi size={24}/><div><h2>{t('在其他设备上观看')}</h2><p>{t('连接同一网络,打开观看地址。')}</p></div></div><div className="cast-share-link"><code>{(!['localhost','127.0.0.1','[::1]'].includes(location.hostname)?new URL('/cast/watch',location.origin).href:runtime.watchAddresses?.[0])??new URL('/cast/watch',location.origin).href}</code><button 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="/cast/watch" aria-label={t('打开观看网页')}><IconExternalLink size={21}/></a></div></section>}
<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>
<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>}
{runtime?.running&&<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} onChange={enabled=>void action('watch-access',async()=>{await api('cast.watchAccess.save',{enabled});setCopied(false);})}/><p>{t('持有此链接的人无需密码即可观看。')}</p><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>}
</>}
{page==='casting-picture'&&<>
+2
View File
@@ -1,4 +1,5 @@
import {CastQuick} from './cast-quick';
import {SpatialFraming} from './cast-spatial-framing';
import {CastPlayer} from './cast-player';
import {Casting,type CastSettings,type CastPage} from './casting';
import {CastViewer} from './cast-viewer';
@@ -38,6 +39,7 @@ const managerMode=location.pathname==='/manager';
const empty:Status={version:'0.4.0',database:{plugins:{},sources:[],safeMode:false},notifications:[],running:[]};
function App(){
if(location.pathname==='/cast/framing')return <SpatialFraming/>;
if(location.pathname==='/apk/permission')return <ApkPermission/>;
if(location.pathname==='/cast/watch')return <CastViewer/>;
if(location.pathname.startsWith('/cast/window/'))return <CastPlayer/>;
+1 -1
View File
@@ -58,7 +58,7 @@ export function Store({plugins,sources,onInspect,onSources,busy,target}:{target?
if(selected){const p=selected.entry,s=selected.source,installed=plugins[p.id],state=stateFor(p,installed,s);const latest=all.find(x=>x.entry.id===p.id&&x.source.id===s.id)?.entry??p;const versions=[latest,...(history.key===historyKey?history.entries:[]).filter(v=>v.version!==latest.version)];return <><button className="back-link" onClick={()=>setSelected(null)}><Icon name="arrow"/>{t("返回插件库")}</button><section className="store-detail"><div className="detail-heading"><PluginImage src={p.icon} name={p.name} size={80}/><div><h1>{p.name}</h1><p><PluginAuthor plugin={p}/> · {p.version}</p></div><button className="primary" disabled={busy||['incompatible'].includes(state.kind)} onClick={()=>void onInspect(request(p,s))}>{state.kind==='update'?t("更新"):state.kind==='new'?t("安装"):state.kind==='other'||state.kind==='different'?t("检查此版本"):state.kind==='installed'?t("重新安装"):state.kind==='older'?t("安装所选版本"):state.label}</button></div><RunUserNotice user={p.runAs}/><section className="settings-card">{history.key===historyKey&&history.loading&&<p className="sub" role="status">{t("正在加载历史版本…")}</p>}{history.key===historyKey&&history.error&&<p className="error" role="alert">{t("历史版本暂时不可用,仍可安装最新版。")}<button onClick={()=>setHistoryRetry(v=>v+1)}>{t("重试")}</button></p>}<Select label={t("可选安装版本")} value={p.version} onChange={version=>{const entry=versions.find(v=>v.version===version);if(entry)setSelected({entry,source:s});}} options={versions.map(v=>({value:v.version,label:v.version+(v.version===installed?.manifest.version?t(" · 已安装"):v.compatible===false?t(" · 不兼容"):v===versions[0]?t(" · 推荐版本"):"")}))}/></section><div className="badges"><span>{state.label}</span><span>{s.name}</span>{(p.tags??[]).map(t=><span key={t}>{t}</span>)}</div>{state.kind==='other'&&<p className="banner">{t("当前安装来自其他源,确认安装此版本后会更换来源。")}</p>}<p className="detail-summary">{p.description}</p><PluginLinks plugin={p}/>{p.engines?.framely&&<p className={p.compatible===false?'banner':'sub'}>{t('要求 Framely:{0}',{0:p.engines.framely})}</p>}{!!p.screenshots?.length&&<div className="screenshots">{p.screenshots.map((url,i)=><figure key={url}><img src={url} alt={t("{0}截图 {1}", {"0": p.name, "1": i+1})}/></figure>)}</div>}<h2>{t("关于插件")}</h2><p className="prose">{p.details||p.description||t("开发者尚未提供详细介绍。")}</p><h2>{t("依赖与冲突")}</h2>{Object.entries(p.dependencies??{}).map(([id,d])=><p key={id}>{t("必需:")}{id} · {typeof d==='string'?d:d.version}{typeof d==='object'&&<small className="source-url">{d.source}</small>}</p>)}{Object.entries(p.optionalDependencies??{}).map(([id,d])=><p key={id}>{t("可选:")}{id} · {typeof d==='string'?d:d.version}</p>)}{Object.entries(p.conflicts??{}).map(([id,range])=><p key={id}>{t("冲突:")}{id} · {range}</p>)}{!!p.exclusiveResources?.length&&<p>{t("独占资源:")}{p.exclusiveResources.join(', ')}</p>}<h2>{t("更新记录")}</h2><p className="prose">{p.changelog||t("开发者尚未提供更新记录。")}</p><section className="settings-card"><h2>{t("版本与来源")}</h2><p>{installed?t("本机版本:{0}", {"0": installed.manifest.version}):t("尚未安装")}{t("· 可用版本:")}{p.version}</p><p className="sub">{t("安装前会展示运行用户。插件库资料由插件源提供,以包内的信息为准。")}</p></section></section></>;}
return <><div className="list-heading"><div><h1>{t("插件库")}</h1><p>{t("浏览已添加插件源中的插件。")}</p></div><div className="row"><button onClick={onSources}><Icon name="link"/>{t("管理插件源")}</button><button disabled={loading} onClick={()=>void load()}><Icon name="refresh"/>{loading?t("刷新中…"):t("刷新")}</button></div></div>{loading&&<div className="banner" role="status"><span>{t("插件源加载进度:{0} / {1}",{0:loadJob?.completedSources??0,1:loadJob?.totalSources??sources.filter(s=>s.enabled).length})}</span><button disabled={!loadJob} onClick={()=>void cancelLoad()}>{t("取消加载")}</button></div>}<div className="store-filters"><div className="search-field"><Icon name="search"/><input aria-label={t("搜索插件库")} placeholder={t("搜索名称、作者或标签")} value={query} onChange={e=>setQuery(e.target.value)}/></div><Select multiple label={t("标签")} value={selectedTags} onChange={setSelectedTags} options={tags.map(value=>({value,label:value}))}/><Select label={t("插件源")} value={source} onChange={setSource} options={[{value:'all',label:t("所有来源")},...sources.filter(s=>s.enabled).map(s=>({value:s.id,label:s.name}))]}/><Select label={t("安装状态")} value={filter} onChange={setFilter} options={[{value:"all",label:t("全部状态")},{value:"installed",label:t("已安装")},{value:"update",label:t("可更新")}]}/></div>{error&&<p className="error">{error}<button onClick={()=>void load()}>{t("重试")}</button></p>}{data.filter(c=>c.error).map(c=><div className="banner" key={c.source?.id}><b>{c.source?.name??t("插件源")}{t("暂时不可用")}</b><p>{c.error}{c.cached?t(",正在显示上次成功读取的目录。"):''}</p><button disabled={loading} onClick={()=>void load()}>{t("重新连接")}</button></div>)}{loading&&!all.length?<div className="empty-state"><h2>{t("正在加载插件目录…")}</h2><p>{t("已安装插件仍可正常使用。")}</p></div>:!sources.some(s=>s.enabled)?<div className="empty-state"><Icon name="link" size={48}/><h2>{t("尚未添加插件源")}</h2><p>{t("配置你信任的目录地址,即可浏览和安装插件。")}</p><button className="primary" onClick={onSources}>{t("管理插件源")}</button></div>:!shown.length?<div className="empty-state"><h2>{all.length?t("没有符合条件的插件"):t("目录暂时没有插件")}</h2><p>{all.length?t("试试其他关键词或清除筛选。"):t("可以检查插件源连接,或导入本地插件包。")}</p>{!!all.length&&<button onClick={()=>{setQuery('');setSelectedTags([]);setSource('all');setFilter('all');}}>{t("清除筛选")}</button>}</div>:<><p className="sub">{shown.length}{t("个结果")}</p><div className="store-grid">{shown.map(({entry:p,source:s,state})=><article className="store-card" key={`${s.id}:${p.id}`} onClick={()=>setSelected({entry:p,source:s})}><PluginImage src={p.icon} name={p.name} size={80}/><div className="store-copy"><h2 className="store-title-row"><button className="store-title" onClick={()=>setSelected({entry:p,source:s})}>{p.name}</button><RunUserBadge user={p.runAs}/></h2><p>{p.description||t("查看插件详情")}</p><small><PluginAuthor plugin={p}/> · {s.name}</small></div><span className="store-version">{p.version}</span><span className={`badge ${state.kind==='update'?'accent':''}`}>{state.label}</span><Icon name="arrow"/></article>)}</div></>}</>;
}
export function Transfer({job,retry,cancel,close}:{job:Job;retry?:()=>void;cancel?:()=>void;close:()=>void}){const labels:Record<string,string>={queued:t("等待开始"),downloading:t("正在下载"),verifying:t("正在校验内容"),resolving:t("正在解析插件依赖"),installing:t("正在安装"),loading:t("正在连接"),done:t("已完成"),failed:t("操作失败"),cancelled:t("已取消")};const percent=job.total?Math.min(100,Math.round(job.received/job.total*100)):undefined;return <div className="modal transfer"><h2>{labels[job.phase]??job.phase}</h2>{job.phase==='downloading'&&<><progress max={100} value={percent}/><p>{(job.received/1024/1024).toFixed(1)} MiB{job.total?` / ${(job.total/1024/1024).toFixed(1)} MiB · ${percent}%`:''}</p></>}{job.error&&<p className="error">{job.error}</p>}{job.phase==='failed'&&retry&&<button className="primary" onClick={retry}>{t("重试")}</button>}{!['done','failed','cancelled','installing'].includes(job.phase)&&cancel&&<button onClick={cancel}>{t("取消")}</button>}{['done','failed','cancelled'].includes(job.phase)&&<button onClick={close}>{t("关闭")}</button>}{job.phase==='installing'&&<p className="sub">{t("请稍候,正在保存插件和配置。")}</p>}</div>}
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);
const form=()=>({id:edit??`source-${Date.now()}`,name:name.trim(),url:url.trim(),allowHttp:http,enabled:true});
async function change(next:Source[]){setBusy(true);setError('');try{await save(next);return true;}catch(e){setError(String(e));return false;}finally{setBusy(false);}}
+1 -1
View File
@@ -92,7 +92,7 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
</div>
{checked&&!release&&<p className="banner" role="status">{t('所选渠道暂无可用发行版本。')}</p>}
{job&&<p className="banner" role="status">{job.kind==='system.check'?t('正在读取更新清单…'):job.phase==='verifying'?t('下载完成,正在校验发行包…'):t('正在下载发行包…')}</p>}
{progress?.phase==='downloading'&&<div className="download-progress"><progress aria-label={t('下载进度')} max={progress.total??1} value={progress.received??0}/><p>{((progress.received??0)/1024/1024).toFixed(1)} / {((progress.total??0)/1024/1024).toFixed(1)} MiB</p></div>}
{progress?.phase==='downloading'&&<div className="download-progress"><progress aria-label={t('下载进度')} max={progress.total??1} value={Math.min(progress.received??0,(progress.total??1)*.99)}/><p>{((progress.received??0)/1024/1024).toFixed(1)} / {((progress.total??0)/1024/1024).toFixed(1)} MiB</p></div>}
{progress?.phase==='verifying'&&<div className="download-progress"><progress aria-label={t('校验进度')} max={progress.total??1} value={progress.verified??0}/><p>{t('已校验 {0} / {1} MiB',{'0':((progress.verified??0)/1024/1024).toFixed(1),'1':((progress.total??0)/1024/1024).toFixed(1)})}</p></div>}
{progress?.phase==='failed'&&<p className="error">{t('上次更新失败:')}{progress.error}<small>{t('诊断日志:/var/lib/framely/logs/update.log')}</small></p>}
{progress?.phase==='done'&&<p className="banner">{t('上次更新操作已完成。')}</p>}