mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 01:02:16 +02:00
Compare commits
111
Commits
v0.5.0-preview.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4886c4de2 | ||
|
|
05e3215204 | ||
|
|
dbf37ecd33 | ||
|
|
f301327eb8 | ||
|
|
ed51e913bb | ||
|
|
176c4d0f8b | ||
|
|
2b43094404 | ||
|
|
2afd1dff50 | ||
|
|
d414fd2733 | ||
|
|
ac6e3dc110 | ||
|
|
b52b8d7e20 | ||
|
|
acf298991f | ||
|
|
92d97d5b3b | ||
|
|
2028c97039 | ||
|
|
fe1c84d5e5 | ||
|
|
493e6f66f9 | ||
|
|
267c5dfff5 | ||
|
|
7d9b5a23f4 | ||
|
|
b885b8f56a | ||
|
|
53ddf07f18 | ||
|
|
b3c692e4a2 | ||
|
|
cbd13e7db2 | ||
|
|
9cf05cde09 | ||
|
|
b23b39fc00 | ||
|
|
2b7fd3098a | ||
|
|
2f9471c3e4 | ||
|
|
1b847f9305 | ||
|
|
d229ad2b09 | ||
|
|
69831e474e | ||
|
|
a078e8bfb1 | ||
|
|
926ee38b96 | ||
|
|
1e62eefb85 | ||
|
|
d5a039dad9 | ||
|
|
fd3666f931 | ||
|
|
76eba81eff | ||
|
|
3427786053 | ||
|
|
2e5496a2bd | ||
|
|
cdb24fd31d | ||
|
|
3039c9ef0e | ||
|
|
41137e1d26 | ||
|
|
d3628b0bcf | ||
|
|
a9d61df041 | ||
|
|
c647aa28a0 | ||
|
|
6cb052f732 | ||
|
|
33941cd3f4 | ||
|
|
1291925884 | ||
|
|
ae35a7c487 | ||
|
|
2a5e77a1c1 | ||
|
|
d2ed96658d | ||
|
|
c08c4d323f | ||
|
|
1cb33ccec3 | ||
|
|
f78bf2f0ee | ||
|
|
87ab0ad431 | ||
|
|
de54f93a73 | ||
|
|
1ed4b80fd3 | ||
|
|
c44fad4fc4 | ||
|
|
05f172e467 | ||
|
|
a73e4a31a1 | ||
|
|
49ff883be7 | ||
|
|
3456cad319 | ||
|
|
7a9855769c | ||
|
|
37464e5158 | ||
|
|
afa29ac8f2 | ||
|
|
885356ea4d | ||
|
|
134eb3f4c4 | ||
|
|
b83da59115 | ||
|
|
2716ec2e41 | ||
|
|
1d1747749a | ||
|
|
caa4fa071d | ||
|
|
96f129d1dd | ||
|
|
06863516c2 | ||
|
|
536ec1e3aa | ||
|
|
2eb921b2c8 | ||
|
|
1e67a2d141 | ||
|
|
782e6076a7 | ||
|
|
a72cf7b3c1 | ||
|
|
423abf3007 | ||
|
|
c2d457ed84 | ||
|
|
418b0e0380 | ||
|
|
fcf423c1c8 | ||
|
|
007849d1cb | ||
|
|
38d3152a73 | ||
|
|
b124409470 | ||
|
|
e8e5ab3d77 | ||
|
|
dc1bd07145 | ||
|
|
4005216a42 | ||
|
|
425983a5fb | ||
|
|
cb67ac5607 | ||
|
|
6cb70b8098 | ||
|
|
79db4a80fc | ||
|
|
6a0e302fd6 | ||
|
|
cbf285d957 | ||
|
|
a3d751541a | ||
|
|
9c7dc06bad | ||
|
|
be36694719 | ||
|
|
786c8b7928 | ||
|
|
2c7863dcfb | ||
|
|
7e374a669a | ||
|
|
b1d0c739b2 | ||
|
|
73fa970021 | ||
|
|
73d2d4289b | ||
|
|
3b7382e1a0 | ||
|
|
d24aa708a7 | ||
|
|
c64bdfb374 | ||
|
|
1fc2e32f26 | ||
|
|
3d2899fb6e | ||
|
|
6589f85ec6 | ||
|
|
e8c406ffca | ||
|
|
16fb76c579 | ||
|
|
c75896541c | ||
|
|
36e1c04758 |
No files matched your search
@@ -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
|
||||
@@ -29,7 +55,13 @@ jobs:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
- name: Install native build dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y g++ libgl1-mesa-dev libx11-dev
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y g++ cmake pkg-config libgl1-mesa-dev libx11-dev \
|
||||
libdrm-dev libvulkan-dev glslc libclang-dev libssl-dev libplist-dev \
|
||||
libavahi-compat-libdnssd-dev libgstreamer1.0-dev \
|
||||
libgstreamer-plugins-base1.0-dev libgupnp-1.6-dev \
|
||||
meson ninja-build libavfilter-dev libavformat-dev libavcodec-dev libavutil-dev
|
||||
- name: Check release version
|
||||
env:
|
||||
RELEASE_REF: ${{ github.ref }}
|
||||
@@ -48,6 +80,38 @@ jobs:
|
||||
key: cef-linux-arm64-154.0.32-682c378
|
||||
- name: Fetch pinned CEF
|
||||
run: test -d target/cef || bash tools/fetch-cef.sh
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
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
|
||||
curl -fsSL --retry 3 -o target/q2f-input/cli-jar.zip https://github.com/ovrport/app/releases/download/1.2.3/cli-jar.zip
|
||||
python3 - <<'PY'
|
||||
import zipfile
|
||||
with zipfile.ZipFile('target/q2f-input/cli-jar.zip') as source:
|
||||
name = 'overportcli-1.2.3-all.jar'
|
||||
with source.open(name) as data, open('target/q2f-input/' + name, 'xb') as output:
|
||||
import shutil
|
||||
shutil.copyfileobj(data, output)
|
||||
PY
|
||||
curl -fsSL --retry 3 -o target/q2f-input/libraries.zip https://files.crx.moe/ovrport/tracker/releases/download/3.4.3-23204ea/libraries.zip
|
||||
curl -fsSL --retry 3 -o target/q2f-input/LICENSE https://raw.githubusercontent.com/ovrport/app/c790854/LICENSE
|
||||
curl -fsSL --retry 3 -o target/q2f-input/source.tar.gz https://github.com/ovrport/app/archive/c790854.tar.gz
|
||||
sha256sum -c <<'EOF'
|
||||
307d58a69a5c9722f912d1b6566b0dd1e0ebe1440c6d1e56cab6cb883d74fef2 target/q2f-input/overportcli-1.2.3-all.jar
|
||||
6cec6c176e1aeee8d9ec4f9b4e3982f71da7b44a36a293249620073a06f00e5e target/q2f-input/libraries.zip
|
||||
2db2c0f597a8be01d9742180019627b4c23fd0213f04f5034fbad53f0d296eb3 target/q2f-input/source.tar.gz
|
||||
EOF
|
||||
jlink --strip-debug --no-man-pages --no-header-files --compress=2 \
|
||||
--add-modules java.base,java.datatransfer,java.xml,java.prefs,java.desktop,java.logging,java.net.http,jdk.crypto.ec,jdk.unsupported \
|
||||
--output target/q2f-input/java
|
||||
- name: Validate
|
||||
run: |
|
||||
npm ci
|
||||
@@ -60,11 +124,19 @@ jobs:
|
||||
env:
|
||||
RELEASE_REPO: ${{ github.repository }}
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
Q2F_JAVA_HOME: target/q2f-input/java
|
||||
Q2F_CLI_JAR: target/q2f-input/overportcli-1.2.3-all.jar
|
||||
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
|
||||
archive=$(find release -maxdepth 1 -name 'framely-*-linux-arm64.tar.gz' ! -name '*-offline-linux-arm64.tar.gz' ! -name 'framely-cef-*' -print -quit)
|
||||
cp release/*.tar.gz release/framely-cef.json release-assets/
|
||||
cp release/*.tar.gz release/framely-cef.json release/framely-q2f.json release-assets/
|
||||
cp tools/bootstrap.py release-assets/bootstrap.py
|
||||
cp install.sh release-assets/install.sh
|
||||
"$CARGO_TARGET_DIR/release/framely" release-manifest --archive "$archive" --url "https://github.com/$RELEASE_REPO/releases/download/$RELEASE_TAG/$(basename "$archive")" --output release-assets/framely-release.json --changelog "Framely $RELEASE_TAG"
|
||||
@@ -93,8 +165,8 @@ jobs:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
cd release-assets
|
||||
sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json framely-cef.json > SHA256SUMS
|
||||
printf 'Framely %s\n\nFirst install: choose the complete *-offline-linux-arm64.tar.gz package. Updates: choose the smaller Framely package without CEF. A separate framely-cef-* archive is available for runtime downloads.\n\nInstall on Frame over SSH using the repository install.sh. SHA256SUMS verifies archives; each archive also includes internal checksums.\n' "$RELEASE_TAG" > release-notes.txt
|
||||
sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json framely-cef.json framely-q2f.json > SHA256SUMS
|
||||
printf 'Framely %s\n\nFirst install: choose the complete *-offline-linux-arm64.tar.gz package. Updates: choose the smaller Framely package without CEF. Separate framely-cef-* and framely-q2f-* archives provide the browser and optional Quest converter.\n\nInstall on Frame over SSH using the repository install.sh. SHA256SUMS verifies archives; each archive also includes internal checksums.\n' "$RELEASE_TAG" > release-notes.txt
|
||||
prerelease=false
|
||||
latest=true
|
||||
if [[ "$RELEASE_TAG" == *-* ]]; then
|
||||
@@ -102,5 +174,5 @@ jobs:
|
||||
latest=false
|
||||
fi
|
||||
gh release create "$RELEASE_TAG" --verify-tag --title "Framely $RELEASE_TAG" --notes-file release-notes.txt --draft --latest=false --prerelease="$prerelease"
|
||||
gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json framely-cef.json
|
||||
gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json framely-cef.json framely-q2f.json
|
||||
gh release edit "$RELEASE_TAG" --draft=false --latest="$latest" --prerelease="$prerelease"
|
||||
@@ -15,3 +15,11 @@
|
||||
# Generated UI review screenshots
|
||||
/docs/previews/
|
||||
/templates/plugin/payload/
|
||||
|
||||
/media/capture/target/
|
||||
/media/bin/
|
||||
|
||||
/media/source/
|
||||
|
||||
# Local agent operating notes
|
||||
/AGENTS.md
|
||||
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.5.0-preview.1"
|
||||
version = "0.8.3-preview.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.5.0-preview.1"
|
||||
version = "0.8.3-preview.3"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -7,9 +7,17 @@
|
||||
|
||||
[简体中文](README.zh-CN.md)
|
||||
|
||||
> Framely is still under rapid development. Preview releases may change frequently; features and interactions are still being refined.
|
||||
> Framely is still under rapid development. Features and interfaces may change between preview releases. Android app and game compatibility depends on Lepton and the app itself.
|
||||
|
||||
A React plugin manager for Steam Frame, with a Rust core service, a separate CEF/OpenVR host, a network management panel, and a desktop installer. Manage plugins, sources, windows, notifications, and updates.
|
||||
Framely provides a unified spatial launcher for Steam Frame and a management panel available both inside the headset and in a PC browser.
|
||||
|
||||
- **Unified launcher**: launch Android apps in Lepton, Linux desktop applications, local Steam games, and games available through Steam Remote Play.
|
||||
- **APK sideloading and management**: install, update, and uninstall APKs, manage application containers and data, and provide controller input mapping and rumble feedback for Android games that support controllers.
|
||||
- **Plugin installation and management**: install and manage Framely plugins, browse the plugin library, and extend Frame with additional features.
|
||||
- **Remote management from your PC**: access Frame's management panel in a PC browser to manage files, APKs, and plugins. Upload, download, and edit files, and preview common image and video formats.
|
||||
- **Web terminal**: use an interactive terminal running on Frame, execute shell commands, and connect to other devices with `ssh`. The web terminal does not require an SSH connection to Frame.
|
||||
|
||||
Framely uses a Rust core service and a separate CEF/OpenVR host, with a desktop installer for setup. It also manages plugin sources, windows, notifications, and updates.
|
||||
|
||||

|
||||
|
||||
@@ -85,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.
|
||||
+13
-5
@@ -7,9 +7,17 @@
|
||||
|
||||
[English](README.md)
|
||||
|
||||
> Framely 目前仍在快速迭代中,Preview 版本可能频繁更新,功能与交互仍在持续完善。
|
||||
> Framely 目前仍在快速迭代中,功能和界面可能随预览版本调整。安卓应用与游戏的兼容性取决于 Lepton 和应用本身。
|
||||
|
||||
Steam Frame 的 React 插件管理器:Rust 核心服务、独立 CEF/OpenVR 宿主、网络管理面板和电脑端安装器。可管理插件、来源、窗口、通知和更新。
|
||||
Framely 为 Steam Frame 提供统一的空间启动台,以及可在头显内和 PC 浏览器中使用的管理面板。
|
||||
|
||||
- **统一启动入口**:启动 Lepton 中的安卓应用、Linux 桌面程序、Steam 本地游戏和 Steam 远程畅玩游戏。
|
||||
- **APK 侧载与管理**:安装、更新和卸载 APK,管理应用容器与数据;为支持手柄的安卓游戏提供手柄输入映射和振动反馈。
|
||||
- **插件安装与管理**:安装和管理 Framely 插件,通过插件库发现并扩展功能。
|
||||
- **PC 网页远程管理**:在 PC 浏览器中访问 Frame 的管理面板,管理设备文件、APK 和插件,支持文件上传、下载、编辑及常见图片和视频预览。
|
||||
- **网页终端**:通过管理面板操作 Frame 的交互式终端,支持 Shell 命令和使用 `ssh` 连接其他设备,无需先通过 SSH 连接 Frame。
|
||||
|
||||
Framely 使用 Rust 核心服务和独立的 CEF/OpenVR 宿主,并提供电脑端安装器,也可管理插件来源、窗口、通知和更新。
|
||||
|
||||

|
||||
|
||||
@@ -85,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 的许可证及对应源码。
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# Framely 内置投屏
|
||||
|
||||
管理网页顶部的 **投屏** 面板使用侧边菜单分为 **网页观看**、**画面与声音**、**接收投屏** 和 **投到设备**。手机通过页面选择器切换,底部六个管理入口保持单行。设置保存在核心数据库。接收协议默认关闭;采集默认 **屏幕采集**,也可手动选择 **SteamVR**。没有自动采集模式,也不会因失败自行切换来源。
|
||||
|
||||
## 采集与网页观看
|
||||
|
||||
快捷面板和“网页观看”提供 **显示录制指示器** 开关,默认开启并持久保存。录制过程中也可切换;关闭仅隐藏头显右上角指示器及其点击区域,不停止或重启录制、不改变录制区域边框。隐藏后仍可通过两个面板的“停止录制”按钮停止。
|
||||
|
||||
“网页观看”和快捷面板的串流卡片均有 **开始/停止录制** 按钮,状态同步。录制与串流共用画面来源、取景、分辨率、帧率、码率、视频编码和声音设置;默认输出为 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”,避免把参考实现的应用名当作采集方式。
|
||||
|
||||
- **单眼画面**:校正镜头畸变,读取 SteamVR 的主力眼或手动选左/右眼。使用实时完整视角预览,拖动取景框移动中心、拖动四角缩放,支持方向键和视野滑杆。开始调整会临时切换共享画面到完整参考视角,应用或取消后恢复之前的串流状态。画幅、分辨率和常见帧率使用预设按钮。
|
||||
- **双眼原始画面**:保留扫描方向、双眼布局及镜头畸变。高度由原始比例和输出宽度计算,不使用主力眼或取景设置。
|
||||
- **SteamVR**:读取 `/dev/video99`,由头显现有 FFmpeg 缩放、限帧及软件编码。实测输入固定为 RGB3 1920×1080;更改 V4L2 格式不能改变输入尺寸。设置帧率会改变驱动的报告值,但实际独立输入帧仍约 90 fps,因此设置调整的是串流输出。
|
||||
|
||||
启动一次采集,多个网页观看者共享同一条所选 H.264 或 H.265 + Opus 串流。管理页预览和 `/cast/watch` 使用同源 WHEP 信令代理和 WebRTC;沿用网络面板的登录与 Origin 检查。观看网页的视频占满视口,播放、音量、重连和全屏控件叠加在画面上,并在静止三秒后收起。始终等比显示完整视频,不裁切或拉伸;默认静音便于浏览器自动播放。手机适配安全区域和横竖屏。
|
||||
|
||||
网页优先协商用户选择的 H.264 或 H.265。当前官方 CEF 运行包不能解码所选编码时,头显自带 FFmpeg 以 libvpx 提供 VP8 + Opus 兼容输出;所有需要 VP8 的观看者共享一个额外的软件编码进程。采集来源不变。支持所选编码的观看者直接接收原串流,DLNA 仍使用 H.264。VP8 增加 CPU 消耗;兼容观看结束后收回该进程。Framely 不安装、调用或依赖外部 Chrome。
|
||||
|
||||
“在其他设备上观看”及 **免验证** 开关常驻显示,未开始串流时也能提前设置和复制观看地址。开启后,显示和复制的观看地址会附带随机凭证。持有该链接的人无需管理密码即可观看,但不能访问管理面板、文件或终端。链接在停止/重新开始串流及重启后仍有效;关闭开关会撤销链接并断开免验证观看,已登录观看不受影响。再次开启生成新链接。网络访问总开关仍需开启。
|
||||
|
||||
声音使用小片段和独立的有界输入队列,避免音频阻塞拖慢视频;编码使用无 B 帧和低延迟参数。SteamVR 的 H.265 是软件编码,会增加 CPU 负载;屏幕采集使用 Iris 硬件编码。
|
||||
|
||||
## 接收与发送
|
||||
|
||||
AirPlay 由固定版本 UxPlay 处理 RAOP 音频和屏幕镜像;只允许一个发送设备,不实现 AirPlay 发送。自定义 GStreamer sink 在用户确认前丢弃音频和画面。断开、拒绝或停止后清除同意状态,新连接重新询问。
|
||||
|
||||
DLNA 使用 GUPnP 的标准发现、SOAP 和事件订阅,接收 HTTP(S) 媒体地址。每个新请求先显示通知,接受后才下载、解码和播放。接收的音频打开内置控制窗口;视频采用旋转及像素比例校正后的实际画幅,控件浮在视频上并自动收起,不改变窗口画幅。不同 DLNA 请求可以保持多个空间窗口。播放器支持暂停、音量、停止以及媒体支持时的跳转;关闭空间窗口会停止该投屏。未处理请求 60 秒后过期。
|
||||
|
||||
GStreamer 解码结果经用户私人运行目录中的带锁 BGRA frame 文件交给原生 OpenGL 空间窗口,CEF 负责播放器控制,不承担视频解码。播放结束、错误及接收进程退出会关闭对应窗口并清理状态。
|
||||
|
||||
DLNA 发送需要开启网络管理面板并启动串流。GUPnP 发现设备后,使用该设备所在网卡的地址发送带随机会话令牌的 HLS 地址。FFmpeg 复制 H.264 视频,将 H.265 转为兼容的 H.264,并在需要时将 Opus 转为 AAC。令牌只允许读取该串流的播放列表和 MPEG-TS 分段,停止串流后失效。设备需要支持 HLS;接收端支持的具体媒体格式取决于头显已安装的 GStreamer 解码器。DRM 保护媒体不在此实现范围。
|
||||
|
||||
## 构建与打包
|
||||
|
||||
头显运行依赖现有 FFmpeg(libx264、libvpx、libopus、AAC、V4L2、PulseAudio)、GStreamer 1.24+、FFmpeg 7 共享库与随包的 gst-libav 插件、GUPnP 1.6、Avahi、OpenVR、Vulkan/DRM、Qualcomm Iris。设备上已确认 FFmpeg、GStreamer 1.24.2、GUPnP 1.6.6 和上述编码器可用。
|
||||
|
||||
构建需要 Rust、C/C++ 编译器、CMake、Meson、Ninja、pkg-config、glslc,以及 DRM/Vulkan、GStreamer app/video、GUPnP 1.6、Avahi、OpenSSL/libplist 开发文件。`tools/build-media.sh` 构建采集助手、接收器、gst-libav 插件和 UxPlay,并下载校验固定版本的 MediaMTX。可用 `FRAMELY_SYSROOT` 为采集 crate 指定 ARM64 sysroot;gst-libav 链接 FFmpeg 7 ABI,其余组件使用目标系统的开发库。
|
||||
|
||||
`tools/package-release.sh <CEF目录>` 同时构建媒体能力;助手放入 `bin`,其他媒体程序放入 `lib/media`。第三方许可证和媒体改动源码随包提供,版本和 SHA-256 位于 `media/dependencies.json`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
CARGO_HOME=/tmp/framely-cast-cargo cargo test --locked
|
||||
npm run typecheck
|
||||
node tools/test-localization.mjs
|
||||
bash tools/test-native.sh
|
||||
python3 tools/test-casting-media.py --binaries media/bin --upnp media/upnp
|
||||
```
|
||||
|
||||
`test-casting-media.py` 使用生成的测试媒体与临时音频 sink,不改变系统音频设置;验证 UxPlay 启动、确认门控、DLNA SOAP、已知色条的解码像素、暂停、音量、精确跳转、并发请求及超过会话上限次数的释放。测试结束回收进程和临时设备。
|
||||
|
||||
实机采集与实际 CEF WebRTC 测试:
|
||||
|
||||
```bash
|
||||
FRAMELY_MEDIA_BIN=/绝对路径/media/bin cargo test --locked steamvr_stream_has_requested_output_and_stops_all_workers -- --ignored --nocapture
|
||||
DISPLAY=:0 FRAMELY_MEDIA_BIN=/绝对路径/media/bin \
|
||||
FRAMELY_TEST_ASSETS=/绝对路径/ui/dist FRAMELY_CAST_WEB_PORT=28189 \
|
||||
FRAMELY_CEF_ROOT=/绝对路径/CEF运行目录 \
|
||||
FRAMELY_CAST_BROWSER_PROBE=/绝对路径/cast-browser-probe \
|
||||
cargo test --locked casting_browser_fixture -- --ignored --nocapture
|
||||
```
|
||||
|
||||
`tests/casting_browser_probe.cpp` 可使用现有 CEF 开发头和 libcef 编译。运行时需要 ICU、资源和 locales 与测试可执行文件一起可用。测试使用独立的临时数据库、网络端口及浏览器缓存,检查网页解码尺寸、连续播放时间和实际帧数,不修改设备上已安装的 Framely。设置 `FRAMELY_CAST_TEST_SOURCE=screen` 可以通过已安装核心的私有 IPC 请求屏幕助手;设置 `FRAMELY_CAST_TEST_CODEC=h265` 验证 H.265;启动请求走实际 HTTP 接口,以验证媒体进程不随请求线程结束而退出。
|
||||
|
||||
需用户在头显上完成的验收:
|
||||
|
||||
1. 戴上/唤醒头显,以 root 执行 `tools/test-casting-panel.py --binaries /绝对路径/media/bin`,验证普通、取景及原始双眼三种屏幕输出。
|
||||
2. 打开 **投屏**,验证默认屏幕串流、视野与主力眼设置、应用后输出变化、网页画面和声音。
|
||||
3. 用真实 AirPlay 发送端分别发送音频和屏幕镜像,确认拒绝前没有声音或画面;接受后可控,断开后重连重新询问。
|
||||
4. 用真实 DLNA 发送端打开两个媒体窗口,核对各自比例、独立暂停/停止,关闭一个不影响另一个。
|
||||
5. 选择真实 DLNA 播放设备,验证头显串流播放和停止;比较 H.264 与 CEF 的 VP8 兼容输出在目标分辨率下的流畅度和延迟。
|
||||
|
||||
自动通过的协议/编解码测试不能替代真实发送端兼容性、头显内视觉比例和用户感知延迟验收。
|
||||
|
||||
快捷面板的录屏模块提供 **画幅调整**:透明空间窗口中的框可以移动、等比例缩放,并修改画幅比例(包括 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 环境。
|
||||
@@ -76,7 +76,7 @@ Optional `quickPage` points to a payload bundle. `windows` defaults to an empty
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `entry` / `title` | Required payload entry and nonempty title ≤120 bytes |
|
||||
| `dockIcon` | false by default; true creates a Dock window |
|
||||
| `dockIcon` | false by default; controls Dock icon visibility only. Both values use the same SteamVR window controls |
|
||||
| `width` / `height` | Default 1600×900; width 640–2560, height 360–1440 |
|
||||
| `widthMeters` | Default 3.0; finite 0.4–4.0 meters. Source null is accepted but omitted during packaging, then reads back as default 3.0 |
|
||||
| `localWeb` | false by default; specialized localhost windows obtain a URL from backend `window.get` |
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
## Default GitHub Release URL
|
||||
|
||||
GitHub topic discovery is also supported without a catalog. See [GitHub plugin discovery and release.json](../github-plugin-discovery.md) for the generated Release asset, legacy compatibility and publishing commands.
|
||||
|
||||
A public GitHub plugin repository's **source `manifest.json` may omit `downloadUrl`**. The community database derives it from the registered repository, version and plugin ID:
|
||||
|
||||
```text
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# GitHub plugin discovery
|
||||
|
||||
[简体中文及完整 JSON 示例](zh-CN/github-plugin-discovery.md)
|
||||
|
||||
Framely searches public GitHub repositories with topic `framely-plugin`, excluding forks and archived repositories. Releases appear in the existing plugin library alongside catalogs. One row per plugin ID recommends the highest compatible SemVer; version selection retains different package hashes and origins. Existing install confirmation, runtime-user checks and dependency resolution apply.
|
||||
|
||||
Upload a `release.json` asset with `schemaVersion: 1` and a nonempty `plugins` array. Required plugin fields are `id`, `name`, `version`, `apiVersion`, `author`, `description`, `asset`, `size`, `sha256`, and `runAs`. `asset` identifies a `.framely` asset in that Release; size is whole-package bytes and SHA256 is 64 hexadecimal characters. `runAs` is the effective backend/lifecycle user, or explicit `null` for UI-only plugins. Optional presentation, engines and relation fields follow existing catalog metadata. Images may name Release assets or fixed HTTPS URLs. Release tag, prerelease flag and download URLs come from GitHub.
|
||||
|
||||
Generate metadata from final, verified packages rather than maintaining hashes manually:
|
||||
|
||||
```bash
|
||||
framely release-json --packages ./release --output ./release/release.json
|
||||
gh release upload "$TAG" ./release/*
|
||||
```
|
||||
|
||||
The generator also exports packaged images into that directory unless external publishing images are configured. Upload every generated file. Add these commands to your own publishing CI and add the repository topic to opt into discovery. Existing external plugin repositories are not changed by this feature.
|
||||
|
||||
Only Releases without `release.json` use legacy compatibility: resolve the tag to a commit SHA, read its root `manifest.json`, require tag/version agreement, match the explicit download URL or `<id>-<version>.framely`, and require GitHub's SHA256 asset digest or `SHA256SUMS`. All available hashes must agree. Malformed new metadata never falls back to legacy metadata. Old clients continue using their existing catalogs; package format is unchanged.
|
||||
|
||||
Metadata uses pagination, SemVer sorting, a ten-minute cache, ETag revalidation and a global six-request concurrency limit. Manual refresh revalidates. Retryable network failures may use stale cache with a warning; invalid JSON, certificate errors and ordinary 404s do not. Proxy settings and cancellation are reused. Browsing does not download package bytes.
|
||||
|
||||
GitHub sources use canonical repository URLs and stable IDs. A newly discovered source is persisted only when installation is confirmed. Updates, history and dependencies subsequently read its Releases. Disabled sources remain disabled. Installation verifies actual package hash, identity, version, API, engine requirements, runtime user and relations against Release metadata.
|
||||
@@ -64,6 +64,8 @@ framely catalog --name 'My plugins' --base-url https://example.org/plugins --pac
|
||||
|
||||
Archives contain Manifest and hashed payload only, without keys/signatures. Host the main catalog and relative history JSON. Authors can host packages on GitHub Releases. Current first initialization includes community stable/testing sources. Origins remain explicit; same-ID plugins in other sources do not silently replace existing origins. HTTP needs explicit development permission; up to five validated redirects, without HTTPS downgrade.
|
||||
|
||||
Alternatively, opt into [GitHub plugin discovery](github-plugin-discovery.md) with topic `framely-plugin` and a generated `release.json` asset. The library merges identical IDs and recommends the highest compatible version, preserving origin/hash choices and installation confirmation. Existing catalogs and older Releases remain supported.
|
||||
|
||||
## Hover feedback
|
||||
|
||||
Interactive controls automatically receive one hover pulse per entry, excluding disabled/inert controls and movement between their children. Supported elements include buttons, links, inputs, interactive ARIA roles/focusable controls or pointer cursors. Custom controls may mark `data-framely-interactive`. Native feedback uses the current hit controller, 12 ms/120 Hz/amplitude 0.15 with at least 80 ms spacing. Pages cannot target other windows/controllers.
|
||||
|
||||
+9
-7
@@ -38,8 +38,8 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven
|
||||
|
||||
Device and installer share the repository with independent versions/tags/workflows:
|
||||
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.4.2-preview.12`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.8`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.8.3-preview.3`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.3`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
|
||||
|
||||
Versions need not match or ship together. Update the relevant Cargo.lock with version changes. Installer package/macOS versions use the installer version.
|
||||
|
||||
@@ -47,18 +47,18 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
|
||||
|
||||
```bash
|
||||
# After committing and pushing the intended source:
|
||||
git tag v0.4.2-preview.12
|
||||
git push origin v0.4.2-preview.12
|
||||
git tag v0.8.3-preview.3
|
||||
git push origin v0.8.3-preview.3
|
||||
# Independent installer release:
|
||||
git tag installer-v0.4.1-preview.8
|
||||
git push origin installer-v0.4.1-preview.8
|
||||
git tag installer-v0.4.3
|
||||
git push origin installer-v0.4.3
|
||||
```
|
||||
|
||||
Prerelease suffixes create GitHub Prereleases without Latest. Installer releases are never Latest. Only stable device releases become Latest, keeping default installer/update URLs on the device product. The desktop installer filters device archive names and does not mistake ARM64 installer packages for device runtimes. Download desktop installers from their explicit installer tags.
|
||||
|
||||
Only tags publish. After every product build succeeds, publication creates a draft, uploads assets/checksums, then makes it public. Failed drafts are not stable download targets. Manual runs leave Actions artifacts only.
|
||||
|
||||
Device attachments include the core archive without CEF, the complete `*-offline-linux-arm64.tar.gz` archive, a separate `framely-cef-*` runtime archive, `framely-release.json`, `framely-cef.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. The update descriptor always targets the core archive. First installation selects the complete offline archive. CEF is verified and shared under `/home/.framely/cef/<runtime-id>`; missing or newly required runtimes download separately, and previous runtimes remain available for rollback. See [package selection and runtime recovery](user-guide/installation.md#release-packages-and-cef). Installer releases have five platform packages and checksums only. Desktop installers download archive/checksums without user-managed scripts.
|
||||
Device attachments include the core archive without CEF, the complete `*-offline-linux-arm64.tar.gz` archive, a separate `framely-cef-*` runtime archive, an optional `framely-q2f-*` Quest conversion archive, `framely-release.json`, `framely-cef.json`, `framely-q2f.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. The update descriptor always targets the core archive. First installation selects the complete offline archive. CEF is verified and shared under `/home/.framely/cef/<runtime-id>`; missing or newly required runtimes download separately, and previous runtimes remain available for rollback. The q2f archive is downloaded only when Quest conversion is chosen; each release pins a verified runtime identity, and already installed APKs are not automatically reconverted. See [package selection and runtime recovery](user-guide/installation.md#release-packages-and-cef). Installer releases have five platform packages and checksums only. Desktop installers download archive/checksums without user-managed scripts.
|
||||
|
||||
External checksums cover downloaded archives; internal checksums cover extracted payload. Neither independently proves author identity. Online installation trusts the chosen repository/HTTPS, local installation the supplied archive/checksums. Redirects never downgrade HTTPS.
|
||||
|
||||
@@ -66,6 +66,8 @@ The entry fetches the release engine. Default is latest stable; `--version TAG`
|
||||
|
||||
Installation initializes the official update descriptor URL only if no source is configured, preserving existing sources for future in-app updates.
|
||||
|
||||
When run from Framely's built-in terminal, the package installer moves into an independent systemd service before stopping Framely. This prevents the terminal shutdown from killing the installer. Its output remains in the journal under the `framely-install-*` unit printed by the command; the UI closes during installation and starts again on completion.
|
||||
|
||||
## Unified uninstallation
|
||||
|
||||
Scripts and desktop UI expose one uninstall operation. It stops session/downloads, then calls root-only `prepare-uninstall --approve`: persistently disable plugins, stop them in dependency order, run uninstall hooks, remove payloads. Only complete success removes services/core/runtime. Failure retains Framely, restores session access and leaves plugins disabled for troubleshooting/retry. There is no skip-failed-plugin manager uninstall.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# APK Manager
|
||||
|
||||
In **APK → Apps → Manage**, set **Custom Steam name** for each app. Leave it blank to restore the original app name. The name survives APK updates and is applied to existing and future Steam entries; if Steam is unavailable, it syncs when Steam is ready. This does not rename the app in Framely or change its package, container or saved data.
|
||||
|
||||
Global controller and container lifecycle preferences are under **APK → Settings**, separate from the application list. Per-application display and launch preferences remain in each app’s management dialog.
|
||||
|
||||
[简体中文](../zh-CN/user-guide/apk-manager.md)
|
||||
@@ -89,7 +91,7 @@ Do not invoke Lepton's global cleanup commands as an application-specific uninst
|
||||
|
||||
## Supported formats
|
||||
|
||||
The initial implementation supports individual APKs. Split APK bundles, XAPK/APKM, OBB import, Google Play installation and automatic Steam shortcut creation are outside this version. Unsupported bundles are rejected rather than treated as ordinary APKs.
|
||||
The initial implementation supports individual APKs. Optional Quest conversion downloads and verifies a separate `framely-q2f-*` runtime when selected in the APK install review. It preserves the original APK and reviews the converted, newly signed APK before installation. The runtime and per-package signing keys live in the configured APK storage location and move with it. An existing original APK may reject the new signature; Framely will not silently uninstall it or erase saves. Updating q2f does not automatically reconvert installed games. Games can continue downloading their own OBB data into persistent container storage. Split APK bundles, XAPK/APKM, manual OBB import, Google Play installation and automatic Steam shortcut creation are outside this version. Unsupported bundles are rejected rather than treated as ordinary APKs.
|
||||
|
||||
### Flat window orientation
|
||||
|
||||
@@ -123,6 +125,28 @@ Steam Input loads SDL3 from Steam’s bundled ARM64 runtime and reads Steam’s
|
||||
|
||||
桥接以 Steam 会话用户运行,使用 Steam 随附的 ARM64 SDL3,仅读取 Steam 虚拟手柄。Framely 输出设备使用独立标识并排除自身,避免反馈循环。仅把专用事件节点及只读按键布局挂载进容器,不修改系统或 Steam 安装文件。Frame 直连使用独立的 SteamVR 身份;Steam Input 不注册 OpenVR 输入动作,避免影响 SDL 的虚拟手柄枚举。SDL3 不可用时明确报错,可手动选择 Frame 直连。日志位于 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键。APK 必须从管理面板或启动台启动,单次 CLI 不能持有持续桥接。
|
||||
|
||||
|
||||
### Steam Input passthrough
|
||||
|
||||
**APK → Settings → Controller input source → Steam Input passthrough** mounts one Steam-generated event device directly into Android with the correct Steam key layout. It does not create another virtual gamepad or use SDL/OpenVR to remap input. The existing Steam Input bridge remains the default; Frame direct input remains available.
|
||||
|
||||
Passthrough chooses the lowest valid Steam controller slot, excluding Framely's own bridge output. It preserves both sticks, analog triggers, buttons and hats. Steam's native node does not supply digital L2/R2: use the bridge if a game needs these buttons or automatic switching between several controllers. The trigger threshold applies only to bridge modes.
|
||||
|
||||
A small Android input guard neutralizes background events and gates game-requested force feedback using the same controller-input and rumble switches. Expiring control leases stop input and feedback if the session helper disappears. Native force feedback goes directly to Steam's device; games must request controller vibration. Bridge-specific strength and duration limits do not apply to passthrough.
|
||||
|
||||
Only the selected device is mounted, not the entire host input directory. No SteamOS or Lepton installation files are changed. If the device node or Steam controller ownership changes, input stops; close and restart that container after reconnecting the controller. Framely never silently remounts another device or restarts a running game. Diagnostics are written to `~/.local/share/framely/apk-manager/logs/gamepad.log` without recording button events.
|
||||
|
||||
|
||||
### Steam Input 直通
|
||||
|
||||
在 **APK → 设置 → 手柄输入来源 → Steam Input 直通** 中选择。仅将 Steam 生成的一只手柄事件设备及正确按键布局挂载进 Android,不创建第二只虚拟手柄,也不通过 SDL/OpenVR 重映射。现有 Steam Input 桥接仍为默认,Frame 直连保持可用。
|
||||
|
||||
直通选择编号最小的有效 Steam 手柄槽位,排除 Framely 自己生成的桥接输出。支持双摇杆、线性扳机、按键及十字键。Steam 原节点没有数字 L2/R2;需要这两个按键或多手柄自动切换的游戏可继续使用桥接。扳机阈值仅对桥接模式生效。
|
||||
|
||||
Android 输入保护层清零后台输入,并使用现有输入及震动开关控制游戏请求的力反馈。控制租约到期后停止输入和震动,避免会话辅助进程退出后继续响应。直通震动直接交给 Steam 原设备;游戏本身必须发出手柄震动请求。桥接专用的强度及持续时间限制不适用于直通。
|
||||
|
||||
只挂载选中的设备,不暴露整个主机输入目录,不修改 SteamOS 或 Lepton 安装文件。设备节点重建或 Steam 手柄归属变化后停止输入;重新连接后需关闭并重启该容器,不会静默切换到其他设备或重启游戏。日志位于 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键事件。
|
||||
|
||||
## Container lifecycle
|
||||
|
||||
The APK page provides two persistent switches. **Stop container after app exits** defaults off: after a confirmed launch, all target app processes must remain absent for 15 seconds before the container stops. Background apps and services keep it alive. **Stop container when closing its window** defaults on: a confirmed system window-close signal stops the associated container (normally within a few seconds). Switching windows, minimizing, and opening the launcher do not count as closing.
|
||||
@@ -139,6 +163,14 @@ On Frame Gamescope `3.16.28-76-ge383171f`, both window and Dock close controls l
|
||||
|
||||
Steam entries show the APK's application name and icon. Framely generates matching library capsules, headers and hero images from the icon, without downloading artwork. The original launcher icon, order and favorites stay intact; the explicit Steam App ID binding excludes duplicate entries from the launcher Steam category. APK updates refresh generated entry metadata.
|
||||
|
||||
Generated artwork includes a 600×900 portrait capsule, 920×430 landscape capsule, 3840×1240 text-free hero, 1280×360 transparent logo and 256×256 icon. Portrait titles are centered; landscape capsules use the icon on the left and the title on the right. The hero reserves space for Steam's separate logo overlay. Long titles are fitted using actual font metrics. Rendering uses Steam's existing CEF Canvas and system fonts; no extra runtime, image service or font download is required.
|
||||
|
||||
The shortcut icon is a separate file, not a fifth Steam custom-artwork type. Steam can crop hero backgrounds with `cover` depending on its window shape; the main motifs stay in the central safe band rather than requiring the whole exported image to be visible. An exact Framely-owned icon accidentally written over a landscape capsule by the first artwork build is automatically repaired; unrelated custom images are not replaced.
|
||||
|
||||
The same OKLab clustering and area/edge-distribution scoring processes every icon; application names and package IDs do not select colors. Main ornaments retain the selected color's hue and use 80% RGB intensity. Icons without a reliable chromatic candidate use a neutral palette. Package IDs seed only the stable decoration layout, so refreshing does not randomly change the design.
|
||||
|
||||
Framely stores generated image-content hashes in its private Devkit entry directory. Updates replace only missing images, unchanged Framely-generated images or exact old icon-only artwork; manually replaced Steam artwork is preserved. Renaming a shortcut refreshes Framely-generated titles during the next Steam synchronization. Steam must be running and its library UI available; otherwise synchronization is deferred.
|
||||
|
||||
The native runtime has a `steamlaunch-…` process/container name, while the APK keeps its existing data directory and Framely identity. No game-data copy, APK reinstall or system compositor modification is required. A private, per-user Lepton entry adapter retains resource storage, selected activity, window orientation, shader cache and optional controller forwarding. It prevents native Lepton from clearing an existing installation after a timestamp change or early exit. Unknown entry-script formats are refused before starting the application.
|
||||
|
||||
Close an already running container before switching its launch path. Framely does not restart a running game to assign Steam ownership. Old shared containers remain discoverable, but only one Steam launch may own their data at a time. Duplicate launches are blocked using an ownership lock that survives the native process launch.
|
||||
@@ -171,6 +203,18 @@ Container list headings show installed application names; the data path identifi
|
||||
|
||||
**APK → Settings → Keep installed APKs in Steam** defaults to on. Turning it off removes idle Framely-owned entries and disables automatic registration. Launching an app whose **Launch through Steam** option is enabled registers its entry on demand; after the native launch process and container finish, the entry and its temporary Devkit copy are removed. Installed APKs, containers, saves and downloaded game data are retained. Running entries are protected. Failed or interrupted starts are cleaned up after a startup grace period, with retries when Steam is unavailable. Turning retention back on resumes automatic registration.
|
||||
|
||||
### Failed installs and damaged containers
|
||||
|
||||
A failed first install rolls back only the container, runtime and installation records created by that attempt, retaining operation logs. A failed update keeps the existing container, application data and any backup already created. If safe cleanup fails, the pending state is retained and both installation and cleanup errors are reported.
|
||||
|
||||
For older, incomplete Framely-owned containers without an Android package database, choose **Clean damaged container** in application details or delete the container from the container page. Explicit confirmation permanently deletes its application data, associated APK cache and Steam entries; historical backups remain. Ownership, storage paths and runtime ownership must pass validation. Unreadable old data is never automatically deleted. After confirming that the data is no longer needed, clean the damaged container and select the APK again. Diagnose the original installation failure using its logs.
|
||||
|
||||
### Uninstalling while keeping the container
|
||||
|
||||
Keeping application data uses Android PackageManager readiness instead of requiring user unlock or external storage mounting. Deleting data still waits for those conditions. Framely boots a stopped container without showing its desktop and repairs only the selected APK registration if needed. It reports startup, PackageManager, unlock, storage and container-exit failures separately. Steam entries are removed after Android confirms successful uninstall; Android success is saved first so a Steam cleanup retry does not uninstall again. Failed startup or uninstall preserves the application record, Steam binding and application data. The error dialog offers Retry and Export logs. A container that cannot boot still requires diagnosis; Framely does not silently delete it or edit Android package databases.
|
||||
|
||||
L2/R2 digital button activation is adjustable in **APK Manager → Settings**, from 1% to 100%, with an 80% default. Both triggers use this threshold with either Steam Input or Frame direct input. Analog trigger travel is unaffected. Saving applies to running bridges immediately; release occurs five percentage points below the selected threshold (at 0% for thresholds below 5%).
|
||||
|
||||
## Per-app graphics compatibility
|
||||
|
||||
Under **Manage app → Advanced settings**, **Framebuffer compatibility fix** is off by default. Enable it only for games that play sound but show a black screen (tested with Rock Kingdom on the current Mesa/Zink driver). Restart the app after changing it. The GLES layer validates framebuffer status after texture attachment, only in the selected application; other apps, system drivers, game files and saved data remain unchanged. Turning it off restores the normal rendering path on the next app launch. It is a targeted workaround, not a general graphics or performance enhancement.
|
||||
@@ -19,12 +19,14 @@ The manager opens a separate large window with five sections: Plugin, APK, Termi
|
||||
|
||||
Enable, disable or favorite plugins in the list. Enabled means available for use; backends normally start on demand. Only plugins declaring `autostart` remain resident after startup. Closing a plugin window generally does not disable its backend. Dependency or conflict problems are explained, and affected plugins are shown before changes.
|
||||
|
||||
Dock windows use SteamVR close controls. Temporary windows may be destroyed when switching views. Individual plugin interfaces and behavior are maintained by their developers.
|
||||
Plugin windows use the same SteamVR move, resize, keyboard and close controls, with or without a Dock icon. Individual plugin interfaces and behavior are maintained by their developers.
|
||||
|
||||
## Settings and diagnostics
|
||||
|
||||
Settings provides language, network panel and proxy configuration. HTTP and GitHub proxies have independent switches; saving an address does not automatically enable it.
|
||||
|
||||
**Automatically try gh-proxy.com when GitHub cannot be reached** is enabled by default and can be disabled separately. Framely first uses the existing direct/manual proxy route, then retries eligible public GitHub network failures once through this third-party service. Authentication-bearing requests, signed URLs, missing resources and failed checksum verification are not routed automatically. A truncated download restarts from zero; package verification remains required. Non-GitHub requests are unchanged.
|
||||
|
||||
Safe mode pauses third-party plugins for troubleshooting; leave it after resolving the problem. Settings → About shows the device version and terms; Settings → Updates manages updates. Revoking consent disables all plugins; accepting again does not automatically re-enable them.
|
||||
|
||||
In Settings → Updates, select the Stable or Testing update channel and check for updates. Choose **Download and install**, then confirm once: Framely downloads and verifies the release and automatically starts installation. Download and verification are displayed as separate stages, with byte progress for each. Download or verification failure stops installation and displays an error. Plugins and data remain; the Framely interface briefly closes and restarts.
|
||||
|
||||
@@ -72,7 +72,7 @@ Both services should be `active`. Open SteamVR Dashboard and find Framely in the
|
||||
|
||||
## Update and rollback
|
||||
|
||||
When an update source is configured, choose **Stable** or **Testing** in **About → Framely update**, then check, download and confirm installation. Stable checks stable releases only; Testing checks prereleases such as Preview, Beta and RC. The channel preference persists on the device. Switching channels clears the previous candidate and download, so check and download again before installation. Alternatively, use the desktop installer or run on Frame:
|
||||
When an update source is configured, choose **Stable** or **Testing** in **About → Framely update**, then check, download and confirm installation. Stable checks stable releases only; Testing checks both stable releases and prereleases such as Preview, Beta and RC, then selects the newest version. The channel preference persists on the device. Switching channels clears the previous candidate and download, so check and download again before installation. Alternatively, use the desktop installer or run on Frame:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- update --version v0.4.2-preview.3
|
||||
|
||||
@@ -6,13 +6,21 @@
|
||||
|
||||
## Manager navigation
|
||||
|
||||
The top switcher contains **Plugin / APK / Terminal / Files / Settings**. Opening the device IP without a fragment starts in Plugin. Each section remembers its last subpage in the browser. Existing `#settings`, `#launcher-settings`, `#about` and `#updates` links continue to work.
|
||||
On desktop, the top switcher contains **Plugin / APK / Terminal / Files / Settings**. Opening the device IP without a fragment starts in Plugin. Each section remembers its last subpage in the browser. Existing `#settings`, `#launcher-settings`, `#about` and `#updates` links continue to work.
|
||||
|
||||
Plugin contains installed plugins, the plugin library, sources and notification settings, including Framely's notifications. Plugin-specific settings remain in each plugin's management dialog. APK keeps its apps, containers and retained-data cleanup. Settings contains General, Launcher, Updates and About.
|
||||
|
||||
## Mobile controls
|
||||
|
||||
On phones, Plugin, APK, Terminal, Files and Settings use a bottom navigation bar. Tap the current page name in the header to choose a subpage. Dialogs open as bottom sheets with touch-sized controls. The layout reserves device safe areas and adjusts to the on-screen keyboard; the bottom navigation hides while the keyboard is open.
|
||||
|
||||
In Files, **Browse folders** opens Quick access and the folder tree in a drawer. Selecting a folder closes the drawer. **Create and upload** and **Filters and view** open task sheets. Tap **Select**, select files, then use **Actions** in the selection bar for copying, moving, downloading or deleting. No right-click is needed. The upload queue continues while browsing or changing sections.
|
||||
|
||||
Terminal provides Ctrl+C, Esc, Tab and arrow buttons above the keyboard. Ctrl+C interrupts the running program. **Paste** uses the browser clipboard when permitted; otherwise it opens a text field for the phone's native paste action and requires **Send to terminal**. The bar disables input while disconnected or read-only.
|
||||
|
||||
## Interaction feedback
|
||||
|
||||
In **Settings → General → Interaction feedback**, Haptic feedback and Sound feedback are enabled by default and saved across restarts. Haptic feedback controls laser hover and launcher gaze-focus vibration across Framely and plugin pages; it does not change APK game rumble. Sound feedback uses Steam’s installed navigation and activation sounds for focus and clicks. It requires the local Steam sound files and audio runtime; no sound files are bundled or downloaded.
|
||||
In **Settings → General → Interaction feedback**, Haptic feedback is enabled by default; Sound feedback is disabled by default. Both preferences are saved across restarts. Haptic feedback controls laser hover and launcher gaze-focus vibration across Framely and plugin pages; it does not change APK game rumble. Sound feedback uses Steam’s installed keyboard sound at reduced volume for focus and clicks. It requires the local Steam sound files and audio runtime; no sound files are bundled or downloaded.
|
||||
|
||||
## Terminal
|
||||
|
||||
@@ -34,11 +42,11 @@ Use the address bar, breadcrumbs, parent button or expandable folder tree. Quick
|
||||
|
||||
Drag the divider between the sidebar and file list to resize it; the width is remembered in this browser and limited automatically on narrow windows. Quick access stays in a fixed-height upper section. The folder tree fills the remaining sidebar height; both lists scroll independently, with their headings kept visible. Opening a folder in the file list or address bar expands the tree to its location and highlights it.
|
||||
|
||||
The file list provides hidden-file visibility, sorting, checkboxes for multiple selection, current-folder filtering and cancellable recursive filename search. Recursive search does not follow directory links. Lists are paged in groups of 200; recursive results are limited to 5,000 matches and 500,000 visited entries. Media contents are never scanned to create thumbnails.
|
||||
The file list provides hidden-file visibility, sorting, checkboxes for multiple selection, current-folder filtering and cancellable recursive filename search. Recursive search does not follow directory links. Lists are paged in groups of 200; recursive results are limited to 5,000 matches and 500,000 visited entries. Media contents are not scanned in the background.
|
||||
|
||||
**Actions** provides copy, cut, paste, rename, delete, properties, permission editing, compression, extraction and opening files. Right-click a file or folder to open a menu at the pointer; right-clicking a selected item preserves the multi-selection. Right-click the empty list area for new file/folder, paste, upload and refresh actions. Escape or clicking elsewhere closes the menu. Symbolic links have an arrow marker and their target in Properties. Copy preserves links; deleting a link does not delete its target. Special filesystem objects cannot be copied or archived.
|
||||
|
||||
Copy, move, upload and extraction let you skip conflicts, keep both names or confirm replacement for the batch. Replacement of a same-name folder replaces that whole folder; it does not merge its contents. Long tasks show actual bytes or completed entries, can be cancelled, and report individual failures. Completed items remain completed when a later item fails or the task is cancelled. Switching manager sections keeps active file work and editor state in the current page.
|
||||
Copy, move and extraction let you skip conflicts, keep both names or confirm replacement for the batch. Replacement of a same-name folder replaces that whole folder; it does not merge its contents. Long tasks show actual bytes or completed entries, can be cancelled, and report individual failures. Completed items remain completed when a later item fails or the task is cancelled. Switching manager sections keeps active file work and editor state in the current page.
|
||||
|
||||
## Recycle bin
|
||||
|
||||
@@ -48,12 +56,30 @@ The recycle bin supports restoring with an unused name or keeping both copies, p
|
||||
|
||||
## Transfer and edit
|
||||
|
||||
Upload multiple files or a folder from your browser. Transfers use 1 MiB chunks, byte progress and private temporary files; only a completed upload receives its final filename. Cancelled uploads are removed and hourly maintenance removes transfers idle for at least an hour. A stopped service may leave clearly named `.framely-upload-*` temporary files, never a falsely complete final file. Uploads are limited to 64 GiB per file and 64 simultaneous transfer records.
|
||||
Drag files, folders or a mixed selection from your computer onto a folder in the list, icon grid, sidebar or breadcrumbs. Drop onto empty file-list space to upload into the current folder. The highlighted target shows its path. Files, the recycle-bin content and preview overlays are not upload destinations.
|
||||
|
||||
Before any writing, a confirmation shows the canonical destination path, counts and total size. Choose **Skip**, **Overwrite** or **Keep both** (default). Existing folders are merged; the policy applies only to same-name files and other destination files are retained. Skip is checked before transfer. Overwrite replaces the original only after successful completion; keep-both results show the new filename. Type conflicts fail individually. Destination changes and nested symbolic-link traversal are rejected.
|
||||
|
||||
Click **Upload queue** to open a compact floating panel without reducing the file-list area. Each batch shows uploaded files and total files; folders are excluded and skipped files are not counted as uploaded. The queue groups selections by destination and provides individual/batch cancellation, retry of failed tasks and removal of finished records. **Simultaneous file uploads** defaults to 3, accepts 1–8, and is saved for the device user independently of view and folder sorting. Increasing it starts more work immediately; reducing it lets active files finish. Each file uses at most two chunk requests; the queue has at most eight chunk requests in flight. Browsing and changing manager sections do not stop uploads or change their destination.
|
||||
|
||||
Folder drag-and-drop preserves empty folders. The Upload folder button uses the browser directory API when available; its legacy file-input fallback can only return files, so use drag-and-drop for empty folders. Large directories are enumerated asynchronously; folder readers are drained until empty. A selection may contain at most 100,000 entries.
|
||||
|
||||
The queue lasts only for this browser page. Refreshing or closing warns about unfinished work and attempts cancellation; server inactivity cleanup is the fallback. Completed files are retained. Failed transfers retry from the beginning; there is no cross-refresh resume.
|
||||
|
||||
Upload multiple files or a folder from your browser. Transfers use 16 MiB chunks, byte progress and private temporary files; only a completed upload receives its final filename. Cancelled uploads are removed and hourly maintenance removes transfers idle for at least an hour. A stopped service may leave clearly named `.framely-upload-*` temporary files, never a falsely complete final file. Uploads are limited to 64 GiB per file and 64 simultaneous transfer records.
|
||||
|
||||
Download a single file directly, or select multiple files/folders to download a ZIP. Download links expire after an hour. Large files stream without loading the entire file into memory; single-byte ranges support video seeking and resuming downloads.
|
||||
|
||||
Opening an ordinary file attempts the UTF-8 editor, limited to 5 MiB. Invalid UTF-8, binary data and larger files cannot be edited; use Download instead. Saving checks a content revision. External changes require reloading the file or explicitly confirming overwrite. Unsaved changes require confirmation before closing the editor.
|
||||
|
||||
**Edit as text** is available in the context menu and Actions for every non-directory entry, including images, archives and unknown extensions. It bypasses media preview. Binary data and invalid UTF-8 can be viewed as read-only text (replacement characters may appear); they cannot be saved through the editor. Devices, pipes and files over 5 MiB are rejected. Nothing is executed or converted on disk.
|
||||
|
||||
The offline CodeMirror editor loads only when needed. It provides line numbers, highlighting for JavaScript/TypeScript, JSON, HTML, CSS, Python and Rust, indentation, folding, find/replace, undo/redo, word wrap and Ctrl/Cmd+S. Other formats open as plain text. There are no completion popups, diagnostics, language servers or CDN requests. Headset keyboard input edits the document at the current selection. If loading fails, the ordinary textarea remains available.
|
||||
|
||||
The editor occupies the file workspace, with a document tab and parent path, a compact toolbar, a manual language-mode selector and live line/column status. Saving and closing stay at the top. The status bar reports encoding and line endings; consistent CRLF and CR files retain their line separators when edited. Opening a confirmation dialog preserves the editor's undo history and temporarily blocks hidden input.
|
||||
|
||||
**Properties** reads fresh metadata: path, type, byte size, allocated disk space, owner/group names and numeric IDs, modification/access/metadata-change/creation times (where supported), inode, device and hard-link count. Link properties describe the link itself and show its target and resolved path. Directory size describes the directory entry, not a recursive total. Owned non-symlink entries support a read/write/execute matrix synchronized with a validated three-digit octal mode. Existing special bits are preserved; other users' permissions are not elevated. Saving checks device/inode identity to reject replaced entries.
|
||||
|
||||
## Archives and media
|
||||
|
||||
Create ZIP, TAR or TAR.GZ archives; the output filename selects the format. Choose a destination and conflict behavior when extracting. Archive operations reject absolute paths, parent traversal, symbolic/hard links and special files; extracted contents are never executed. Archive limits are 50,000 entries and 64 GiB expanded data. Cancelling removes staging files and leaves already committed items intact.
|
||||
@@ -62,8 +88,14 @@ Select an image or video to preview it. PNG, JPEG, GIF, WebP, AVIF, BMP, MP4, We
|
||||
|
||||
## Access and privacy
|
||||
|
||||
Terminal, file and transfer routes use the existing authenticated manager connection and port. Mutating routes and the terminal WebSocket require an exact matching Origin; plugin sandboxes cannot use them. The service runs as the configured Steam user, independently of the root daemon. Preview/download content is read only after explicit selection. Test media are generated fixtures; Framely does not automatically read or upload Arcturus recordings.
|
||||
Terminal, file and transfer routes use the existing authenticated manager connection and port. Mutating routes and the terminal WebSocket require an exact matching Origin; plugin sandboxes cannot use them. The service runs as the configured Steam user, independently of the root daemon. Full previews and downloads read content after explicit selection; icon views read only visible media to generate lazy thumbnails. There is no background directory media scan. Test media are generated fixtures; development and validation do not read or upload private Arcturus recordings.
|
||||
|
||||
## Diagnostic export
|
||||
|
||||
**Settings → General → Export logs** and the installer’s **Export logs** use the same collector. The archive includes recent APK operation logs, Lepton’s saved Android logs, and a limited snapshot of running containers: Android boot, user unlock, storage mounting, PackageManager readiness and recent main/system/crash logs. Rootless containers are inspected as the configured Steam session user. Stopped containers are not started. App state is summarized without tokens or icons; APK files, saves, media and raw memory dumps are excluded. File and archive limits retain recent output; missing sources, truncation and command timeouts are listed in `report.json`. Logs can contain application information; review before sharing.
|
||||
|
||||
File sorting (name, size or modification time, including ascending/descending order) is remembered separately for each folder. The **View** menu switches between a list and small, medium or large icons; this choice applies to all folders. Preferences are saved on Frame and restored when reopening the panel in a browser or headset. Icon views lazily generate thumbnails for visible images and videos using browser decoding, with at most two concurrent jobs. Videos use a frame around one second. Thumbnails are cached in bounded memory while the page remains open and refreshed when file metadata changes. Scrolling out of view cancels pending work; unsupported formats or decoding failures retain the file-type icon. No FFmpeg or whole-folder media scan is used.
|
||||
|
||||
Clicking an image or video opens a full-panel viewer. Use the wheel or zoom buttons to zoom, drag the content to pan, and use Previous/Next or the arrow keys to browse the image/video files in the current displayed list. Fit to window resets the view; Esc closes it. Video playback controls remain available. If browser decoding fails, download the file from the viewer. UTF-8 text up to 5 MiB still opens in the editor; binary files, unsupported encodings and larger files download directly when clicked.
|
||||
|
||||
The viewer controls are grouped in a floating capsule centered at the bottom, with the filename below. Clicking blank space closes the viewer; clicking media or controls and dragging to pan do not.
|
||||
@@ -76,7 +76,7 @@
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `entry` / `title` | 必填;入口需在 payload;标题非空且最多 120 字节 |
|
||||
| `dockIcon` | 默认 false,true 为独立 Dock 窗口 |
|
||||
| `dockIcon` | 默认 false,仅控制是否显示 Dock 图标;两种窗口均使用相同的 SteamVR 窗口控件 |
|
||||
| `width` / `height` | 默认 1600×900;宽 640–2560,高 360–1440 |
|
||||
| `widthMeters` | 默认 3.0;有效值 0.4–4.0 米。源码可读取 null,但打包会省略,再读取采用默认 3.0 |
|
||||
| `localWeb` | 默认 false;特殊本机网页窗口由后端 `window.get` 提供 localhost URL |
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
## GitHub Release 默认地址
|
||||
|
||||
现在也支持通过 GitHub topic 自动发现,无需目录文件。`release.json` 格式、生成命令和旧 Release 兼容规则见 [GitHub 插件自动发现](../github-plugin-discovery.md)。
|
||||
|
||||
公开 GitHub 插件仓库的**源码 `manifest.json` 可以省略 `downloadUrl`**。社区数据库从登记的仓库地址、版本和插件 ID 自动生成:
|
||||
|
||||
```text
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# GitHub 插件自动发现与 Release 元数据
|
||||
|
||||
[English](../github-plugin-discovery.md)
|
||||
|
||||
Framely 在现有插件库中搜索带 `framely-plugin` topic 的公开 GitHub 仓库,排除 fork 和 archived 仓库,再读取 Releases。无需 npm 或额外目录文件。现有社区源、自建源继续使用;同 ID 合并为一个列表项,优先推荐语义版本最高的兼容版本。来源、正式版/预览版和历史版本可在原界面选择。同 ID、版本和包哈希相同的条目合并;同版本不同哈希仍保留,版本选项显示短哈希。更换来源继续经过安装确认。
|
||||
|
||||
## 新 Release:release.json
|
||||
|
||||
每个 Release 上传一个 `release.json` 附件,可描述多个插件。不要放在仓库默认分支来代替 Release 附件。
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"plugins": [{
|
||||
"id": "example.plugin",
|
||||
"name": "Example Plugin",
|
||||
"version": "1.2.0",
|
||||
"apiVersion": 1,
|
||||
"author": "Developer",
|
||||
"description": "插件简介",
|
||||
"engines": {"framely": ">=0.8.3"},
|
||||
"asset": "example.plugin-1.2.0.framely",
|
||||
"size": 123456,
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"runAs": "steamos",
|
||||
"icon": "example.plugin-1.2.0-icon.png",
|
||||
"screenshots": [],
|
||||
"tags": ["工具"],
|
||||
"details": "详细介绍",
|
||||
"changelog": "更新记录",
|
||||
"homepage": "https://github.com/example/plugin",
|
||||
"dependencies": {},
|
||||
"optionalDependencies": {},
|
||||
"conflicts": {},
|
||||
"exclusiveResources": []
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
必填:`id`、`name`、`version`(SemVer)、`apiVersion`、`author`、`description`、`asset`、`size`(整包字节数)、`sha256`(整包 64 位十六进制 SHA256)、`runAs`。有后端或生命周期脚本时,`runAs` 是实际运行用户;纯界面插件为 `null`,不能省略。其他字段遵循现有插件目录格式;图片可以是同一 Release 的附件名或固定 HTTPS 地址。`authorUrl`、`documentationUrl` 也支持现有格式。
|
||||
|
||||
`asset` 必须对应同一 Release 的 `.framely` 附件;大小必须与 GitHub 附件资料一致。GitHub 提供 tag、预览版标记及附件下载地址,JSON 无需重复 `downloadUrl`。有 GitHub asset digest/`SHA256SUMS` 时必须与 JSON 一致。声明不合法、缺附件或哈希冲突的 Release 不会作为可安装版本,也不会退回旧格式掩盖错误。
|
||||
|
||||
## 自动生成及上传
|
||||
|
||||
先完成正常构建、打包和验证,把最终 `.framely` 包放入一个专用发行目录,再运行新版 Framely:
|
||||
|
||||
```bash
|
||||
framely release-json --packages ./release --output ./release/release.json
|
||||
```
|
||||
|
||||
在 Framely 源码目录也可用 `cargo run --locked -- release-json ...`。生成器验证包内文件哈希,读取最终包内清单,自动计算整包 SHA256、字节数、运行用户及依赖。没有 `publish.icon`/非空 `publish.screenshots` 外链时,自动导出包内图标和截图到同一发行目录,JSON 使用这些附件名。拒绝覆盖内容不同的同名发行图片。每个插件 ID 在一次发行中只出现一次。
|
||||
|
||||
将生成步骤加入自己的发布 CI,并上传发行目录中的全部包、JSON 和图片,例如已有 GitHub CLI 发布流程中:
|
||||
|
||||
```bash
|
||||
framely release-json --packages ./release --output ./release/release.json
|
||||
gh release upload "$TAG" ./release/*
|
||||
```
|
||||
|
||||
不要在生成 JSON 后重新打包,也不要覆盖已发布的同版本附件。想加入自动发现,由仓库维护者添加 `framely-plugin` topic。本次 Framely 实现不会替维护者修改外部插件仓库。
|
||||
|
||||
## 没有 release.json 的旧 Release
|
||||
|
||||
只有完全没有该附件时才采用兼容路径:
|
||||
|
||||
1. 将 Release 的 tag 解析为提交 SHA,读取该 SHA 的根目录 `manifest.json`;不读取默认分支。
|
||||
2. tag 为 `v<version>` 或 `<version>`,且必须与清单版本完全一致。
|
||||
3. 清单有 `downloadUrl` 时必须精确对应本 Release 的 `.framely` 附件;否则匹配 `<id>-<version>.framely`。
|
||||
4. 使用 GitHub asset digest 或 Release 的 `SHA256SUMS`;二者都存在必须一致。均缺失则不允许安装,不猜测哈希。可选 `downloadSha256` 也必须一致。
|
||||
5. 有 `publish` 图片外链时沿用;否则图标使用固定提交 SHA 的 Raw 地址。
|
||||
|
||||
旧插件和现有 `catalog.json`/`versions.json` 来源无需迁移,旧客户端仍沿用原有来源,忽略新 Release 附件。`release.json` 只是增加一种发现方式,不替换现有安装包格式和兼容性检查。
|
||||
|
||||
## 缓存、安装及失败处理
|
||||
|
||||
搜索和 Releases 分页读取,最多各 10 页,每页 100 条;Release 先全部读取再按 SemVer 排序,不依赖 API 返回顺序。达到搜索上限会提示;超过 Release 历史上限会报错,避免错误推荐未完整读取的版本。元数据缓存 10 分钟,携带 ETag,手动刷新重新验证;网络失败时可显示旧缓存并提示,证书、解析和普通 404 等错误不隐藏。所有 GitHub 请求最多同时 6 个,支持取消,复用现有 HTTP/手动 GitHub 代理和自动代理策略。浏览目录不下载插件包。
|
||||
|
||||
首次选择 GitHub 插件时来源是规范仓库地址 `https://github.com/<owner>/<repo>`,来源 ID 根据仓库名稳定生成,安装确认成功才写入配置。后续更新、依赖和历史版本从该仓库 Releases 读取;禁用来源不会被自动发现重新启用。安装时再次核对整包哈希及包内 ID、版本、API、Framely 要求、实际运行用户、依赖与冲突;权限和来源切换使用现有确认流程。部分仓库失败不会隐藏其他来源已成功加载的插件。
|
||||
@@ -79,7 +79,7 @@ framely catalog --name 'My plugins' --base-url https://example.org/plugins --pac
|
||||
|
||||
包中包含 manifest.json 和文件载荷,files 记录每个载荷的 SHA256;不生成公钥或签名。只支持 manifest.json 与清单中声明的载荷文件,拒绝未声明的附加文件。
|
||||
|
||||
源需静态托管 catalog.json 及相对路径下的 plugins/<ID>/versions.json;插件包可放在作者自己的 GitHub Release。在插件源管理中添加目录完整 URL;当前代码首次初始化加入社区 stable/testing 来源,实际内容以服务器为准。目录条目有版本、下载 URL、SHA256。每个源独立显示,插件记录所选来源;另一个源同 ID 不会静默替换。HTTP 必须用户明确允许;目录和包支持最多五次重定向,HTTPS 不允许降级为 HTTP。
|
||||
传统源可静态托管 catalog.json 及相对路径下的 plugins/<ID>/versions.json;插件包可放在作者自己的 GitHub Release。在插件源管理中添加目录完整 URL;当前代码首次初始化加入社区 stable/testing 来源,实际内容以服务器为准。目录条目有版本、下载 URL、SHA256。插件库也支持 [GitHub 自动发现及 release.json](github-plugin-discovery.md),同 ID 合并展示并按兼容语义版本推荐,历史版本保留不同来源及哈希。插件记录所选来源,切换来源仍经过安装确认。HTTP 必须用户明确允许;目录和包支持最多五次重定向,HTTPS 不允许降级为 HTTP。
|
||||
|
||||
### 悬停反馈
|
||||
|
||||
|
||||
@@ -42,14 +42,14 @@ framely call system.source.save '{"source":{"url":"https://example.org/framely-r
|
||||
|
||||
每次构建生成不含 CEF 的本体包 `framely-<版本及构建号>-linux-arm64.tar.gz`、完整离线包 `framely-<版本及构建号>-offline-linux-arm64.tar.gz`、独立运行库 `framely-cef-<运行库标识>-linux-arm64.tar.gz`。更新清单 `framely-release.json` 固定指向不含 CEF 的本体包;首次安装固定选择完整离线包。`framely-cef.json` 提供独立 CEF 的 HTTPS 下载地址、大小及 SHA256。
|
||||
|
||||
CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时复用;旧安装中相同的运行库自动迁移。所需运行库缺失或 CEF 版本更换时单独下载并校验,各发行的运行库保留用于回滚。完整包可离线提供 CEF。参见[发行包与运行库恢复](user-guide/installation.md#发行包与-cef)。
|
||||
CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时复用;旧安装中相同的运行库自动迁移。所需运行库缺失或 CEF 版本更换时单独下载并校验,各发行的运行库保留用于回滚。完整包可离线提供 CEF。参见[发行包与运行库恢复](user-guide/installation.md#发行包与-cef)。可选的 Quest 转换工具另打 `framely-q2f-*` 包,发行包声明经过验证的确切版本;只有用户选择 Quest 转换时才校验缓存并按需下载,Framely 本体更新不自动重转已安装 APK。
|
||||
|
||||
## GitHub Actions 发布
|
||||
|
||||
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
|
||||
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.12`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.8`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.8.3-preview.3`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.3`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
|
||||
|
||||
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
|
||||
|
||||
@@ -59,19 +59,19 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
|
||||
|
||||
```bash
|
||||
# 提交并推送代码后,选择需要发布的产品
|
||||
git tag v0.4.2-preview.12
|
||||
git push origin v0.4.2-preview.12
|
||||
git tag v0.8.3-preview.3
|
||||
git push origin v0.8.3-preview.3
|
||||
|
||||
# 独立发布安装器,不触发 Framely 构建
|
||||
git tag installer-v0.4.1-preview.8
|
||||
git push origin installer-v0.4.1-preview.8
|
||||
git tag installer-v0.4.3
|
||||
git push origin installer-v0.4.3
|
||||
```
|
||||
|
||||
带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。
|
||||
|
||||
只有标签触发会发布。各工作流在自己的构建全部成功后创建草稿 Release,上传本产品产物及外部 `SHA256SUMS`,最后公开草稿。Framely Release 额外包含 `bootstrap.py`、入口脚本和更新清单;安装器 Release 只包含五个平台的安装器包和校验文件。失败的草稿不作为最新稳定版提供下载。手动触发用于验证,仅保留 Actions artifacts。
|
||||
|
||||
Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为不含 CEF 的更新包,`framely-*-offline-linux-arm64.tar.gz` 为首次安装用的完整离线包,`framely-cef-*` 为独立运行库,`framely-cef.json` 提供运行库下载信息;`framely-release.json` 为内置更新功能的版本清单;`install.sh` 是命令行入口,调用 `bootstrap.py` 完成下载、校验和维护;`SHA256SUMS` 为附件校验清单。桌面安装器只需自动下载运行包和校验清单,用户无需手动下载脚本。安装器的五个平台包发布在独立的安装器 Release 中。
|
||||
Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为不含 CEF 的更新包,`framely-*-offline-linux-arm64.tar.gz` 为首次安装用的完整离线包,`framely-cef-*` 为独立运行库,`framely-cef.json` 提供运行库下载信息;`framely-q2f-*` 为可选 Quest 转换工具包,`framely-q2f.json` 提供下载信息;`framely-release.json` 为内置更新功能的版本清单;`install.sh` 是命令行入口,调用 `bootstrap.py` 完成下载、校验和维护;`SHA256SUMS` 为附件校验清单。桌面安装器只需自动下载运行包和校验清单,用户无需手动下载脚本。安装器的五个平台包发布在独立的安装器 Release 中。
|
||||
|
||||
外部 `SHA256SUMS` 包含压缩包哈希,校验传输内容;包内同名文件校验解压后的载荷。哈希不能独立证明作者身份,在线安装依赖指定 GitHub 仓库与 HTTPS;本地安装依赖用户提供的可信包与校验文件。下载遵循 HTTPS 重定向,不允许降级。
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# APK 管理
|
||||
|
||||
在 **APK → 应用 → 管理** 中,可以为每个应用设置 **自定义 Steam 名称**。留空恢复应用原名;更新 APK 后保留,并应用于已有和以后创建的 Steam 入口。Steam 暂不可用时,会在就绪后同步。此设置不改变 Framely 中的应用名称、包名、容器或存档。
|
||||
|
||||
全局手柄与容器生命周期选项位于 **APK → 设置**,不再占用应用列表。每个应用的显示与启动选项仍保留在对应应用的管理弹窗内。
|
||||
|
||||
[English](../../user-guide/apk-manager.md)
|
||||
@@ -26,6 +28,8 @@ APK 管理用于安装和管理 Steam Frame 上的 Lepton 侧载应用,本地
|
||||
|
||||
点击“安装 APK”,选择单个 `.apk`,核对名称、包名、版本、最低 Android SDK 和目标容器。在 Frame 内选择本机 APK 会直接在设备上校验,不经过浏览器上传;确认阶段保留独立本机副本,不修改原文件。网络上传暂存于 Frame 持久化文件系统,避免占满较小的 `/tmp`。上传显示真实字节进度;后续显示处理阶段,不使用假百分比。
|
||||
|
||||
Quest 游戏可在安装审核中点击“转换此 APK”。Framely 首次使用时按发行包声明的标识下载独立的 `framely-q2f-*` 工具包,校验完整归档和内部文件,之后复用已验证缓存;普通 APK 安装无需下载。转换调用 OVR Port,原始 APK 不修改,转换结果重新读取并再次审核后才安装。转换会重新签名;原版 APK 已安装时可能出现签名不匹配,Framely 不会自动卸载应用或清除存档。工具包和每个包名的签名密钥保存在配置的 APK 存储目录,随该目录迁移;转换临时文件也使用该位置,不使用 `/tmp`。工具包更新只影响之后发起的转换,不自动重新转换已安装游戏。转换结果能否在 Frame 运行仍取决于游戏。
|
||||
|
||||
在 Frame 内,两个管理面板的文件选择入口统一使用 Framely 内置文件选择器,包括 APK、插件包和语言文件。默认打开下载目录,记住上次选择的目录;左侧为按需加载的文件夹树,覆盖主目录、临时目录和已挂载存储,支持逐级展开、收起和当前路径定位,提供目录导航、手动输入路径、搜索、隐藏文件和文件类型筛选。选中文件后确认;B/Escape 只取消选择器,不关闭管理面板。外部浏览器继续使用系统文件选择器。启动台右侧的“安装APK”按钮打开 APK 管理面板。
|
||||
|
||||
共享容器中的 APK 使用各自独立的 Android 安装目录。容器重启会保留 Android 外部存储中的已下载资源,包括未完成的下载片段和更新进度记录。无法识别的 Lepton 存储初始化脚本会在执行前拒绝,避免丢弃文件;游戏是否继续下载仍由自身更新器决定。旧版单 APK 挂载会在保存的安装包可用时恢复,旧 OBB 资源链接会在替换安装前迁移,并保留原目标。这只能防止后续丢失,旧版启动流程已经删除的下载资源无法仅靠 APK 还原。
|
||||
@@ -95,7 +99,7 @@ Android 清除已卸载包的保留状态时可能需要原签名 APK。Framely
|
||||
|
||||
## 格式范围
|
||||
|
||||
首版支持单个 APK。不包含分包、XAPK/APKM、OBB 导入、Google Play 安装及自动创建 Steam 快捷方式;不支持的包格式会明确拒绝。
|
||||
首版支持单个 APK。Quest 兼容转换同样只接受单个 APK;游戏自行下载的 OBB 资源继续使用持久化容器存储。不包含分包、XAPK/APKM、手动 OBB 导入、Google Play 安装及自动创建 Steam 快捷方式;不支持的包格式会明确拒绝。
|
||||
|
||||
### 平面窗口方向
|
||||
|
||||
@@ -129,6 +133,14 @@ APK 安装、更新、其他修改与启动检查期间暂停监测。容器重
|
||||
|
||||
Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的库封面、横幅和背景图,不联网下载图片。启动台原有图标、排序和收藏不变,通过明确的 Steam App ID 绑定过滤 Steam 分类中的重复入口。APK 更新会刷新生成的入口信息。
|
||||
|
||||
自动生成 600×900 竖版封面、920×430 横版封面、3840×1240 无文字详情背景、1280×360 透明 Logo 和 256×256 图标。竖版标题居中,横版采用左图标、右名称,详情背景由 Steam 单独叠加 Logo。较长名称按实际字体宽度缩放。使用 Steam 现有 CEF Canvas 和系统字体,无需额外安装运行时、调用图片服务或下载字体。
|
||||
|
||||
快捷方式图标使用独立文件,不作为第五种 Steam 自定义封面类型写入。Steam 会按窗口比例以 `cover` 裁切详情背景;主要装饰放在中央安全带,不依赖整张导出图片都可见。第一版封面功能误写到横版位置的 Framely 图标,会在内容摘要完全匹配时自动修复;无关的手动图片不覆盖。
|
||||
|
||||
所有图标使用同一套 OKLab 聚类及面积、外圈分布评分,不按应用名称或包名指定颜色。主装饰保留选中色相,RGB 强度统一为原色的 80%;无可靠彩色候选时使用中性配色。包名仅用于生成稳定的装饰布局,刷新不会随机改变画面。
|
||||
|
||||
Framely 在自己的私有 Devkit 入口目录记录生成图片的内容摘要。后续只更新缺失图片、仍与记录一致的 Framely 生成图片,或内容完全匹配的旧版居中图标封面;手动替换的 Steam 图片保留。修改 Steam 名称后,会在下一次 Steam 同步时刷新 Framely 生成的标题。Steam 及其库界面未就绪时延后同步。
|
||||
|
||||
原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。
|
||||
|
||||
切换启动路径之前,需要先关闭已经运行的容器;Framely 不会为了绑定 Steam 重启游戏。旧共享容器仍可识别,但同时只允许一个 Steam 启动占用其数据。跨进程保留的占用锁阻止重复启动及共享数据的并发启动。
|
||||
@@ -145,6 +157,28 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
|
||||
|
||||
**APK → 设置 → 保留安装的 APK 到 Steam** 默认开启。关闭后清理空闲的 Framely 自有入口,不再自动注册。应用启用 **通过 Steam 启动** 时,会在启动时临时注册入口;原生启动进程和容器退出后删除入口及临时 Devkit 副本。已安装 APK、容器、存档和下载的游戏数据都会保留,运行中的入口不会删除。失败或中断的启动在启动宽限期后清理;Steam 不可用时稍后重试。重新开启保留设置会恢复自动注册。
|
||||
|
||||
### 安装失败与损坏容器
|
||||
|
||||
首次安装失败时,Framely 仅回滚本次新建的容器、运行时和安装记录,保留操作日志。更新已有容器失败时不会自动删除容器或存档,已生成的备份也会保留。安全校验或清理失败会保留待处理状态,并报告安装和清理两项错误。
|
||||
|
||||
旧版本遗留的容器若缺少安卓数据库,可在应用管理详情中选择 **清理损坏容器**,或在容器页删除该容器。此操作仍需确认:容器内可能存在的应用数据、关联 APK 缓存和 Steam 入口会永久删除,历史备份保留。只允许对明确由 Framely 管理且路径和运行时归属校验通过的损坏容器执行;不会因读取失败就自动删除旧数据。确认不需要容器内数据后清理,再重新选择 APK 安装。原始安装失败的具体原因仍应通过日志诊断。
|
||||
|
||||
### 卸载并保留容器
|
||||
|
||||
保留应用数据的卸载使用 Android 包管理器就绪检查,不再强制等待用户解锁或外部存储挂载。删除数据仍等待这些条件。Framely 在后台启动已停止的容器,不显示容器桌面;需要时只修复目标 APK 的登记。启动、包管理器、解锁、存储和容器退出分别报错。Android 确认卸载成功后才移除 Steam 入口,先保存 Android 卸载结果,重试 Steam 清理不会重复卸载。启动或卸载失败保留应用记录、Steam 绑定和应用数据。错误弹窗提供重试与导出日志。无法启动的容器仍需诊断,不会自动删除容器或直接修改 Android 包数据库。
|
||||
|
||||
在“APK 管理 → 设置”中可调节 L2/R2 按键触发行程,范围 1%~100%,默认 80%。两个扳机共用该设置,Steam Input 和 Frame 直连均适用,不改变模拟轴的完整行程。保存后立即应用到正在运行的转发进程;松开阈值比按下低 5 个百分点,设置低于 5% 时在归零后松开。
|
||||
|
||||
## 单应用图形兼容
|
||||
|
||||
在**管理应用 → 高级设置**中,“帧缓冲兼容修复”默认关闭。仅为有声音但黑屏的游戏开启(已在当前 Mesa/Zink 驱动下验证洛克王国),修改后需重启应用。GLES 修复层只在所选应用中,于绑定纹理后检查帧缓冲状态,不改变其他应用、系统驱动、游戏文件或存档。关闭后,下次启动恢复正常渲染路径。这是针对特定问题的兼容措施,并非通用画质或性能优化。
|
||||
|
||||
### Steam Input 直通
|
||||
|
||||
在 **APK → 设置 → 手柄输入来源 → Steam Input 直通** 中选择。仅将 Steam 生成的一只手柄事件设备及正确按键布局挂载进 Android,不创建第二只虚拟手柄,也不通过 SDL/OpenVR 重映射。现有 Steam Input 桥接仍为默认,Frame 直连保持可用。
|
||||
|
||||
直通选择编号最小的有效 Steam 手柄槽位,排除 Framely 自己生成的桥接输出。支持双摇杆、线性扳机、按键及十字键。Steam 原节点没有数字 L2/R2;需要这两个按键或多手柄自动切换的游戏可继续使用桥接。扳机阈值仅对桥接模式生效。
|
||||
|
||||
Android 输入保护层清零后台输入,并使用现有输入及震动开关控制游戏请求的力反馈。控制租约到期后停止输入和震动,避免会话辅助进程退出后继续响应。直通震动直接交给 Steam 原设备;游戏本身必须发出手柄震动请求。桥接专用的强度及持续时间限制不适用于直通。
|
||||
|
||||
只挂载选中的设备,不暴露整个主机输入目录,不修改 SteamOS 或 Lepton 安装文件。设备节点重建或 Steam 手柄归属变化后停止输入;重新连接后需关闭并重启该容器,不会静默切换到其他设备或重启游戏。日志位于 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键事件。
|
||||
@@ -19,12 +19,14 @@
|
||||
|
||||
在插件列表启用、停用或收藏插件。启用表示允许使用;默认后端按需启动,只有声明 `autostart` 的插件会常驻。关闭插件窗口通常不会停用后台功能。依赖不满足或有冲突时,界面会给出原因,并在变更前显示受影响插件。
|
||||
|
||||
带 Dock 图标的独立窗口由 SteamVR 提供关闭控件;临时窗口可能在切换界面时销毁。不同插件的 UI 和功能由各自开发者决定。
|
||||
无论是否带 Dock 图标,插件窗口都使用相同的 SteamVR 移动、缩放、键盘和关闭控件。不同插件的 UI 和功能由各自开发者决定。
|
||||
|
||||
## 设置与诊断
|
||||
|
||||
设置中可修改语言、网络面板及代理。HTTP 代理和 GitHub 代理各有开关,保存地址不会自动启用代理。
|
||||
|
||||
**GitHub 连接失败时自动使用 gh-proxy.com** 默认开启,可单独关闭。先使用原有直连或手动代理,符合条件的公开 GitHub 网络失败才通过第三方服务重试一次。带认证信息或签名参数的请求、资源不存在以及校验失败不自动代理;下载中断后从零重新下载,不混合两条线路的内容,仍须通过原有校验。非 GitHub 请求不受影响。
|
||||
|
||||
“安全模式”暂停第三方插件,适合出现故障时检查;排查后再退出安全模式。在“设置 → 关于”查看本体版本和协议,在“设置 → 更新”检查更新。撤回协议会停用全部插件,再次同意后需要手动启用。
|
||||
|
||||
在“设置 → 更新”选择正式版或测试版渠道并检查更新,点击“下载并安装”并确认一次:Framely 下载、校验发行包后自动开始安装。下载和校验分别显示阶段提示与实际字节进度。下载或校验失败时停止安装并显示原因。插件和数据保留,Framely 界面会暂时关闭并重新启动。
|
||||
|
||||
@@ -72,7 +72,7 @@ sudo /var/lib/framely/current/bin/framely status
|
||||
|
||||
## 更新与回滚
|
||||
|
||||
已配置更新服务时,在“关于 → Framely 更新”选择“正式版”或“测试版”,再检查、下载并确认安装。正式版只检查正式 Release,测试版只检查 Preview、Beta、RC 等预发布版本;渠道选择保存在设备上。切换渠道会清除原候选版本和已下载包,需要重新检查和下载。也可以用桌面安装器,或在 Frame 执行:
|
||||
已配置更新服务时,在“关于 → Framely 更新”选择“正式版”或“测试版”,再检查、下载并确认安装。正式版只检查正式 Release;测试版同时检查正式版和 Preview、Beta、RC 等预发布版本,并选择最新版本。渠道选择保存在设备上。切换渠道会清除原候选版本和已下载包,需要重新检查和下载。也可以用桌面安装器,或在 Frame 执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- update --version v0.4.2-preview.3
|
||||
|
||||
@@ -10,9 +10,17 @@
|
||||
|
||||
插件区域包含已安装插件、插件库、来源和通知设置,通知仍包含 Framely 本体;插件自身设置位于对应插件的管理弹窗。APK 保留应用、容器及数据清理。设置集中放置通用、启动台、更新和关于。
|
||||
|
||||
## 手机操作
|
||||
|
||||
手机端底部导航包含插件、APK、终端、文件和设置。点击顶部当前页面名称选择子页面,确认和安装弹窗以底部面板展示。界面为安全区和软键盘保留空间,键盘展开时隐藏底部导航。
|
||||
|
||||
文件页点击“浏览文件夹”打开快速访问与目录树抽屉,选择目录后自动收起。“新建与上传”和“筛选与显示”打开操作面板;点击“选择”勾选文件,再从底部选择栏的“操作”进入复制、移动、下载或删除,无需右键。浏览目录和切换区域不停止上传队列。
|
||||
|
||||
终端提供 Ctrl+C、Esc、Tab 和方向快捷键,其中 Ctrl+C 用于中断程序。“粘贴”在浏览器允许时读取剪贴板,否则打开文本框,使用手机原生粘贴后点击“发送到终端”。断连或只读时禁用输入快捷键。
|
||||
|
||||
## 交互反馈
|
||||
|
||||
在 **设置 → 通用 → 交互反馈** 中,振动反馈和提示音反馈默认开启,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机的导航和点击音效,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
|
||||
在 **设置 → 通用 → 交互反馈** 中,振动反馈默认开启,提示音反馈默认关闭,修改后会保存。振动开关统一控制 Framely 和插件页面的激光悬停、启动台眼动聚焦振动,不影响 APK 游戏振动。提示音开关使用 Steam 本机键盘音效,以较低音量反馈聚焦和点击,需要本机 Steam 音效文件与音频运行库,不打包或下载这些音效。
|
||||
|
||||
## 终端
|
||||
|
||||
@@ -32,11 +40,11 @@
|
||||
|
||||
通过地址栏、面包屑、上级按钮或可展开文件夹树导航。快速访问包含主目录、下载、挂载存储、文件系统根目录、回收站及自定义文件夹。星标按钮添加当前目录,旁边按钮可改名、排序、移除;暂不可用的目录保留记录,打开时显示访问错误。
|
||||
|
||||
文件列表支持隐藏文件、排序、多选、当前目录过滤及可取消的递归文件名搜索。递归搜索不跟随目录链接;列表每页 200 项,递归搜索最多返回 5,000 个匹配项、访问 500,000 项。不扫描媒体内容生成缩略图。
|
||||
文件列表支持隐藏文件、排序、多选、当前目录过滤及可取消的递归文件名搜索。递归搜索不跟随目录链接;列表每页 200 项,递归搜索最多返回 5,000 个匹配项、访问 500,000 项。不在后台扫描媒体内容。
|
||||
|
||||
“操作”提供复制、剪切、粘贴、重命名、删除、属性、权限修改、压缩、解压及打开。右键文件会在指针处打开操作菜单。符号链接显示箭头,属性显示目标;复制保留链接,删除链接不会删除目标。特殊文件不能复制或压缩。
|
||||
|
||||
复制、移动、上传、解压可按批次选择跳过、保留两份或明确确认覆盖。同名文件夹覆盖会替换整个文件夹,不合并内容。长任务显示真实字节或已完成条目,可取消并逐项报告错误;后续失败或取消不撤销已完成的项目。在当前网页内切换区域会保留文件任务及编辑状态。
|
||||
复制、移动、解压可按批次选择跳过、保留两份或明确确认覆盖。同名文件夹覆盖会替换整个文件夹,不合并内容。长任务显示真实字节或已完成条目,可取消并逐项报告错误;后续失败或取消不撤销已完成的项目。在当前网页内切换区域会保留文件任务及编辑状态。
|
||||
|
||||
拖动侧栏与文件列表之间的分隔线可调整宽度,并在当前浏览器中记住;窄窗口会自动限制宽度。快速访问固定在顶部一定高度,目录树占据侧栏剩余高度;两个列表分别在内部滚动,标题保持可见,从右侧列表或地址栏进入文件夹时,目录树会展开并标记对应位置。
|
||||
|
||||
@@ -50,12 +58,30 @@
|
||||
|
||||
## 传输与编辑
|
||||
|
||||
网页可上传多个文件或文件夹,使用 1 MiB 分块和字节进度,先写私有临时文件,完成后才获得最终文件名。取消会移除临时文件,约每小时清理闲置至少一小时的上传。服务被强行停止可能留下明确命名的 `.framely-upload-*` 临时文件,不会冒充完整结果。每个文件最大 64 GiB,同时最多保存 64 条上传记录。
|
||||
从电脑拖入文件、文件夹或混合选择。列表、图标视图、侧边目录树、快速访问及面包屑中的文件夹均为上传目标;列表空白区域对应当前目录。拖动时高亮目标并显示路径,文件条目、回收站内容及预览遮罩不接受上传。
|
||||
|
||||
写入前弹窗确认真实目标路径、文件与文件夹数量及总大小,并选择跳过、覆盖或保留两者,默认保留两者。同名文件夹合并,策略只处理同名文件,已有其他文件保留。跳过在传输前判断;覆盖在上传完成后原子替换;保留两者会显示新文件名。类型冲突逐项报错,目标目录变化及嵌套符号链接逃逸会被拒绝。
|
||||
|
||||
点击“上传任务队列”按钮打开紧凑悬浮面板,不挤占文件列表。队列按批次和目标分组,显示已上传文件数与总文件数(不包含文件夹,跳过的文件不计为已上传),可取消单项或整批、重试失败项及清除已结束记录。“同时上传文件数”默认 3,可设置 1–8,按设备用户全局保存,不改变展示方式和目录排序。增加立即补充任务,减少时让正在上传的文件完成。每文件最多两个分块请求,整个队列最多八个分块请求。上传不锁住文件页面,切换目录或管理区域后继续,目标保持确认时的路径。
|
||||
|
||||
拖入文件夹会保留空文件夹。上传文件夹按钮优先使用浏览器目录 API;传统文件输入框只能返回文件,空文件夹请通过拖放上传。大目录异步枚举,完整读取浏览器分批返回的条目,每次最多 100,000 项。
|
||||
|
||||
队列仅保留在当前浏览器页面,刷新或关闭前提醒,并尽力取消活动上传;服务端按闲置时间兜底清理。已完成文件保留,失败项从头重试,不提供跨刷新续传。
|
||||
|
||||
网页可上传多个文件或文件夹,使用 16 MiB 分块和字节进度,先写私有临时文件,完成后才获得最终文件名。取消会移除临时文件,约每小时清理闲置至少一小时的上传。服务被强行停止可能留下明确命名的 `.framely-upload-*` 临时文件,不会冒充完整结果。每个文件最大 64 GiB,同时最多保存 64 条上传记录。
|
||||
|
||||
单文件可直接下载,多选文件或文件夹以 ZIP 下载;下载链接一小时后失效。大文件流式传输,单段 Range 请求支持视频拖动和下载续传。
|
||||
|
||||
普通文件打开时尝试 UTF-8 文本编辑器,上限 5 MiB;非法编码、二进制或超大文件无法编辑,可下载后处理。保存校验内容版本,外部修改后须重新加载或确认覆盖。关闭未保存内容会要求确认。
|
||||
|
||||
所有非目录条目的右键菜单和“操作”中都提供“以文本编辑”,包括图片、压缩包和未知后缀文件,不会先打开媒体预览。二进制或非法 UTF-8 可作为只读文本查看(可能出现替代字符),不能保存;设备、管道和超过 5 MiB 的文件不打开。不执行文件内容,不在磁盘上转换编码。
|
||||
|
||||
离线 CodeMirror 编辑器仅在打开文件时加载,支持行号、JavaScript/TypeScript、JSON、HTML、CSS、Python、Rust 高亮、缩进、折叠、查找替换、撤销重做、自动换行和 Ctrl/Cmd+S。其他格式按纯文本显示。没有补全弹窗、诊断、语言服务或 CDN 请求。头显键盘在当前光标或选区编辑文档;加载失败时仍可使用普通文本框。
|
||||
|
||||
编辑器占满文件管理工作区,提供文件标签及父目录路径、紧凑工具栏、手动语言模式选择和实时行列状态,保存及关闭固定在顶部。底部显示编码和换行格式;统一使用 CRLF 或 CR 的文件编辑后保留其分隔符。确认对话框打开时保留撤销历史,并暂时阻止隐藏编辑区继续接收输入。
|
||||
|
||||
“属性”实时读取完整路径、类型、字节大小、磁盘占用、所有者及所属组名称和数字 ID、修改/访问/状态变更/创建时间(不支持时显示不可用)、Inode、设备编号及硬链接数。符号链接显示链接自身的属性,以及目标和解析路径。目录大小是目录条目自身大小,不是递归统计。当前用户拥有的非符号链接条目可通过读/写/执行矩阵和同步的三位八进制权限修改;保留现有特殊权限位,不自动提权。保存会核对设备及 Inode,拒绝更改已经被替换的条目。
|
||||
|
||||
## 压缩与媒体
|
||||
|
||||
支持创建 ZIP、TAR、TAR.GZ,输出文件名决定格式。解压时选择目标目录和冲突策略。拒绝绝对路径、向上路径穿越、软硬链接及特殊文件,不执行解压内容;最多 50,000 项、展开大小 64 GiB。取消会清理暂存文件,保留已经提交的项目。
|
||||
@@ -64,8 +90,14 @@
|
||||
|
||||
## 访问与隐私
|
||||
|
||||
终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 WebSocket 严格匹配 Origin,插件沙箱不可调用。接口运行于 Steam 用户会话服务,不借用 root 本体执行文件操作。预览与下载仅在用户主动选择后读取内容;测试媒体为生成的样本,不自动读取或上传 Arcturus 录制文件。
|
||||
终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 WebSocket 严格匹配 Origin,插件沙箱不可调用。接口运行于 Steam 用户会话服务,不借用 root 本体执行文件操作。完整预览与下载在用户主动选择后读取内容;图标视图仅为当前可见的媒体按需读取并生成缩略图,不后台扫描目录媒体。测试媒体为生成的样本,开发及验证不读取或上传私人 Arcturus 录制文件。
|
||||
|
||||
## 导出诊断日志
|
||||
|
||||
**设置 → 通用 → 导出日志** 和安装器的 **导出日志** 使用同一收集流程。日志包包含近期 APK 操作日志、Lepton 已保存的 Android 日志,以及运行中容器的有限快照:Android 启动、用户解锁、存储挂载、包管理器就绪状态和近期 main/system/crash 日志。以配置的 Steam 会话用户检查无 root 容器,不启动已停止的容器。应用状态只导出摘要,排除令牌和图标;不读取 APK、存档、媒体或原始内存转储。单文件及总大小受限,保留近期输出;缺失来源、截断和命令超时记录在 `report.json`。日志可能包含应用信息,分享前请检查。
|
||||
|
||||
文件排序(名称、大小或修改时间,以及升序/降序)按文件夹分别记忆。“展示方式”可切换列表、小图标、中图标和大图标,此选项对所有文件夹统一生效。偏好保存在 Frame,重新打开浏览器或头显管理面板时恢复。图标视图仅为当前可见的图片和视频按需生成缩略图,使用浏览器解码,最多同时处理两项。视频截取约第 1 秒的画面。缩略图使用有上限的页面内存缓存,文件元数据变化后重新生成;滚出可视区域会取消待处理任务。不支持的格式或解码失败时保留文件类型图标。不使用 FFmpeg,也不扫描整个文件夹的媒体。
|
||||
|
||||
点击图片或视频会打开全屏遮罩预览。使用滚轮或缩放按钮缩放,拖动画面移动,通过上一个/下一个或方向键切换当前展示列表中的图片、视频。“适应窗口”恢复初始视图,Esc 关闭;视频保留播放控制。浏览器无法解码时,可在预览中下载文件。5 MiB 以内的 UTF-8 文本仍在编辑器中打开;二进制文件、不支持的编码及更大的文件点击后直接下载。
|
||||
|
||||
查看器操作集中在底部居中的悬浮胶囊条中,文件名在下方居中显示。点击空白区域关闭;点击媒体、操作按钮或拖动画面不会误关闭。
|
||||
+20
-1
@@ -20,5 +20,24 @@ for ((i=0; i<${#args[@]}; i++)); do
|
||||
[[ $version =~ ^[a-zA-Z0-9][a-zA-Z0-9.+-]*$ ]] || { echo 'Invalid release version.' >&2; exit 1; }
|
||||
release_path="download/$version"
|
||||
done
|
||||
curl --fail --location --proto '=https' --proto-redir '=https' --retry 3 --output "$work/bootstrap.py" "https://github.com/$repo/releases/$release_path/bootstrap.py"
|
||||
bootstrap_url="https://github.com/$repo/releases/$release_path/bootstrap.py"
|
||||
if status=$(curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --dump-header "$work/headers" --write-out '%{http_code}' --output "$work/bootstrap.py" "$bootstrap_url"); then
|
||||
:
|
||||
else
|
||||
code=$?
|
||||
retry=false
|
||||
case "$code" in 5|6|7|18|28|35|52|55|56) retry=true;; esac
|
||||
if [[ $code == 22 ]]; then
|
||||
case "$status" in 408|429|5??) retry=true;; 403)
|
||||
if grep -Eiq '^(x-ratelimit-remaining:[[:space:]]*0[[:space:]]*$|retry-after:)' "$work/headers"; then retry=true; fi;;
|
||||
esac
|
||||
fi
|
||||
if [[ ${FRAMELY_GITHUB_AUTO_FALLBACK:-1} == 0 || $retry != true ]]; then
|
||||
echo "Bootstrap primary route failed (curl $code, HTTP $status); not eligible for automatic proxy." >&2; exit 1
|
||||
fi
|
||||
echo 'GitHub connection failed; trying gh-proxy.com once' >&2
|
||||
if ! curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --output "$work/bootstrap.py" "https://gh-proxy.com/$bootstrap_url"; then
|
||||
echo "Bootstrap primary route failed (curl $code, HTTP $status); gh-proxy.com fallback also failed." >&2; exit 1
|
||||
fi
|
||||
fi
|
||||
python3 "$work/bootstrap.py" --repo "$repo" "$@"
|
||||
Generated
+1
-1
@@ -1985,7 +1985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.10"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.10"
|
||||
version = "0.4.3"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
+4
-2
@@ -16,7 +16,7 @@ Only one installer instance can run per user; additional instances exit immediat
|
||||
|
||||
1. Enable developer mode and set a password on Frame. Connect the computer and device to the same LAN.
|
||||
2. Discovery prioritizes `192.168.*` subnets, with at most 1024 addresses per scan. Name discovery and IP probing run together. Only devices whose hostname is `frame` are listed, with SSH availability indicated. Other devices and addresses without a confirmed name are omitted. Manual IP and SSH port entry is always available.
|
||||
3. Check the SSH handshake and displayed host fingerprint, then enter credentials. The default user is `steamos`. Changed saved fingerprints cause the connection to be rejected. After verifying a device reinstall, you can remove its entry from `known-hosts.json` in the configuration directory.
|
||||
3. Check the SSH handshake and displayed host fingerprint, then enter credentials. The default user is `steamos`. If a reinstall changes the host key, the connection page shows both old and new fingerprints. Verify the device and choose “Trust new fingerprint and connect” to continue without editing `known-hosts.json`. Cancellation or failed login preserves the old record; the new fingerprint is saved only after authentication and device checks succeed. A further key change before login stops the connection and requires a fresh SSH check.
|
||||
4. Select a release or a local `framely-*-linux-arm64.tar.gz` together with its external `SHA256SUMS` or matching `.sha256` file. Local packages do not require GitHub access. Prereleases are hidden by default.
|
||||
5. Choose an operation on Install & Maintenance and confirm the device, version and action in the modal dialog. Cancel or press Esc to return without executing. Errors and operation results also appear in dialogs. SSH fingerprint verification stays on the connection page. The installer does not disable SteamOS root filesystem read-only protection; it stops with an error when required directories are not writable.
|
||||
|
||||
@@ -64,7 +64,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
|
||||
|
||||
Preview requires an available GPU or software Vulkan renderer. Default release builds do not include preview support.
|
||||
|
||||
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.10`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
|
||||
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.3`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
|
||||
|
||||
Actions builds Linux x64/ARM64, Windows x64, and macOS Intel/Apple Silicon. macOS produces an `.app` ZIP, Windows an EXE ZIP, and Linux a tar.gz. Apple notarization and Windows code signing are not configured; verify first-launch behavior on each system before publishing.
|
||||
|
||||
@@ -88,4 +88,6 @@ The archive includes bounded recent service/plugin/CEF logs, GPU evidence, insta
|
||||
|
||||
### Proxy preferences
|
||||
|
||||
Automatic GitHub fallback is enabled by default and can be disabled independently. After an eligible public GitHub connection failure, the installer retries once through `https://gh-proxy.com`, retaining the existing HTTP/system proxy and checksum verification. Partial downloads restart from zero. The selected fallback preference is passed to the device-side installation engine without changing persistent Frame settings. Standalone scripts also default to fallback and accept `FRAMELY_GITHUB_AUTO_FALLBACK=0` to disable it. Certificate errors, ordinary permission/not-found errors, authenticated or signed URLs and checksum failures do not trigger fallback.
|
||||
|
||||
The **Settings** tab controls downloads on the installer computer. System proxy discovery is enabled by default. An explicit HTTP/SOCKS proxy takes precedence; leave it empty to follow the system toggle. An optional HTTPS GitHub proxy prefix routes GitHub API and asset URLs as `prefix/original-URL`. Settings persist locally and apply to subsequent version checks and downloads; SSH and Frame settings are unchanged. Saving clears the release selection so the next version check uses the new settings. Package verification remains enabled.
|
||||
@@ -16,7 +16,7 @@ Rust + GPUI Kit 的桌面安装器,连接 Steam Frame,安装、更新、修
|
||||
|
||||
1. 在 Frame 开启开发者模式并设置密码;电脑和设备连接同一局域网。
|
||||
2. 默认优先扫描 `192.168.*` 网段(每次最多 1024 个地址)。名称发现和 IP 探测同时开始;只列出主机名为 `frame` 的设备,并标注 SSH 是否可用。其他设备和无法确认名称的地址不会显示。手动 IP 和 SSH 端口始终可用。
|
||||
3. 检查 SSH 握手,核实显示的主机指纹,再输入账号密码登录。用户名默认 `steamos`。已保存的指纹发生变化时拒绝连接;核实设备重装后可手动移除配置目录中的对应 `known-hosts.json` 条目。
|
||||
3. 检查 SSH 握手,核实显示的主机指纹,再输入账号密码登录。用户名默认 `steamos`。设备重装等情况导致指纹变化时,连接页显示旧、新指纹,核实设备身份后点击“信任新指纹并连接”即可继续,无需手动修改 `known-hosts.json`。取消或登录失败保留原记录;登录及设备检查成功后才保存新指纹。登录前若指纹再次变化,会停止并要求重新检查 SSH。
|
||||
4. 从 Releases 选择版本,或选择本地 `framely-*-linux-arm64.tar.gz` 和压缩包外部的 `SHA256SUMS`(也可选择对应 `.sha256` 文件)。本地包不需要访问 GitHub。默认隐藏测试版。
|
||||
5. 在“安装与维护”选择操作,在弹窗中核对设备、版本和操作后确认;取消或按 Esc 返回,不会执行。错误和操作结果也使用弹窗。SSH 指纹确认仍保留在连接页面。安装器不会解除 SteamOS 根分区的只读保护,所需目录不可写时会报错停止。
|
||||
|
||||
@@ -64,7 +64,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
|
||||
|
||||
预览依赖可用的 GPU 或软件 Vulkan 渲染器;默认发行构建不包含此预览功能。
|
||||
|
||||
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.10`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
|
||||
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.3`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
|
||||
|
||||
Actions 构建 Linux x64/ARM64、Windows x64、macOS Intel/Apple Silicon。macOS 输出 `.app` ZIP,Windows 输出 EXE ZIP,Linux 输出 tar.gz。当前未配置 Apple 公证或 Windows 代码签名,发布前应分别验证系统的首次启动体验。
|
||||
|
||||
@@ -88,4 +88,6 @@ cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --
|
||||
|
||||
### 代理设置
|
||||
|
||||
自动 GitHub 代理默认开启,可单独关闭。公开 GitHub 请求出现符合条件的网络故障后,安装器通过 `https://gh-proxy.com` 最多重试一次,继续沿用 HTTP/系统代理及校验保护;半截下载会从零重新开始。本次开关状态会传给设备侧安装引擎,不改写 Frame 的持久设置。独立安装脚本默认开启,可用 `FRAMELY_GITHUB_AUTO_FALLBACK=0` 禁用。证书错误、普通权限或资源不存在错误、带认证或签名参数的地址以及校验失败不触发兜底。
|
||||
|
||||
**设置**页控制安装器电脑的下载。默认启用系统代理;填写 HTTP/SOCKS 代理后优先使用指定地址,留空则遵循系统代理开关。可选的 HTTPS GitHub 代理以 `代理前缀/原始 URL` 转发 GitHub API 和资源请求。设置在本机保存,后续检查版本和下载时生效,不更改 SSH 或 Frame 的设置。保存会清除版本选择,下次检查版本使用新配置;包校验继续启用。
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod discovery;
|
||||
#[path = "../../src/http/github_fallback.rs"]
|
||||
pub(crate) mod github_fallback;
|
||||
pub mod instance;
|
||||
pub mod maintenance;
|
||||
pub mod progress;
|
||||
|
||||
+86
-2
@@ -49,6 +49,7 @@ enum Event {
|
||||
struct Installer {
|
||||
page: usize,
|
||||
system_proxy: bool,
|
||||
github_auto_fallback: bool,
|
||||
http_proxy: Entity<InputState>,
|
||||
github_proxy: Entity<InputState>,
|
||||
chosen_action: String,
|
||||
@@ -117,6 +118,7 @@ impl Installer {
|
||||
let mut view = Self {
|
||||
page: 0,
|
||||
system_proxy: proxies.system,
|
||||
github_auto_fallback: proxies.github_auto_fallback,
|
||||
http_proxy: input(&proxies.http, "http://127.0.0.1:7890", false),
|
||||
github_proxy: input(&proxies.github, "https://your-github-proxy.example", false),
|
||||
chosen_action: "install".into(),
|
||||
@@ -273,7 +275,12 @@ impl Installer {
|
||||
self.busy = false;
|
||||
match result {
|
||||
Ok(probe) => {
|
||||
self.status = "SSH 握手成功。确认下方设备指纹后登录。".into();
|
||||
self.status = if probe.changed() {
|
||||
"设备指纹已变化。若设备已重装,请核对下方新指纹后重新信任并连接。"
|
||||
} else {
|
||||
"SSH 握手成功。确认下方设备指纹后登录。"
|
||||
}
|
||||
.into();
|
||||
self.probe = Some(probe);
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -292,6 +299,10 @@ impl Installer {
|
||||
self.busy = false;
|
||||
match result {
|
||||
Ok(connection) => {
|
||||
if let Some(probe) = &mut self.probe {
|
||||
probe.previous_fingerprint = Some(probe.fingerprint.clone());
|
||||
probe.known = true;
|
||||
}
|
||||
self.status =
|
||||
format!("已连接 {}:{}", connection.host, connection.version);
|
||||
self.chosen_action = connection.installation.default_action().into();
|
||||
@@ -525,7 +536,7 @@ impl Installer {
|
||||
self.connection_stage = Some(self.status.clone());
|
||||
let sender = self.sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = remote::connect(credentials, &probe.fingerprint, &mut |stage| {
|
||||
let result = remote::connect(credentials, &probe, &mut |stage| {
|
||||
let _ = sender.send(Event::ConnectionStage(stage));
|
||||
})
|
||||
.map_err(|e| format!("{e:#}"));
|
||||
@@ -601,6 +612,7 @@ impl Installer {
|
||||
}
|
||||
fn save_proxy(&mut self, cx: &mut Context<Self>) {
|
||||
let settings = framely_installer::proxy::DownloadSettings {
|
||||
github_auto_fallback: self.github_auto_fallback,
|
||||
system: self.system_proxy,
|
||||
http: self.http_proxy.read(cx).value().trim().to_owned(),
|
||||
github: self
|
||||
@@ -610,6 +622,7 @@ impl Installer {
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
match framely_installer::proxy::save(settings) {
|
||||
Ok(()) => {
|
||||
@@ -833,6 +846,75 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
})?;
|
||||
context.update_window(handle, |_, window, cx| transparent_window_root(window, cx))?;
|
||||
context.allow_parking();
|
||||
if std::env::var("FRAMELY_INSTALLER_PREVIEW_CASE").as_deref() == Ok("fingerprint-change") {
|
||||
context.update_window(handle, |_, window, cx| {
|
||||
view.update(cx, |view, cx| {
|
||||
view.page = 0;
|
||||
view.busy = true;
|
||||
view.connection = None;
|
||||
view.sender
|
||||
.send(Event::Probe(Ok(Probe {
|
||||
endpoint: "192.168.1.42:22".into(),
|
||||
fingerprint: "SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".into(),
|
||||
known: false,
|
||||
previous_fingerprint: Some(
|
||||
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into(),
|
||||
),
|
||||
})))
|
||||
.unwrap();
|
||||
view.poll(window, cx);
|
||||
assert!(!view.busy && view.probe.as_ref().unwrap().changed());
|
||||
assert!(view.status.contains("设备指纹已变化"));
|
||||
});
|
||||
window.render_frame(cx);
|
||||
assert!(window.find("connect").visible());
|
||||
assert!(window.find("cancel-fingerprint-change").visible());
|
||||
})?;
|
||||
context.run_until_parked();
|
||||
context.update_window(handle, |_, window, cx| window.render_frame(cx))?;
|
||||
context
|
||||
.capture_screenshot(handle)?
|
||||
.save(dir.join("installer-fingerprint-change.png"))?;
|
||||
let pending =
|
||||
context.update_window(handle, |_, _, cx| view.read(cx).probe.clone().unwrap())?;
|
||||
context.update_window(handle, |_, window, cx| {
|
||||
window.click("cancel-fingerprint-change", cx);
|
||||
window.render_frame(cx);
|
||||
assert!(view.read(cx).probe.is_none() && !view.read(cx).busy);
|
||||
assert!(view.read(cx).status.contains("未更改"));
|
||||
assert!(window.find("probe").visible());
|
||||
view.update(cx, |view, cx| {
|
||||
view.probe = Some(pending.clone());
|
||||
view.sender
|
||||
.send(Event::Connected(Err("SSH 登录失败".into())))
|
||||
.unwrap();
|
||||
view.poll(window, cx);
|
||||
assert!(view.probe.as_ref().unwrap().changed() && !view.busy);
|
||||
});
|
||||
window.close_all_dialogs(cx);
|
||||
view.update(cx, |view, cx| {
|
||||
view.prompts.clear();
|
||||
view.sender
|
||||
.send(Event::Connected(Ok(Connection {
|
||||
session: ssh2::Session::new().unwrap(),
|
||||
host: "192.168.1.42".into(),
|
||||
user: "steamos".into(),
|
||||
version: "frame · 0.8.3".into(),
|
||||
installation: Installation {
|
||||
present: true,
|
||||
current_version: Some("0.8.3".into()),
|
||||
previous_version: None,
|
||||
},
|
||||
})))
|
||||
.unwrap();
|
||||
view.poll(window, cx);
|
||||
let probe = view.probe.as_ref().unwrap();
|
||||
assert!(probe.known && !probe.changed() && !view.busy);
|
||||
});
|
||||
})?;
|
||||
println!("Fingerprint change preview, cancellation and connection state checks passed");
|
||||
return Ok(());
|
||||
}
|
||||
for (page, name) in [
|
||||
(0, "devices"),
|
||||
(1, "versions"),
|
||||
@@ -866,8 +948,10 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
view.devices.clear();
|
||||
} else if name == "connecting" {
|
||||
view.probe = Some(Probe {
|
||||
endpoint: "192.168.1.42:22".into(),
|
||||
fingerprint: "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into(),
|
||||
known: false,
|
||||
previous_fingerprint: None,
|
||||
});
|
||||
view.busy = true;
|
||||
view.sender
|
||||
|
||||
@@ -62,12 +62,12 @@ pub fn copy(
|
||||
ensure!(value.completed + n as u64 <= total, "传输超过声明长度");
|
||||
writer.write_all(&block[..n])?;
|
||||
value.completed += n as u64;
|
||||
if last.elapsed() >= Duration::from_millis(200) {
|
||||
if last.elapsed() >= Duration::from_millis(200) && value.completed < total {
|
||||
report(value.clone());
|
||||
last = Instant::now();
|
||||
}
|
||||
}
|
||||
ensure!(value.completed == total, "传输未完成");
|
||||
if value.completed != total { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "传输未完成").into()); }
|
||||
writer.flush()?;
|
||||
report(value);
|
||||
Ok(())
|
||||
|
||||
@@ -8,6 +8,10 @@ use url::Url;
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct DownloadSettings {
|
||||
#[serde(skip)]
|
||||
#[doc(hidden)]
|
||||
pub github_fallback_attempt: bool,
|
||||
pub github_auto_fallback: bool,
|
||||
pub system: bool,
|
||||
pub http: String,
|
||||
pub github: String,
|
||||
@@ -15,6 +19,8 @@ pub struct DownloadSettings {
|
||||
impl Default for DownloadSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
github_fallback_attempt: false,
|
||||
github_auto_fallback: true,
|
||||
system: true,
|
||||
http: String::new(),
|
||||
github: String::new(),
|
||||
@@ -46,6 +52,7 @@ impl DownloadSettings {
|
||||
Ok(())
|
||||
}
|
||||
pub fn route(&self, url: &Url) -> Url {
|
||||
if self.github_fallback_attempt && !crate::github_fallback::eligible(url.as_str(), &[]) { return url.clone(); }
|
||||
if !self.github.is_empty()
|
||||
&& matches!(
|
||||
url.host_str(),
|
||||
@@ -695,6 +702,15 @@ mod download_settings_tests {
|
||||
assert!(settings.proxy_for(&target).unwrap().is_some());
|
||||
}
|
||||
#[test]
|
||||
fn github_fallback_default_and_sensitive_redirect_policy() {
|
||||
let legacy:DownloadSettings=serde_json::from_str("{}").unwrap();assert!(legacy.github_auto_fallback);
|
||||
let disabled:DownloadSettings=serde_json::from_str("{\"githubAutoFallback\":false}").unwrap();assert!(!disabled.github_auto_fallback);
|
||||
let fallback=DownloadSettings {github:crate::github_fallback::PREFIX.into(),github_fallback_attempt:true,..Default::default()};
|
||||
let signed=Url::parse("https://release-assets.githubusercontent.com/a?X-Amz-Signature=secret").unwrap();
|
||||
assert_eq!(fallback.route(&signed),signed);
|
||||
assert!(fallback.route(&Url::parse("https://github.com/a/b/releases").unwrap()).as_str().starts_with(crate::github_fallback::PREFIX));
|
||||
}
|
||||
#[test]
|
||||
fn github_routes_only_exact_hosts_and_does_not_double_wrap_redirects() {
|
||||
let settings = DownloadSettings {
|
||||
github: "https://proxy.example/prefix/".into(),
|
||||
|
||||
+57
-11
@@ -30,25 +30,42 @@ pub fn validate_repo(repo: &str) -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn agent(url: &url::Url, settings: &crate::proxy::DownloadSettings) -> Result<ureq::Agent> {
|
||||
fn agent(url: &url::Url, settings: &crate::proxy::DownloadSettings, download: bool) -> Result<ureq::Agent> {
|
||||
let mut builder = ureq::AgentBuilder::new()
|
||||
.try_proxy_from_env(false)
|
||||
.timeout(Duration::from_secs(120))
|
||||
.redirects(0);
|
||||
let timeout = Duration::from_secs(120);
|
||||
builder = if download { builder.timeout_connect(timeout).timeout_read(timeout).timeout_write(timeout) } else { builder.timeout(timeout) };
|
||||
if let Some(proxy) = settings.proxy_for(url)? {
|
||||
builder = builder.proxy(proxy);
|
||||
}
|
||||
Ok(builder.build())
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn get(url: &str) -> Result<ureq::Response> {
|
||||
let settings = crate::proxy::current();
|
||||
crate::github_fallback::run(settings.github_auto_fallback, url, &[], |fallback| {
|
||||
let mut route=settings.clone();
|
||||
if fallback {route.github=crate::github_fallback::PREFIX.into();route.github_fallback_attempt=true;}
|
||||
get_routed(url, &route, false)
|
||||
})
|
||||
}
|
||||
fn get_routed(url: &str, settings: &crate::proxy::DownloadSettings, download: bool) -> Result<ureq::Response> {
|
||||
let mut url = url::Url::parse(url)?;
|
||||
for _ in 0..=5 {
|
||||
ensure!(url.scheme() == "https", "下载地址必须使用 HTTPS");
|
||||
// Re-evaluate bypass rules for every redirect target.
|
||||
let routed = settings.route(&url);
|
||||
let response = agent(&routed, &settings)?
|
||||
.get(routed.as_str())
|
||||
#[cfg(test)]
|
||||
let transport_url = crate::github_fallback::test_url(routed.as_str());
|
||||
#[cfg(not(test))]
|
||||
let transport_url = routed.to_string();
|
||||
#[cfg(test)]
|
||||
let transport_settings = if transport_url != routed.as_str() { crate::proxy::DownloadSettings {system:false,http:String::new(),..settings.clone()} } else {settings.clone()};
|
||||
#[cfg(not(test))]
|
||||
let transport_settings = settings.clone();
|
||||
let response = agent(&routed, &transport_settings, download)?
|
||||
.get(&transport_url)
|
||||
.set("User-Agent", "Framely-Installer")
|
||||
.call()?;
|
||||
if (300..400).contains(&response.status()) {
|
||||
@@ -63,15 +80,22 @@ pub fn list(repo: &str) -> Result<Vec<Release>> {
|
||||
validate_repo(repo)?;
|
||||
let mut all = Vec::new();
|
||||
for page in 1..=10 {
|
||||
let response = get(&format!(
|
||||
let url=format!(
|
||||
"https://api.github.com/repos/{repo}/releases?per_page=100&page={page}"
|
||||
))?;
|
||||
);
|
||||
let settings=crate::proxy::current();
|
||||
let bytes=crate::github_fallback::run(settings.github_auto_fallback,&url,&[],|fallback| {
|
||||
let mut route=settings.clone();
|
||||
if fallback {route.github=crate::github_fallback::PREFIX.into();route.github_fallback_attempt=true;}
|
||||
let response=get_routed(&url,&route,false)?;
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.into_reader()
|
||||
.take(4 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.len() <= 4 * 1024 * 1024, "Release 列表过大");
|
||||
Ok(bytes)
|
||||
})?;
|
||||
let items: Vec<Release> = serde_json::from_slice(&bytes)?;
|
||||
let finished = items.len() < 100;
|
||||
all.extend(
|
||||
@@ -209,9 +233,17 @@ pub fn download(
|
||||
crate::progress::Progress::new(crate::progress::Stage::Download, &asset.name);
|
||||
starting.total = Some(asset.size);
|
||||
progress(starting);
|
||||
let mut input = get(&asset.browser_download_url)?.into_reader();
|
||||
let mut output = File::create(dest)?;
|
||||
crate::progress::copy(
|
||||
let settings = crate::proxy::current();
|
||||
crate::github_fallback::run(settings.github_auto_fallback, &asset.browser_download_url, &[], |fallback| {
|
||||
let mut route=settings.clone();
|
||||
if fallback {
|
||||
route.github=crate::github_fallback::PREFIX.into();
|
||||
route.github_fallback_attempt=true;
|
||||
progress(crate::progress::Progress::new(crate::progress::Stage::Download, "正在尝试 gh-proxy.com 自动代理"));
|
||||
}
|
||||
let mut input = get_routed(&asset.browser_download_url, &route, true)?.into_reader();
|
||||
let mut output = File::create(dest)?;
|
||||
crate::progress::copy(
|
||||
&mut input,
|
||||
&mut output,
|
||||
asset.size,
|
||||
@@ -219,12 +251,26 @@ pub fn download(
|
||||
&asset.name,
|
||||
progress,
|
||||
)?;
|
||||
output.sync_all()?;
|
||||
Ok(())
|
||||
output.sync_all()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn github_fallback_restarts_a_truncated_installer_download_from_zero() {
|
||||
let (target,server)=crate::github_fallback::local_truncated_server();
|
||||
let source=format!("https://github.com/framely-test/{}/asset",target.rsplit(':').next().unwrap());
|
||||
let _routes=crate::github_fallback::TestRoutes::new(vec![(source.clone(),target.clone()),(format!("{}/{}",crate::github_fallback::PREFIX,source),target)]);
|
||||
let temp=tempfile::tempdir().unwrap();let dest=temp.path().join("archive");let mut progress=vec![];
|
||||
// This test uses an explicit local transport mapping, never the public network.
|
||||
let asset=Asset{name:"test.tar.gz".into(),browser_download_url:source,size:8};
|
||||
download(&asset,&dest,&mut |value| progress.push(value)).unwrap();
|
||||
assert_eq!(std::fs::read(dest).unwrap(),b"new-data");
|
||||
assert!(progress.iter().any(|p|p.detail.contains("gh-proxy.com")));
|
||||
server.join().unwrap();
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn https_requests_use_discovered_desktop_proxy() {
|
||||
|
||||
+144
-27
@@ -23,8 +23,32 @@ pub struct Credentials {
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Probe {
|
||||
pub endpoint: String,
|
||||
pub fingerprint: String,
|
||||
pub known: bool,
|
||||
pub previous_fingerprint: Option<String>,
|
||||
}
|
||||
impl Probe {
|
||||
pub fn changed(&self) -> bool {
|
||||
self.previous_fingerprint
|
||||
.as_ref()
|
||||
.is_some_and(|old| old != &self.fingerprint)
|
||||
}
|
||||
fn confirm(&self, actual: &str, hosts: &Hosts) -> Result<()> {
|
||||
ensure!(
|
||||
actual == self.fingerprint,
|
||||
"SSH 指纹与刚才检查的不一致,请重新检查 SSH 连接"
|
||||
);
|
||||
ensure!(
|
||||
hosts
|
||||
.keys
|
||||
.get(&self.endpoint)
|
||||
.map(|saved| normalized_saved_fingerprint(saved))
|
||||
== self.previous_fingerprint,
|
||||
"已保存的 SSH 指纹记录发生变化,请重新检查 SSH 连接"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub struct Connection {
|
||||
pub session: Session,
|
||||
@@ -44,7 +68,9 @@ fn hosts_file() -> Result<PathBuf> {
|
||||
.join("known-hosts.json"))
|
||||
}
|
||||
fn load_hosts() -> Result<Hosts> {
|
||||
let path = hosts_file()?;
|
||||
load_hosts_from(&hosts_file()?)
|
||||
}
|
||||
fn load_hosts_from(path: &Path) -> Result<Hosts> {
|
||||
if path.exists() {
|
||||
Ok(serde_json::from_slice(&fs::read(path)?)?)
|
||||
} else {
|
||||
@@ -88,20 +114,24 @@ pub fn probe(host: &str, port: u16) -> Result<Probe> {
|
||||
let fingerprint = fingerprint(&session)?;
|
||||
let hosts = load_hosts()?;
|
||||
let endpoint = format!("{host}:{port}");
|
||||
if let Some(saved) = hosts.keys.get(&endpoint) {
|
||||
ensure!(
|
||||
normalized_saved_fingerprint(saved) == fingerprint,
|
||||
"SSH 主机指纹发生变化,已阻止连接;请先核实设备身份"
|
||||
);
|
||||
}
|
||||
Ok(Probe {
|
||||
Ok(observed_probe(endpoint, fingerprint, &hosts))
|
||||
}
|
||||
fn observed_probe(endpoint: String, fingerprint: String, hosts: &Hosts) -> Probe {
|
||||
let previous_fingerprint = hosts
|
||||
.keys
|
||||
.get(&endpoint)
|
||||
.map(|saved| normalized_saved_fingerprint(saved));
|
||||
let known = previous_fingerprint.as_ref() == Some(&fingerprint);
|
||||
Probe {
|
||||
endpoint,
|
||||
fingerprint,
|
||||
known: hosts.keys.contains_key(&endpoint),
|
||||
})
|
||||
known,
|
||||
previous_fingerprint,
|
||||
}
|
||||
}
|
||||
pub fn connect(
|
||||
credentials: Credentials,
|
||||
expected: &str,
|
||||
confirmed: &Probe,
|
||||
progress: &mut dyn FnMut(String),
|
||||
) -> Result<Connection> {
|
||||
ensure!(
|
||||
@@ -118,12 +148,15 @@ pub fn connect(
|
||||
"密码不能包含换行符"
|
||||
);
|
||||
ensure!(!credentials.password.is_empty(), "请输入设备登录密码");
|
||||
ensure!(
|
||||
confirmed.endpoint == format!("{}:{}", credentials.host, credentials.port),
|
||||
"设备地址已变化,请重新检查 SSH 连接"
|
||||
);
|
||||
progress("正在建立 SSH 连接…".into());
|
||||
let session = handshake(&credentials.host, credentials.port).context("无法建立 SSH 连接")?;
|
||||
ensure!(
|
||||
fingerprint(&session)? == expected,
|
||||
"SSH 指纹与刚才检查的不一致"
|
||||
);
|
||||
// The Connect button confirms this exact observation, including a changed
|
||||
// key. Recheck before sending credentials; never accept a second change.
|
||||
confirmed.confirm(&fingerprint(&session)?, &load_hosts()?)?;
|
||||
progress("正在验证用户名和密码…".into());
|
||||
session
|
||||
.userauth_password(&credentials.user, &credentials.password)
|
||||
@@ -161,16 +194,7 @@ pub fn connect(
|
||||
"未安装"
|
||||
});
|
||||
progress("正在保存设备指纹…".into());
|
||||
let path = hosts_file()?;
|
||||
fs::create_dir_all(path.parent().unwrap())
|
||||
.context("无法创建指纹配置目录,请检查本机目录权限")?;
|
||||
let mut hosts = load_hosts()?;
|
||||
hosts.keys.insert(
|
||||
format!("{}:{}", credentials.host, credentials.port),
|
||||
expected.into(),
|
||||
);
|
||||
fs::write(path, serde_json::to_vec_pretty(&hosts)?)
|
||||
.context("无法保存设备指纹,请检查本机配置目录权限")?;
|
||||
remember_probe(&hosts_file()?, confirmed)?;
|
||||
Ok(Connection {
|
||||
session,
|
||||
host: credentials.host,
|
||||
@@ -179,6 +203,21 @@ pub fn connect(
|
||||
installation,
|
||||
})
|
||||
}
|
||||
fn remember_probe(path: &Path, confirmed: &Probe) -> Result<()> {
|
||||
let parent = path.parent().context("无效的指纹配置路径")?;
|
||||
fs::create_dir_all(parent).context("无法创建指纹配置目录,请检查本机目录权限")?;
|
||||
let mut hosts = load_hosts_from(path)?;
|
||||
confirmed.confirm(&confirmed.fingerprint, &hosts)?;
|
||||
hosts
|
||||
.keys
|
||||
.insert(confirmed.endpoint.clone(), confirmed.fingerprint.clone());
|
||||
let mut file = tempfile::NamedTempFile::new_in(parent)?;
|
||||
file.write_all(&serde_json::to_vec_pretty(&hosts)?)?;
|
||||
file.as_file().sync_all()?;
|
||||
file.persist(path)
|
||||
.context("无法保存设备指纹,请检查本机配置目录权限")?;
|
||||
Ok(())
|
||||
}
|
||||
fn read_installation(
|
||||
session: &Session,
|
||||
password: &str,
|
||||
@@ -368,7 +407,8 @@ pub fn operate(
|
||||
transfer.completed = super::BOOTSTRAP.len() as u64;
|
||||
progress(transfer);
|
||||
let mut args = format!(
|
||||
"sudo -S -p '' -k -- python3 -u {} {} --yes --progress-json --repo {} --user {}",
|
||||
"sudo -S -p '' -k -- env FRAMELY_GITHUB_AUTO_FALLBACK={} python3 -u {} {} --yes --progress-json --repo {} --user {}",
|
||||
if crate::proxy::current().github_auto_fallback { "1" } else { "0" },
|
||||
quote(&script),
|
||||
quote(action),
|
||||
quote(repo),
|
||||
@@ -462,6 +502,83 @@ mod tests {
|
||||
normalized_saved_fingerprint(&format!("SHA256:{}", hex::encode([1_u8; 32]))),
|
||||
standard
|
||||
);
|
||||
let hosts = Hosts {
|
||||
keys: BTreeMap::from([("frame:22".into(), format!("SHA256:{}", hex::encode(digest)))]),
|
||||
};
|
||||
let probe = observed_probe("frame:22".into(), standard.clone(), &hosts);
|
||||
assert!(probe.known && !probe.changed());
|
||||
probe.confirm(&standard, &hosts).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn changed_fingerprint_can_be_confirmed_without_losing_other_devices() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("known-hosts.json");
|
||||
let old = "SHA256:old";
|
||||
let new = "SHA256:new";
|
||||
let hosts = Hosts {
|
||||
keys: BTreeMap::from([
|
||||
("frame:22".into(), old.into()),
|
||||
("other:2222".into(), "SHA256:other".into()),
|
||||
]),
|
||||
};
|
||||
fs::write(&path, serde_json::to_vec(&hosts).unwrap()).unwrap();
|
||||
let fresh = observed_probe("fresh:22".into(), new.into(), &hosts);
|
||||
assert!(!fresh.known && !fresh.changed() && fresh.previous_fingerprint.is_none());
|
||||
let changed = observed_probe("frame:22".into(), new.into(), &hosts);
|
||||
assert!(!changed.known && changed.changed());
|
||||
assert_eq!(changed.previous_fingerprint.as_deref(), Some(old));
|
||||
// Merely inspecting or cancelling leaves the original record intact.
|
||||
assert_eq!(load_hosts_from(&path).unwrap().keys["frame:22"], old);
|
||||
assert!(changed.confirm("SHA256:changed-again", &hosts).is_err());
|
||||
assert_eq!(load_hosts_from(&path).unwrap().keys["frame:22"], old);
|
||||
changed.confirm(new, &hosts).unwrap();
|
||||
remember_probe(&path, &changed).unwrap();
|
||||
let saved = load_hosts_from(&path).unwrap();
|
||||
assert_eq!(saved.keys["frame:22"], new);
|
||||
assert_eq!(saved.keys["other:2222"], "SHA256:other");
|
||||
let next = observed_probe("frame:22".into(), new.into(), &saved);
|
||||
assert!(next.known && !next.changed());
|
||||
next.confirm(new, &saved).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn stale_fingerprint_confirmation_does_not_replace_a_newer_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("known-hosts.json");
|
||||
let mut hosts = Hosts {
|
||||
keys: BTreeMap::from([("frame:22".into(), "SHA256:old".into())]),
|
||||
};
|
||||
let pending = observed_probe("frame:22".into(), "SHA256:new".into(), &hosts);
|
||||
hosts
|
||||
.keys
|
||||
.insert("frame:22".into(), "SHA256:confirmed-elsewhere".into());
|
||||
fs::write(&path, serde_json::to_vec(&hosts).unwrap()).unwrap();
|
||||
assert!(pending.confirm(&pending.fingerprint, &hosts).is_err());
|
||||
assert!(remember_probe(&path, &pending).is_err());
|
||||
assert_eq!(
|
||||
load_hosts_from(&path).unwrap().keys["frame:22"],
|
||||
"SHA256:confirmed-elsewhere"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn confirmed_fingerprint_is_bound_to_the_checked_address_and_port() {
|
||||
let pending = observed_probe("frame:22".into(), "SHA256:new".into(), &Hosts::default());
|
||||
for (host, port) in [("other", 22), ("frame", 2222)] {
|
||||
let mut stages = vec![];
|
||||
let error = connect(
|
||||
Credentials {
|
||||
host: host.into(),
|
||||
port,
|
||||
user: "steamos".into(),
|
||||
password: Zeroizing::new("test".into()),
|
||||
},
|
||||
&pending,
|
||||
&mut |stage| stages.push(stage),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.to_string().contains("设备地址已变化"));
|
||||
assert!(stages.is_empty());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Requires an explicitly supplied SSH device and trusted OpenSSH fingerprints"]
|
||||
@@ -491,7 +608,7 @@ mod tests {
|
||||
user: "steamos".into(),
|
||||
password: Zeroizing::new("framely-invalid-password-test-9c45bc39".into()),
|
||||
},
|
||||
&probe.fingerprint,
|
||||
&probe,
|
||||
&mut |stage| stages.push(stage),
|
||||
);
|
||||
let error = match result {
|
||||
|
||||
+91
-15
@@ -336,6 +336,9 @@ impl Installer {
|
||||
this.child(field("扫描范围", &self.cidr, self.busy || self.scanning))
|
||||
});
|
||||
let mut connection = panel()
|
||||
.when(self.probe.as_ref().is_some_and(Probe::changed), |this| {
|
||||
this.gap_3().p_5()
|
||||
})
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
@@ -391,32 +394,99 @@ impl Installer {
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(icon(IconName::ShieldCheck))
|
||||
.child(div().text_sm().child(if probe.known {
|
||||
.child(div().text_sm().child(if probe.changed() {
|
||||
"设备指纹已变化"
|
||||
} else if probe.known {
|
||||
"设备指纹与上次一致"
|
||||
} else {
|
||||
"首次连接,请核对设备指纹"
|
||||
})),
|
||||
)
|
||||
.when(probe.changed(), |this| {
|
||||
this.child(muted("设备重装后,请核对并信任新指纹,继续连接。"))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("上次指纹"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(12.))
|
||||
.text_color(rgb(MUTED))
|
||||
.child(
|
||||
probe
|
||||
.previous_fingerprint
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(12.))
|
||||
.text_color(rgb(BLUE))
|
||||
.child(probe.fingerprint.clone()),
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.when(probe.changed(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("当前指纹"),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(12.))
|
||||
.text_color(rgb(BLUE))
|
||||
.child(probe.fingerprint.clone()),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("connect")
|
||||
.h(px(42.))
|
||||
.primary()
|
||||
.label(if self.connection_stage.is_some() {
|
||||
"正在连接…"
|
||||
} else {
|
||||
"确认指纹并连接"
|
||||
})
|
||||
.loading(self.connection_stage.is_some())
|
||||
.disabled(locked)
|
||||
.on_click(cx.listener(|view, _, _, cx| view.connect(cx))),
|
||||
div()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
Button::new("connect")
|
||||
.flex_1()
|
||||
.h(px(42.))
|
||||
.primary()
|
||||
.label(if self.connection_stage.is_some() {
|
||||
"正在连接…"
|
||||
} else if probe.changed() {
|
||||
"信任新指纹并连接"
|
||||
} else {
|
||||
"确认指纹并连接"
|
||||
})
|
||||
.loading(self.connection_stage.is_some())
|
||||
.disabled(locked)
|
||||
.on_click(cx.listener(|view, _, _, cx| view.connect(cx))),
|
||||
)
|
||||
.when(probe.changed(), |this| {
|
||||
this.child(
|
||||
Button::new("cancel-fingerprint-change")
|
||||
.h(px(42.))
|
||||
.ghost()
|
||||
.label("取消")
|
||||
.disabled(locked)
|
||||
.on_click(cx.listener(|view, _, _, cx| {
|
||||
view.probe = None;
|
||||
view.error = None;
|
||||
view.status = "未更改已保存的设备指纹。".into();
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
connection = connection.child(
|
||||
@@ -491,6 +561,12 @@ impl Installer {
|
||||
.child(field("HTTP 代理", &self.http_proxy, self.busy))
|
||||
.child(muted("留空时使用系统代理开关的选择。支持 HTTP 和 SOCKS 代理。"))
|
||||
.child(field("GitHub 代理", &self.github_proxy, self.busy))
|
||||
.child(div().flex().items_center().justify_between()
|
||||
.child(div().flex().flex_col().gap_2().child("GitHub 连接失败时自动使用 gh-proxy.com")
|
||||
.child(muted("默认开启。仅在网络失败时尝试一次;公开请求将经过第三方代理,仍保留包校验。")))
|
||||
.child(Switch::new("github-auto-fallback").checked(self.github_auto_fallback)
|
||||
.accessibility_label("GitHub 连接失败时自动使用 gh-proxy.com").disabled(self.busy)
|
||||
.on_change(cx.listener(|view, checked, _, cx| { view.github_auto_fallback = *checked; cx.notify(); }))))
|
||||
.child(muted("留空直接访问 GitHub。填写 HTTPS 前缀地址后,GitHub 请求按“代理地址/原始 URL”转发;仍保留包校验。"))
|
||||
.when(self.error.is_some(), |this| this.child(div().text_color(rgb(0xf7a7a7)).child(self.error.clone().unwrap())))
|
||||
.child(div().flex().justify_end().child(Button::new("save-proxy").primary().label("保存")
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Media components
|
||||
|
||||
- Panel capture code in `capture/src/{encoder,gpu,gpu_copy,kms,lut,grab,openvr}.rs`
|
||||
and `capture/shaders/convert.comp` derives from [coah80/framecorder](https://github.com/coah80/framecorder), MIT.
|
||||
The original license is preserved in `capture/LICENSE.framecorder`.
|
||||
Framely adds streaming output, field-of-view offsets, browser-compatible H.264
|
||||
encoding and a core-managed framebuffer helper. It does not invoke or require
|
||||
the Framecorder application or plugin.
|
||||
- [MediaMTX](https://github.com/bluenviron/mediamtx), MIT, supplies RTSP, WHEP/WebRTC
|
||||
and HLS transport. Its version, source URL and archive checksum are pinned in
|
||||
`dependencies.json`; the release includes its license.
|
||||
- [UxPlay](https://github.com/FDH2/UxPlay), GPL-3.0-or-later, supplies AirPlay/RAOP
|
||||
reception. Its version and source checksum are pinned in `dependencies.json`.
|
||||
`patch-uxplay.py` adds session-end events and enforces one sending device.
|
||||
Releases include the patched source archive, build recipe and UxPlay/llhttp licenses.
|
||||
- [gst-libav](https://gstreamer.freedesktop.org/modules/gst-libav.html), LGPL-2.1-or-later,
|
||||
is bundled as a GStreamer plugin linked to the installed system FFmpeg libraries.
|
||||
Its version and checksum are pinned; releases include its source and license.
|
||||
CI links against the FFmpeg 7 development API to match SteamOS; that build
|
||||
dependency is pinned but its FFmpeg libraries are not distributed.
|
||||
- GStreamer and GUPnP are linked as system libraries (LGPL). GStreamer performs
|
||||
decoding, audio playback, rotation and pixel aspect correction; GUPnP provides
|
||||
UPnP discovery, SOAP and event subscriptions. FFmpeg is invoked as the installed
|
||||
system executable for SteamVR capture, software encoding and audio muxing.
|
||||
These system components are not copied into Framely packages.
|
||||
- `native/json.hpp` is the existing nlohmann/json dependency (MIT), included with
|
||||
the main Framely release notices.
|
||||
|
||||
Framely's added code uses the repository's AGPL-3.0-only license. See
|
||||
`docs/casting.md` for build dependencies and verification commands. Source and
|
||||
licenses for the bundled media additions ship under `share/source/media` and
|
||||
`share/licenses` in a release.
|
||||
Generated
+750
@@ -0,0 +1,750 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "ash"
|
||||
version = "0.38.0+1.3.281"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
dependencies = [
|
||||
"bytemuck_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck_derive"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drm"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a41816e58f47f49acfd956651055ddcf137c4882c2098c30c448817af21183a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"bytemuck_derive",
|
||||
"drm-ffi",
|
||||
"drm-fourcc",
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drm-ffi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690"
|
||||
dependencies = [
|
||||
"drm-sys",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drm-fourcc"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4"
|
||||
|
||||
[[package]]
|
||||
name = "drm-sys"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"linux-raw-sys 0.9.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "enumn"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "framely-capture"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
"clap",
|
||||
"drm",
|
||||
"env_logger",
|
||||
"libc",
|
||||
"log",
|
||||
"serde_json",
|
||||
"v4l2r",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-core"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "v4l2r"
|
||||
version = "0.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67d06043afd259b7e3f0b70bb8cc590b35c99006abc595399956392b4ebbe717"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bindgen",
|
||||
"bitflags 2.13.2",
|
||||
"enumn",
|
||||
"log",
|
||||
"nix",
|
||||
"pastey",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "framely-capture"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
ash = { version = "0.38", default-features = false, features = ["loaded", "debug"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
drm = "0.15"
|
||||
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
|
||||
libc = "0.2"
|
||||
log = "0.4"
|
||||
serde_json = "1"
|
||||
v4l2r = "0.0.8"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 coah80
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,16 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let out = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
||||
let src = "shaders/convert.comp";
|
||||
println!("cargo::rerun-if-changed={src}");
|
||||
|
||||
let status = Command::new("glslc")
|
||||
.args(["-O", "--target-env=vulkan1.1", "-o"])
|
||||
.arg(out.join("convert.spv"))
|
||||
.arg(src)
|
||||
.status()
|
||||
.expect("glslc not found, install shaderc");
|
||||
assert!(status.success(), "failed to compile {src}");
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
#version 450
|
||||
|
||||
// Turns the panel scanout into NV12 for the hardware encoder in one pass.
|
||||
//
|
||||
// Every output pixel gets its source position from a coarse lookup grid. The
|
||||
// grid either maps the output to a straight crop of the panel (raw view) or
|
||||
// undoes the lens distortion for one eye. Each grid point stores a position per
|
||||
// color channel, because the compositor pre-corrects chromatic aberration and
|
||||
// we need to undo that too. A negative position means "outside the lens", which
|
||||
// gets painted black.
|
||||
//
|
||||
// One invocation writes a 4x2 block: two words of luma and one word of
|
||||
// interleaved chroma, so there are no byte stores and no atomics. A workgroup
|
||||
// covers 32x16 pixels, which is exactly 4x2 grid cells, so the workgroup pulls
|
||||
// the grid points it needs into shared memory once and every invocation reads
|
||||
// its cell from there.
|
||||
|
||||
#define STEP 8u
|
||||
#define WG_X 8u
|
||||
#define WG_Y 8u
|
||||
#define CELLS_X (WG_X * 4u / STEP)
|
||||
#define CELLS_Y (WG_Y * 2u / STEP)
|
||||
#define POINTS_X (CELLS_X + 1u)
|
||||
#define POINTS_Y (CELLS_Y + 1u)
|
||||
#define POINTS (POINTS_X * POINTS_Y)
|
||||
|
||||
layout(local_size_x = WG_X, local_size_y = WG_Y, local_size_z = 1) in;
|
||||
|
||||
layout(set = 0, binding = 0) uniform sampler2D src_tex;
|
||||
layout(set = 0, binding = 1, std430) readonly buffer Lut { vec2 lut[]; };
|
||||
layout(set = 0, binding = 2, std430) writeonly buffer Nv12 { uint nv12[]; };
|
||||
|
||||
layout(push_constant) uniform Params {
|
||||
uvec2 out_size;
|
||||
uvec2 lut_size;
|
||||
uint y_stride;
|
||||
uint uv_offset;
|
||||
float lut_step;
|
||||
uint flags;
|
||||
} p;
|
||||
|
||||
const uint FLAG_PER_CHANNEL = 1u;
|
||||
const uint FLAG_SUPERSAMPLE = 2u;
|
||||
const uint FLAG_ROTATED_EYES = 4u;
|
||||
const uint FLAG_SHARP = 8u;
|
||||
|
||||
// Fringes narrower than this, in output pixels, aren't worth extra reads.
|
||||
const float FRINGE_MIN = 0.3;
|
||||
|
||||
shared vec2 s_lut[3u * POINTS];
|
||||
|
||||
// One grid cell for one channel: a corner plus how the source moves per
|
||||
// output pixel along x and y, and the bilinear twist term.
|
||||
struct Cell {
|
||||
vec2 origin;
|
||||
vec2 dx;
|
||||
vec2 dy;
|
||||
vec2 dxy;
|
||||
bool valid;
|
||||
};
|
||||
|
||||
Cell load_cell(uint i, uint j, uint c) {
|
||||
uint base = c * POINTS + j * POINTS_X + i;
|
||||
vec2 a = s_lut[base];
|
||||
vec2 b = s_lut[base + 1u];
|
||||
vec2 d = s_lut[base + POINTS_X];
|
||||
vec2 e = s_lut[base + POINTS_X + 1u];
|
||||
|
||||
Cell cell;
|
||||
cell.valid = min(min(a.x, b.x), min(d.x, e.x)) >= 0.0;
|
||||
const float inv = 1.0 / float(STEP);
|
||||
cell.origin = a;
|
||||
cell.dx = (b - a) * inv;
|
||||
cell.dy = (d - a) * inv;
|
||||
cell.dxy = (e - d - b + a) * (inv * inv);
|
||||
return cell;
|
||||
}
|
||||
|
||||
// Largest distance between two channels' positions at the cell's corners.
|
||||
float cell_split(Cell a, Cell b) {
|
||||
const float s = float(STEP);
|
||||
vec2 o = a.origin - b.origin;
|
||||
vec2 x = (a.dx - b.dx) * s;
|
||||
vec2 y = (a.dy - b.dy) * s;
|
||||
vec2 xy = (a.dxy - b.dxy) * (s * s);
|
||||
return max(max(length(o), length(o + x)), max(length(o + y), length(o + x + y + xy)));
|
||||
}
|
||||
|
||||
vec2 cell_uv(Cell cell, vec2 local) {
|
||||
return cell.origin + cell.dx * local.x + cell.dy * local.y + cell.dxy * (local.x * local.y);
|
||||
}
|
||||
|
||||
// The grid is in the eyes' own panel space, as SteamVR describes it. The
|
||||
// Frame scans each eye's half out turned 180° in place, so turn it back
|
||||
// here, per sample, which keeps the seam between the eyes clean.
|
||||
// Catmull-Rom in 5 bilinear taps (the four far corners dropped, they
|
||||
// barely matter). Crisper than plain bilinear, which matters here because
|
||||
// the panel image has already been resampled once by the compositor.
|
||||
vec4 catmull_rom(vec2 uv) {
|
||||
vec2 size = vec2(textureSize(src_tex, 0));
|
||||
vec2 pos = uv * size;
|
||||
vec2 center = floor(pos - 0.5) + 0.5;
|
||||
vec2 f = pos - center;
|
||||
vec2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
||||
vec2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
||||
vec2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
||||
vec2 w3 = f * f * (-0.5 + 0.5 * f);
|
||||
vec2 w12 = w1 + w2;
|
||||
vec2 tc0 = (center - 1.0) / size;
|
||||
vec2 tc3 = (center + 2.0) / size;
|
||||
vec2 tc12 = (center + w2 / w12) / size;
|
||||
vec4 c = textureLod(src_tex, vec2(tc12.x, tc0.y), 0.0) * (w12.x * w0.y)
|
||||
+ textureLod(src_tex, vec2(tc0.x, tc12.y), 0.0) * (w0.x * w12.y)
|
||||
+ textureLod(src_tex, tc12, 0.0) * (w12.x * w12.y)
|
||||
+ textureLod(src_tex, vec2(tc3.x, tc12.y), 0.0) * (w3.x * w12.y)
|
||||
+ textureLod(src_tex, vec2(tc12.x, tc3.y), 0.0) * (w12.x * w3.y);
|
||||
float norm = w12.x * w0.y + w0.x * w12.y + w12.x * w12.y + w3.x * w12.y + w12.x * w3.y;
|
||||
return max(c / norm, 0.0);
|
||||
}
|
||||
|
||||
vec4 fetch(vec2 uv) {
|
||||
if ((p.flags & FLAG_ROTATED_EYES) != 0u) {
|
||||
uv = vec2(uv.x < 0.5 ? 0.5 - uv.x : 1.5 - uv.x, 1.0 - uv.y);
|
||||
}
|
||||
if ((p.flags & FLAG_SHARP) != 0u) {
|
||||
return catmull_rom(uv);
|
||||
}
|
||||
return textureLod(src_tex, uv, 0.0);
|
||||
}
|
||||
|
||||
vec4 sample_at(Cell cell, vec2 local) {
|
||||
vec2 uv = cell_uv(cell, local);
|
||||
if ((p.flags & FLAG_SUPERSAMPLE) == 0u) {
|
||||
return fetch(uv);
|
||||
}
|
||||
// 4 taps on a rotated grid, a cheap box filter for when we shrink.
|
||||
vec2 fx = cell.dx + cell.dxy * local.y;
|
||||
vec2 fy = cell.dy + cell.dxy * local.x;
|
||||
return 0.25 * (fetch(uv + fx * -0.375 + fy * -0.125) +
|
||||
fetch(uv + fx * 0.125 + fy * -0.375) +
|
||||
fetch(uv + fx * 0.375 + fy * 0.125) +
|
||||
fetch(uv + fx * -0.125 + fy * 0.375));
|
||||
}
|
||||
|
||||
// BT.709, limited range. The scanout is already gamma encoded, which is what
|
||||
// the video matrix expects, so no transfer conversion here.
|
||||
uint luma(vec3 c) {
|
||||
float y = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
return uint(clamp(16.0 + 219.0 * y + 0.5, 0.0, 255.0));
|
||||
}
|
||||
|
||||
uint chroma(vec3 c) {
|
||||
float y = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
float cb = (c.b - y) / 1.8556;
|
||||
float cr = (c.r - y) / 1.5748;
|
||||
uint u = uint(clamp(128.0 + 224.0 * cb + 0.5, 0.0, 255.0));
|
||||
uint v = uint(clamp(128.0 + 224.0 * cr + 0.5, 0.0, 255.0));
|
||||
return u | (v << 8);
|
||||
}
|
||||
|
||||
void load_grid() {
|
||||
uint li = gl_LocalInvocationIndex;
|
||||
uint channels = (p.flags & FLAG_PER_CHANNEL) != 0u ? 3u : 1u;
|
||||
if (li < channels * POINTS) {
|
||||
// Green goes in slot 1 either way, so single channel mode only loads that.
|
||||
uint c = channels == 3u ? li / POINTS : 1u;
|
||||
uint r = li % POINTS;
|
||||
uint gx = min(gl_WorkGroupID.x * CELLS_X + r % POINTS_X, p.lut_size.x - 1u);
|
||||
uint gy = min(gl_WorkGroupID.y * CELLS_Y + r / POINTS_X, p.lut_size.y - 1u);
|
||||
s_lut[c * POINTS + r] = lut[(gy * p.lut_size.x + gx) * 3u + c];
|
||||
}
|
||||
barrier();
|
||||
}
|
||||
|
||||
void main() {
|
||||
load_grid();
|
||||
|
||||
uint x0 = gl_GlobalInvocationID.x * 4u;
|
||||
uint y0 = gl_GlobalInvocationID.y * 2u;
|
||||
if (x0 >= p.out_size.x || y0 >= p.out_size.y) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint ci = gl_LocalInvocationID.x * 4u / STEP;
|
||||
uint cj = gl_LocalInvocationID.y * 2u / STEP;
|
||||
vec2 base = vec2(float(x0 % STEP), float(y0 % STEP)) + 0.5;
|
||||
|
||||
Cell green = load_cell(ci, cj, 1u);
|
||||
Cell red = green;
|
||||
Cell blue = green;
|
||||
bool per_channel = false;
|
||||
if ((p.flags & FLAG_PER_CHANNEL) != 0u) {
|
||||
red = load_cell(ci, cj, 0u);
|
||||
blue = load_cell(ci, cj, 2u);
|
||||
// Near the middle of the lens the channels line up; one read per
|
||||
// pixel is enough there, and that's most of the picture.
|
||||
float limit = FRINGE_MIN * length(green.dx);
|
||||
per_channel = cell_split(red, green) > limit || cell_split(blue, green) > limit;
|
||||
}
|
||||
|
||||
vec3 px[8];
|
||||
for (uint k = 0u; k < 8u; k++) {
|
||||
vec2 local = base + vec2(float(k & 3u), float(k >> 2));
|
||||
if (!green.valid) {
|
||||
px[k] = vec3(0.0);
|
||||
} else if (!per_channel) {
|
||||
px[k] = sample_at(green, local).rgb;
|
||||
} else {
|
||||
px[k] = vec3(red.valid ? sample_at(red, local).r : 0.0,
|
||||
sample_at(green, local).g,
|
||||
blue.valid ? sample_at(blue, local).b : 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
uint row0 = 0u;
|
||||
uint row1 = 0u;
|
||||
for (uint dx = 0u; dx < 4u; dx++) {
|
||||
row0 |= luma(px[dx]) << (8u * dx);
|
||||
row1 |= luma(px[4u + dx]) << (8u * dx);
|
||||
}
|
||||
nv12[(y0 * p.y_stride + x0) >> 2] = row0;
|
||||
nv12[((y0 + 1u) * p.y_stride + x0) >> 2] = row1;
|
||||
|
||||
vec3 left = (px[0] + px[1] + px[4] + px[5]) * 0.25;
|
||||
vec3 right = (px[2] + px[3] + px[6] + px[7]) * 0.25;
|
||||
nv12[(p.uv_offset + (y0 >> 1) * p.y_stride + x0) >> 2] = chroma(left) | (chroma(right) << 16);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
fn main() { if let Err(e) = framely_capture::grab::serve(&std::path::PathBuf::from("/dev/dri/card0")) { eprintln!("{e:#}"); std::process::exit(1); } }
|
||||
@@ -0,0 +1,8 @@
|
||||
use std::time::Duration;
|
||||
|
||||
/// CLOCK_MONOTONIC, the same clock DRM vblank stamps and PipeWire use.
|
||||
pub fn now() -> Duration {
|
||||
let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
|
||||
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
|
||||
Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
//! Drives the Snapdragon's hardware video encoder (qcom iris) through the
|
||||
//! V4L2 stateful encoder interface. Frames go in as dmabufs straight from the
|
||||
//! GPU, compressed packets come out of mmapped buffers.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io;
|
||||
use std::os::fd::{AsRawFd, RawFd};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use v4l2r::bindings as v4l2;
|
||||
|
||||
use crate::gpu::Nv12Layout;
|
||||
|
||||
const BUF_TYPE_CAPTURE: u32 = 9; // VIDEO_CAPTURE_MPLANE
|
||||
const BUF_TYPE_OUTPUT: u32 = 10; // VIDEO_OUTPUT_MPLANE
|
||||
const MEMORY_MMAP: u32 = 1;
|
||||
const MEMORY_DMABUF: u32 = 4;
|
||||
const FIELD_NONE: u32 = 1;
|
||||
const BUF_FLAG_KEYFRAME: u32 = 0x8;
|
||||
const BUF_FLAG_ERROR: u32 = 0x40;
|
||||
const BUF_FLAG_LAST: u32 = 0x0010_0000;
|
||||
const ENC_CMD_STOP: u32 = 1;
|
||||
|
||||
const COLORSPACE_REC709: u32 = 3;
|
||||
const YCBCR_ENC_709: u8 = 2;
|
||||
const QUANTIZATION_LIM_RANGE: u8 = 2;
|
||||
const XFER_FUNC_709: u8 = 1;
|
||||
|
||||
const CID_CODEC_BASE: u32 = 0x0099_0900;
|
||||
const CID_B_FRAMES: u32 = CID_CODEC_BASE + 202;
|
||||
const CID_GOP_SIZE: u32 = CID_CODEC_BASE + 203;
|
||||
const CID_BITRATE_MODE: u32 = CID_CODEC_BASE + 206;
|
||||
const CID_BITRATE: u32 = CID_CODEC_BASE + 207;
|
||||
const CID_BITRATE_PEAK: u32 = CID_CODEC_BASE + 208;
|
||||
const CID_FRAME_RC_ENABLE: u32 = CID_CODEC_BASE + 215;
|
||||
const CID_HEADER_MODE: u32 = CID_CODEC_BASE + 216;
|
||||
const CID_FORCE_KEY_FRAME: u32 = CID_CODEC_BASE + 229;
|
||||
const CID_H264_I_FRAME_QP: u32 = CID_CODEC_BASE + 350;
|
||||
const CID_H264_P_FRAME_QP: u32 = CID_CODEC_BASE + 351;
|
||||
const CID_H264_LEVEL: u32 = CID_CODEC_BASE + 359;
|
||||
const CID_H264_PROFILE: u32 = CID_CODEC_BASE + 363;
|
||||
const CID_HEVC_I_FRAME_QP: u32 = CID_CODEC_BASE + 602;
|
||||
const CID_HEVC_P_FRAME_QP: u32 = CID_CODEC_BASE + 603;
|
||||
const CID_HEVC_PROFILE: u32 = CID_CODEC_BASE + 615;
|
||||
const CID_HEVC_LEVEL: u32 = CID_CODEC_BASE + 616;
|
||||
const CID_HEVC_TIER: u32 = CID_CODEC_BASE + 618;
|
||||
|
||||
const CAPTURE_BUFFERS: u32 = 4;
|
||||
const STOP_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
|
||||
pub enum Codec {
|
||||
#[value(name = "h265", alias = "hevc")]
|
||||
Hevc,
|
||||
H264,
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
fn fourcc(self) -> u32 {
|
||||
u32::from_le_bytes(match self {
|
||||
Codec::Hevc => *b"HEVC",
|
||||
Codec::H264 => *b"H264",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Config {
|
||||
pub codec: Codec,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
pub bitrate: u32,
|
||||
pub qp: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct Packet<'a> {
|
||||
pub data: &'a [u8],
|
||||
pub pts_us: u64,
|
||||
pub key: bool,
|
||||
}
|
||||
|
||||
struct Mapping {
|
||||
ptr: *mut u8,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl Drop for Mapping {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::munmap(self.ptr.cast(), self.len) };
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Encoder {
|
||||
file: File,
|
||||
layout: Nv12Layout,
|
||||
outputs: Vec<bool>,
|
||||
captures: Vec<Mapping>,
|
||||
streaming: bool,
|
||||
}
|
||||
|
||||
// The mmapped capture buffers are only touched from the thread that owns the encoder.
|
||||
unsafe impl Send for Encoder {}
|
||||
|
||||
// asm-generic ioctl encoding, which is what arm64 uses.
|
||||
const fn ioc(dir: u32, nr: u32, size: usize) -> libc::c_ulong {
|
||||
((dir << 30) | ((size as u32) << 16) | ((b'V' as u32) << 8) | nr) as libc::c_ulong
|
||||
}
|
||||
const RW: u32 = 3;
|
||||
const W: u32 = 1;
|
||||
const VIDIOC_S_FMT: libc::c_ulong = ioc(RW, 5, std::mem::size_of::<v4l2::v4l2_format>());
|
||||
const VIDIOC_REQBUFS: libc::c_ulong = ioc(RW, 8, std::mem::size_of::<v4l2::v4l2_requestbuffers>());
|
||||
const VIDIOC_QUERYBUF: libc::c_ulong = ioc(RW, 9, std::mem::size_of::<v4l2::v4l2_buffer>());
|
||||
const VIDIOC_QBUF: libc::c_ulong = ioc(RW, 15, std::mem::size_of::<v4l2::v4l2_buffer>());
|
||||
const VIDIOC_DQBUF: libc::c_ulong = ioc(RW, 17, std::mem::size_of::<v4l2::v4l2_buffer>());
|
||||
const VIDIOC_STREAMON: libc::c_ulong = ioc(W, 18, std::mem::size_of::<libc::c_int>());
|
||||
const VIDIOC_STREAMOFF: libc::c_ulong = ioc(W, 19, std::mem::size_of::<libc::c_int>());
|
||||
const VIDIOC_S_PARM: libc::c_ulong = ioc(RW, 22, std::mem::size_of::<v4l2::v4l2_streamparm>());
|
||||
const VIDIOC_S_CTRL: libc::c_ulong = ioc(RW, 28, std::mem::size_of::<v4l2::v4l2_control>());
|
||||
const VIDIOC_ENCODER_CMD: libc::c_ulong = ioc(RW, 77, std::mem::size_of::<EncoderCmd>());
|
||||
const VIDIOC_S_SELECTION: libc::c_ulong = ioc(RW, 95, std::mem::size_of::<v4l2::v4l2_selection>());
|
||||
const BUF_TYPE_OUTPUT_SINGLE: u32 = 2;
|
||||
const SEL_TGT_CROP: u32 = 0;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
struct EncoderCmd {
|
||||
cmd: u32,
|
||||
flags: u32,
|
||||
data: [u32; 8],
|
||||
}
|
||||
|
||||
unsafe fn xioctl<T>(fd: RawFd, req: libc::c_ulong, arg: *mut T) -> io::Result<()> {
|
||||
loop {
|
||||
if libc::ioctl(fd, req, arg) == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let err = io::Error::last_os_error();
|
||||
if err.kind() != io::ErrorKind::Interrupted {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_crop(fd: RawFd, width: u32, height: u32) -> Result<()> {
|
||||
// Some drivers want the single planar type here even on mplane queues.
|
||||
let mut last = None;
|
||||
for ty in [BUF_TYPE_OUTPUT, BUF_TYPE_OUTPUT_SINGLE] {
|
||||
let mut sel: v4l2::v4l2_selection = unsafe { std::mem::zeroed() };
|
||||
sel.type_ = ty;
|
||||
sel.target = SEL_TGT_CROP;
|
||||
sel.r.width = width;
|
||||
sel.r.height = height;
|
||||
match unsafe { xioctl(fd, VIDIOC_S_SELECTION, &mut sel) } {
|
||||
Ok(()) if sel.r.width == width && sel.r.height == height => return Ok(()),
|
||||
Ok(()) => bail!("the encoder cropped to {}x{} instead of {width}x{height}", sel.r.width, sel.r.height),
|
||||
Err(e) => last = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last.unwrap()).context("setting the encoder crop")
|
||||
}
|
||||
|
||||
fn peak_bitrate(bitrate: u32) -> u32 {
|
||||
(bitrate as u64 * 3 / 2).min(245_000_000) as u32
|
||||
}
|
||||
|
||||
/// Lowest HEVC level (V4L2 menu index) and tier that fit the stream.
|
||||
/// Limits from H.265 Annex A: luma picture size, luma samples per second,
|
||||
/// and Main / High tier bitrate in kbit/s.
|
||||
fn hevc_level(width: u32, height: u32, fps: u32, peak_kbps: u32) -> (i32, bool) {
|
||||
const LEVELS: [(i32, u64, u64, u32, u32); 7] = [
|
||||
(4, 2_228_224, 66_846_720, 12_000, 30_000), // 4
|
||||
(6, 2_228_224, 133_693_440, 20_000, 50_000), // 4.1
|
||||
(7, 8_912_896, 267_386_880, 25_000, 100_000), // 5
|
||||
(8, 8_912_896, 534_773_760, 40_000, 160_000), // 5.1
|
||||
(9, 8_912_896, 1_069_547_520, 60_000, 240_000), // 5.2
|
||||
(10, 35_651_584, 1_069_547_520, 60_000, 240_000), // 6
|
||||
(11, 35_651_584, 2_139_095_040, 120_000, 480_000), // 6.1
|
||||
];
|
||||
let size = width as u64 * height as u64;
|
||||
let rate = size * fps as u64;
|
||||
for &(level, max_size, max_rate, main, high) in &LEVELS {
|
||||
if size <= max_size && rate <= max_rate {
|
||||
if peak_kbps <= main {
|
||||
return (level, false);
|
||||
}
|
||||
if peak_kbps <= high {
|
||||
return (level, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
(11, true)
|
||||
}
|
||||
|
||||
/// Lowest H.264 level (V4L2 menu index) that fits the stream. Limits from
|
||||
/// H.264 Annex A in macroblocks, with High profile's 1.25x bitrate allowance.
|
||||
fn h264_level(width: u32, height: u32, fps: u32, peak_kbps: u32) -> i32 {
|
||||
const LEVELS: [(i32, u64, u64, u32); 5] = [
|
||||
(12, 8_192, 245_760, 62_500), // 4.1
|
||||
(13, 8_704, 522_240, 62_500), // 4.2
|
||||
(14, 22_080, 589_824, 168_750), // 5
|
||||
(15, 36_864, 983_040, 300_000), // 5.1
|
||||
(16, 36_864, 2_073_600, 300_000), // 5.2
|
||||
];
|
||||
let mbs = width.div_ceil(16) as u64 * height.div_ceil(16) as u64;
|
||||
let rate = mbs * fps as u64;
|
||||
LEVELS
|
||||
.iter()
|
||||
.find(|&&(_, max_fs, max_rate, max_kbps)| mbs <= max_fs && rate <= max_rate && peak_kbps <= max_kbps)
|
||||
.map_or(16, |l| l.0)
|
||||
}
|
||||
|
||||
/// Finds the hardware encoder node.
|
||||
pub fn find_device() -> Result<PathBuf> {
|
||||
let link = Path::new("/dev/video-enc0");
|
||||
if link.exists() {
|
||||
return Ok(link.into());
|
||||
}
|
||||
for entry in std::fs::read_dir("/sys/class/video4linux")? {
|
||||
let entry = entry?;
|
||||
let name = std::fs::read_to_string(entry.path().join("name")).unwrap_or_default();
|
||||
if name.contains("encoder") {
|
||||
return Ok(Path::new("/dev").join(entry.file_name()));
|
||||
}
|
||||
}
|
||||
bail!("no hardware video encoder found")
|
||||
}
|
||||
|
||||
impl Encoder {
|
||||
pub fn open(path: &Path, cfg: Config, output_buffers: u32) -> Result<Self> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC)
|
||||
.open(path)
|
||||
.with_context(|| format!("opening encoder {}", path.display()))?;
|
||||
let fd = file.as_raw_fd();
|
||||
|
||||
unsafe {
|
||||
// Coded format first, the raw side's constraints depend on it.
|
||||
let mut fmt: v4l2::v4l2_format = std::mem::zeroed();
|
||||
fmt.type_ = BUF_TYPE_CAPTURE;
|
||||
fmt.fmt.pix_mp.width = cfg.width;
|
||||
fmt.fmt.pix_mp.height = cfg.height;
|
||||
fmt.fmt.pix_mp.pixelformat = cfg.codec.fourcc();
|
||||
fmt.fmt.pix_mp.field = FIELD_NONE;
|
||||
fmt.fmt.pix_mp.num_planes = 1;
|
||||
xioctl(fd, VIDIOC_S_FMT, &mut fmt).context("setting coded format")?;
|
||||
if fmt.fmt.pix_mp.pixelformat != cfg.codec.fourcc() {
|
||||
bail!("the encoder doesn't do {:?}", cfg.codec);
|
||||
}
|
||||
|
||||
let mut fmt: v4l2::v4l2_format = std::mem::zeroed();
|
||||
fmt.type_ = BUF_TYPE_OUTPUT;
|
||||
let pix = &mut fmt.fmt.pix_mp;
|
||||
pix.width = cfg.width;
|
||||
pix.height = cfg.height;
|
||||
pix.pixelformat = u32::from_le_bytes(*b"NV12");
|
||||
pix.field = FIELD_NONE;
|
||||
pix.num_planes = 1;
|
||||
pix.colorspace = COLORSPACE_REC709;
|
||||
pix.__bindgen_anon_1.ycbcr_enc = YCBCR_ENC_709;
|
||||
pix.quantization = QUANTIZATION_LIM_RANGE;
|
||||
pix.xfer_func = XFER_FUNC_709;
|
||||
xioctl(fd, VIDIOC_S_FMT, &mut fmt).context("setting raw format")?;
|
||||
let pix = fmt.fmt.pix_mp;
|
||||
let (got_w, got_h) = (pix.width, pix.height);
|
||||
if got_w < cfg.width || got_h < cfg.height {
|
||||
bail!("the encoder wants {got_w}x{got_h} instead of {}x{}", cfg.width, cfg.height);
|
||||
}
|
||||
if (got_w, got_h) != (cfg.width, cfg.height) {
|
||||
// Buffers are padded to the macroblock size, the crop says what's real.
|
||||
set_crop(fd, cfg.width, cfg.height)?;
|
||||
}
|
||||
|
||||
let plane = pix.plane_fmt[0];
|
||||
let (y_stride, size) = (plane.bytesperline, plane.sizeimage);
|
||||
// Venus/iris NV12: luma rows padded to 32, chroma follows right after.
|
||||
let uv_offset = y_stride * got_h.next_multiple_of(32);
|
||||
let expected = uv_offset + y_stride * (got_h / 2).next_multiple_of(16);
|
||||
if y_stride % 4 != 0 || uv_offset % 4 != 0 || expected > size {
|
||||
bail!("unexpected NV12 layout from the encoder: stride {y_stride}, size {size}");
|
||||
}
|
||||
log::info!("encoder input: NV12 stride {y_stride}, chroma at {uv_offset}, {size} bytes");
|
||||
|
||||
let layout = Nv12Layout { width: cfg.width, height: cfg.height, y_stride, uv_offset, size };
|
||||
|
||||
for ty in [BUF_TYPE_OUTPUT, BUF_TYPE_CAPTURE] {
|
||||
let mut parm: v4l2::v4l2_streamparm = std::mem::zeroed();
|
||||
parm.type_ = ty;
|
||||
// Same offset for capture and output.
|
||||
parm.parm.output.timeperframe.numerator = 1;
|
||||
parm.parm.output.timeperframe.denominator = cfg.fps;
|
||||
if let Err(e) = xioctl(fd, VIDIOC_S_PARM, &mut parm) {
|
||||
log::warn!("couldn't set frame rate on queue {ty}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let mut enc = Self { file, layout, outputs: Vec::new(), captures: Vec::new(), streaming: false };
|
||||
enc.set_controls(&cfg);
|
||||
enc.setup_buffers(output_buffers)?;
|
||||
Ok(enc)
|
||||
}
|
||||
}
|
||||
|
||||
fn set_ctrl(&self, name: &str, id: u32, value: i32) {
|
||||
let mut ctrl = v4l2::v4l2_control { id, value };
|
||||
if let Err(e) = unsafe { xioctl(self.file.as_raw_fd(), VIDIOC_S_CTRL, &mut ctrl) } {
|
||||
log::warn!("encoder ignored {name}={value}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn set_controls(&self, cfg: &Config) {
|
||||
let gop = cfg.fps as i32;
|
||||
self.set_ctrl("b_frames", CID_B_FRAMES, 0);
|
||||
self.set_ctrl("gop_size", CID_GOP_SIZE, gop);
|
||||
self.set_ctrl("header_mode", CID_HEADER_MODE, 0);
|
||||
|
||||
// With a fixed quantizer the bitrate is whatever it turns out to be,
|
||||
// so leave room for the top of the range.
|
||||
let peak_kbps = if cfg.qp.is_some() { 160_000 } else { peak_bitrate(cfg.bitrate) / 1000 };
|
||||
match cfg.codec {
|
||||
Codec::Hevc => {
|
||||
let (level, high_tier) = hevc_level(cfg.width, cfg.height, cfg.fps, peak_kbps);
|
||||
self.set_ctrl("hevc_profile", CID_HEVC_PROFILE, 0); // Main
|
||||
self.set_ctrl("hevc_tier", CID_HEVC_TIER, high_tier as i32);
|
||||
self.set_ctrl("hevc_level", CID_HEVC_LEVEL, level);
|
||||
}
|
||||
Codec::H264 => {
|
||||
self.set_ctrl("h264_profile", CID_H264_PROFILE, 0); // Baseline for browser interoperability
|
||||
self.set_ctrl("h264_level", CID_H264_LEVEL, h264_level(cfg.width, cfg.height, cfg.fps, peak_kbps));
|
||||
}
|
||||
}
|
||||
|
||||
match cfg.qp {
|
||||
Some(qp) => {
|
||||
self.set_ctrl("frame_rc", CID_FRAME_RC_ENABLE, 0);
|
||||
let (i, p) = match cfg.codec {
|
||||
Codec::Hevc => (CID_HEVC_I_FRAME_QP, CID_HEVC_P_FRAME_QP),
|
||||
Codec::H264 => (CID_H264_I_FRAME_QP, CID_H264_P_FRAME_QP),
|
||||
};
|
||||
self.set_ctrl("i_qp", i, qp as i32);
|
||||
self.set_ctrl("p_qp", p, qp as i32);
|
||||
}
|
||||
None => {
|
||||
self.set_ctrl("frame_rc", CID_FRAME_RC_ENABLE, 1);
|
||||
self.set_ctrl("bitrate_mode", CID_BITRATE_MODE, 0); // VBR
|
||||
self.set_ctrl("bitrate", CID_BITRATE, cfg.bitrate as i32);
|
||||
self.set_ctrl("bitrate_peak", CID_BITRATE_PEAK, peak_bitrate(cfg.bitrate) as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn setup_buffers(&mut self, output_buffers: u32) -> Result<()> {
|
||||
let fd = self.file.as_raw_fd();
|
||||
|
||||
let mut req: v4l2::v4l2_requestbuffers = std::mem::zeroed();
|
||||
req.count = output_buffers;
|
||||
req.type_ = BUF_TYPE_OUTPUT;
|
||||
req.memory = MEMORY_DMABUF;
|
||||
xioctl(fd, VIDIOC_REQBUFS, &mut req).context("allocating encoder input slots")?;
|
||||
if req.count < output_buffers {
|
||||
bail!("encoder only gave us {} input slots", req.count);
|
||||
}
|
||||
self.outputs = vec![false; output_buffers as usize];
|
||||
|
||||
let mut req: v4l2::v4l2_requestbuffers = std::mem::zeroed();
|
||||
req.count = CAPTURE_BUFFERS;
|
||||
req.type_ = BUF_TYPE_CAPTURE;
|
||||
req.memory = MEMORY_MMAP;
|
||||
xioctl(fd, VIDIOC_REQBUFS, &mut req).context("allocating encoder output buffers")?;
|
||||
|
||||
for index in 0..req.count {
|
||||
let mut plane: v4l2::v4l2_plane = std::mem::zeroed();
|
||||
let mut buf: v4l2::v4l2_buffer = std::mem::zeroed();
|
||||
buf.index = index;
|
||||
buf.type_ = BUF_TYPE_CAPTURE;
|
||||
buf.memory = MEMORY_MMAP;
|
||||
buf.length = 1;
|
||||
buf.m.planes = &mut plane;
|
||||
xioctl(fd, VIDIOC_QUERYBUF, &mut buf).context("querying encoder output buffer")?;
|
||||
|
||||
let len = plane.length as usize;
|
||||
let ptr = libc::mmap(
|
||||
std::ptr::null_mut(),
|
||||
len,
|
||||
libc::PROT_READ,
|
||||
libc::MAP_SHARED,
|
||||
fd,
|
||||
plane.m.mem_offset as libc::off_t,
|
||||
);
|
||||
if ptr == libc::MAP_FAILED {
|
||||
return Err(io::Error::last_os_error()).context("mapping encoder output buffer");
|
||||
}
|
||||
self.captures.push(Mapping { ptr: ptr.cast(), len });
|
||||
self.queue_capture(index)?;
|
||||
}
|
||||
log::debug!("encoder: {} input slots, {} output buffers", self.outputs.len(), self.captures.len());
|
||||
|
||||
for ty in [BUF_TYPE_OUTPUT, BUF_TYPE_CAPTURE] {
|
||||
let mut t = ty as libc::c_int;
|
||||
xioctl(fd, VIDIOC_STREAMON, &mut t).context("starting the encoder")?;
|
||||
}
|
||||
self.streaming = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn queue_capture(&self, index: u32) -> Result<()> {
|
||||
unsafe {
|
||||
let mut plane: v4l2::v4l2_plane = std::mem::zeroed();
|
||||
plane.length = self.captures[index as usize].len as u32;
|
||||
let mut buf: v4l2::v4l2_buffer = std::mem::zeroed();
|
||||
buf.index = index;
|
||||
buf.type_ = BUF_TYPE_CAPTURE;
|
||||
buf.memory = MEMORY_MMAP;
|
||||
buf.length = 1;
|
||||
buf.m.planes = &mut plane;
|
||||
xioctl(self.file.as_raw_fd(), VIDIOC_QBUF, &mut buf).context("queueing encoder output buffer")
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the next frame a keyframe, so a recording started mid-session
|
||||
/// doesn't wait for the next one.
|
||||
pub fn force_keyframe(&self) {
|
||||
self.set_ctrl("force_key_frame", CID_FORCE_KEY_FRAME, 1);
|
||||
}
|
||||
|
||||
pub fn layout(&self) -> Nv12Layout {
|
||||
self.layout
|
||||
}
|
||||
|
||||
/// An input slot the GPU can write into, or None if the encoder is
|
||||
/// still chewing on all of them.
|
||||
pub fn free_slot(&mut self) -> Result<Option<usize>> {
|
||||
self.reclaim_outputs()?;
|
||||
Ok(self.outputs.iter().position(|busy| !busy))
|
||||
}
|
||||
|
||||
fn reclaim_outputs(&mut self) -> Result<()> {
|
||||
loop {
|
||||
let mut plane: v4l2::v4l2_plane = unsafe { std::mem::zeroed() };
|
||||
let mut buf: v4l2::v4l2_buffer = unsafe { std::mem::zeroed() };
|
||||
buf.type_ = BUF_TYPE_OUTPUT;
|
||||
buf.memory = MEMORY_DMABUF;
|
||||
buf.length = 1;
|
||||
buf.m.planes = &mut plane;
|
||||
match unsafe { xioctl(self.file.as_raw_fd(), VIDIOC_DQBUF, &mut buf) } {
|
||||
Ok(()) => self.outputs[buf.index as usize] = false,
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(()),
|
||||
Err(e) if e.raw_os_error() == Some(libc::EPIPE) => return Ok(()),
|
||||
Err(e) => return Err(e).context("reclaiming encoder input"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn queue_frame(&mut self, slot: usize, dmabuf: RawFd, pts_us: u64) -> Result<()> {
|
||||
unsafe {
|
||||
let mut plane: v4l2::v4l2_plane = std::mem::zeroed();
|
||||
plane.bytesused = self.layout.size;
|
||||
plane.length = self.layout.size;
|
||||
plane.m.fd = dmabuf;
|
||||
let mut buf: v4l2::v4l2_buffer = std::mem::zeroed();
|
||||
buf.index = slot as u32;
|
||||
buf.type_ = BUF_TYPE_OUTPUT;
|
||||
buf.memory = MEMORY_DMABUF;
|
||||
buf.field = FIELD_NONE;
|
||||
buf.length = 1;
|
||||
buf.m.planes = &mut plane;
|
||||
buf.timestamp.tv_sec = (pts_us / 1_000_000) as _;
|
||||
buf.timestamp.tv_usec = (pts_us % 1_000_000) as _;
|
||||
xioctl(self.file.as_raw_fd(), VIDIOC_QBUF, &mut buf).context("feeding the encoder")?;
|
||||
}
|
||||
self.outputs[slot] = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hands every finished packet to `sink`. Returns true once the encoder
|
||||
/// has sent its last packet after `finish`.
|
||||
pub fn poll_packets(&mut self, mut sink: impl FnMut(Packet)) -> Result<bool> {
|
||||
loop {
|
||||
let mut plane: v4l2::v4l2_plane = unsafe { std::mem::zeroed() };
|
||||
let mut buf: v4l2::v4l2_buffer = unsafe { std::mem::zeroed() };
|
||||
buf.type_ = BUF_TYPE_CAPTURE;
|
||||
buf.memory = MEMORY_MMAP;
|
||||
buf.length = 1;
|
||||
buf.m.planes = &mut plane;
|
||||
match unsafe { xioctl(self.file.as_raw_fd(), VIDIOC_DQBUF, &mut buf) } {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(false),
|
||||
Err(e) if e.raw_os_error() == Some(libc::EPIPE) => return Ok(true),
|
||||
Err(e) => return Err(e).context("reading from the encoder"),
|
||||
}
|
||||
|
||||
let map = &self.captures[buf.index as usize];
|
||||
let start = (plane.data_offset as usize).min(map.len);
|
||||
let end = (plane.bytesused as usize).min(map.len);
|
||||
if buf.flags & BUF_FLAG_ERROR != 0 {
|
||||
log::warn!("encoder flagged a corrupt packet, dropping it");
|
||||
} else if end > start {
|
||||
let data = unsafe { std::slice::from_raw_parts(map.ptr.add(start), end - start) };
|
||||
sink(Packet {
|
||||
data,
|
||||
pts_us: buf.timestamp.tv_sec as u64 * 1_000_000 + buf.timestamp.tv_usec as u64,
|
||||
key: buf.flags & BUF_FLAG_KEYFRAME != 0,
|
||||
});
|
||||
}
|
||||
|
||||
if buf.flags & BUF_FLAG_LAST != 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
self.queue_capture(buf.index)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Flushes everything still inside the encoder.
|
||||
pub fn finish(&mut self, mut sink: impl FnMut(Packet)) -> Result<()> {
|
||||
let mut cmd = EncoderCmd { cmd: ENC_CMD_STOP, ..Default::default() };
|
||||
if let Err(e) = unsafe { xioctl(self.file.as_raw_fd(), VIDIOC_ENCODER_CMD, &mut cmd) } {
|
||||
log::warn!("encoder refused to drain: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
let deadline = Instant::now() + STOP_TIMEOUT;
|
||||
while Instant::now() < deadline {
|
||||
if self.poll_packets(&mut sink)? {
|
||||
return Ok(());
|
||||
}
|
||||
let mut pfd = libc::pollfd { fd: self.file.as_raw_fd(), events: libc::POLLIN, revents: 0 };
|
||||
unsafe { libc::poll(&mut pfd, 1, 50) };
|
||||
}
|
||||
log::warn!("encoder didn't finish draining in time, the last frames may be missing");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Encoder {
|
||||
fn drop(&mut self) {
|
||||
if self.streaming {
|
||||
for ty in [BUF_TYPE_OUTPUT, BUF_TYPE_CAPTURE] {
|
||||
let mut t = ty as libc::c_int;
|
||||
let _ = unsafe { xioctl(self.file.as_raw_fd(), VIDIOC_STREAMOFF, &mut t) };
|
||||
}
|
||||
}
|
||||
self.captures.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hevc_levels() {
|
||||
// A 60 Mbit/s peak is past 4.1 High's 50, so level 5 High. 30 fits 4.1 High.
|
||||
assert_eq!(hevc_level(1920, 1080, 60, 60_000), (7, true));
|
||||
assert_eq!(hevc_level(1920, 1080, 60, 30_000), (6, true));
|
||||
assert_eq!(hevc_level(1920, 1080, 60, 15_000), (6, false));
|
||||
// 1080p72 needs level 5 for the sample rate alone.
|
||||
assert_eq!(hevc_level(1920, 1080, 72, 20_000), (7, false));
|
||||
// Square 1440 at 30 fps is light enough for level 4.
|
||||
assert_eq!(hevc_level(1440, 1440, 30, 10_000), (4, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_levels() {
|
||||
assert_eq!(h264_level(1920, 1080, 60, 60_000), 13);
|
||||
assert_eq!(h264_level(1920, 1080, 72, 60_000), 14);
|
||||
assert_eq!(h264_level(1920, 1080, 30, 120_000), 14);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
//! The only GPU work framely-capture does: one small compute dispatch per recorded
|
||||
//! frame. It reads the compositor's scanout buffer in place and writes NV12
|
||||
//! into dmabufs that the hardware encoder reads directly. No CPU copies.
|
||||
//!
|
||||
//! Its queue priority is picked by the caller, see `Priority`.
|
||||
|
||||
use std::ffi::{c_char, CStr, CString};
|
||||
use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use ash::vk;
|
||||
|
||||
use crate::kms::ScanoutBuffer;
|
||||
|
||||
#[path = "gpu_copy.rs"]
|
||||
mod copy;
|
||||
use crate::lut::Lut;
|
||||
|
||||
const SHADER: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/convert.spv"));
|
||||
const MAX_SOURCES: usize = 8;
|
||||
|
||||
const DRM_FORMAT_XRGB8888: u32 = u32::from_le_bytes(*b"XR24");
|
||||
const DRM_FORMAT_ARGB8888: u32 = u32::from_le_bytes(*b"AR24");
|
||||
const DRM_FORMAT_XBGR8888: u32 = u32::from_le_bytes(*b"XB24");
|
||||
const DRM_FORMAT_ABGR8888: u32 = u32::from_le_bytes(*b"AB24");
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct Params {
|
||||
out_size: [u32; 2],
|
||||
lut_size: [u32; 2],
|
||||
y_stride: u32,
|
||||
uv_offset: u32,
|
||||
lut_step: f32,
|
||||
flags: u32,
|
||||
}
|
||||
|
||||
/// Layout of the NV12 buffers, dictated by the encoder.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Nv12Layout {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub y_stride: u32,
|
||||
pub uv_offset: u32,
|
||||
pub size: u32,
|
||||
}
|
||||
|
||||
struct Source {
|
||||
fb_id: u32,
|
||||
/// Identity of the buffer behind the framebuffer id, see `kms::buffer_id`.
|
||||
buffer: u64,
|
||||
last_used: u64,
|
||||
image: vk::Image,
|
||||
memory: vk::DeviceMemory,
|
||||
view: vk::ImageView,
|
||||
/// One descriptor set and pre-recorded command buffer per target.
|
||||
sets: Vec<vk::DescriptorSet>,
|
||||
cmds: Vec<vk::CommandBuffer>,
|
||||
}
|
||||
|
||||
pub struct Target {
|
||||
buffer: vk::Buffer,
|
||||
memory: vk::DeviceMemory,
|
||||
pub fd: OwnedFd,
|
||||
}
|
||||
|
||||
pub struct Gpu {
|
||||
_entry: ash::Entry,
|
||||
instance: ash::Instance,
|
||||
device: ash::Device,
|
||||
ext_fd: ash::khr::external_memory_fd::Device,
|
||||
mem_props: vk::PhysicalDeviceMemoryProperties,
|
||||
queue: vk::Queue,
|
||||
queue_family: u32,
|
||||
cmd_pool: vk::CommandPool,
|
||||
desc_pool: vk::DescriptorPool,
|
||||
set_layout: vk::DescriptorSetLayout,
|
||||
pipeline_layout: vk::PipelineLayout,
|
||||
pipeline: vk::Pipeline,
|
||||
shader: vk::ShaderModule,
|
||||
sampler: vk::Sampler,
|
||||
fence: vk::Fence,
|
||||
/// Two timestamps around the dispatch, to report what the shader costs.
|
||||
queries: vk::QueryPool,
|
||||
timestamp_ns: f64,
|
||||
lut_buffer: vk::Buffer,
|
||||
lut_memory: vk::DeviceMemory,
|
||||
params: Params,
|
||||
layout: Nv12Layout,
|
||||
targets: Vec<Target>,
|
||||
sources: Vec<Source>,
|
||||
tick: u64,
|
||||
/// Where images handed to us by someone else get copied before converting.
|
||||
copy: Option<CopyTarget>,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
}
|
||||
|
||||
/// How the shader reads the source.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Filter {
|
||||
/// One bilinear read per pixel.
|
||||
Bilinear,
|
||||
/// Catmull-Rom: crisper, five reads per pixel.
|
||||
Sharp,
|
||||
/// Four averaged reads, for when the output shrinks the source a lot.
|
||||
Supersample,
|
||||
}
|
||||
|
||||
/// How our GPU work ranks against the game's. The scanout buffer gets reused
|
||||
/// a frame later, so work that waits for the game to leave the GPU idle can
|
||||
/// miss frames in heavy games.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
|
||||
pub enum Priority {
|
||||
/// Only runs when the game leaves the GPU idle.
|
||||
Low,
|
||||
/// Takes turns with the game.
|
||||
Medium,
|
||||
/// Cuts in ahead of the game, briefly.
|
||||
High,
|
||||
}
|
||||
|
||||
impl Priority {
|
||||
fn vk(self) -> vk::QueueGlobalPriorityKHR {
|
||||
match self {
|
||||
Priority::Low => vk::QueueGlobalPriorityKHR::LOW,
|
||||
Priority::Medium => vk::QueueGlobalPriorityKHR::MEDIUM,
|
||||
Priority::High => vk::QueueGlobalPriorityKHR::HIGH,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensions to enable beyond our own: fixed ones for the instance, and a
|
||||
/// function giving the device ones for the physical device we pick.
|
||||
pub struct Extensions {
|
||||
pub instance: Vec<CString>,
|
||||
pub device: Box<dyn Fn(vk::PhysicalDevice) -> Vec<CString>>,
|
||||
}
|
||||
|
||||
/// Raw handles for handing our device to other Vulkan users.
|
||||
pub struct NativeHandles {
|
||||
pub instance: vk::Instance,
|
||||
pub device: vk::Device,
|
||||
pub physical_device: vk::PhysicalDevice,
|
||||
pub queue: vk::Queue,
|
||||
pub queue_family: u32,
|
||||
}
|
||||
|
||||
struct CopyTarget {
|
||||
pool: vk::CommandPool,
|
||||
cmd: vk::CommandBuffer,
|
||||
image: vk::Image,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// Framebuffer id used for the copy source.
|
||||
const COPY_SOURCE: u32 = u32::MAX;
|
||||
|
||||
impl Gpu {
|
||||
/// `extra` adds extensions someone else needs on our instance and device
|
||||
/// (SteamVR, to hand us its headset view).
|
||||
pub fn new(
|
||||
layout: Nv12Layout,
|
||||
target_count: usize,
|
||||
lut: &Lut,
|
||||
filter: Filter,
|
||||
priority: Priority,
|
||||
extra: Option<&Extensions>,
|
||||
) -> Result<Self> {
|
||||
let entry = unsafe { ash::Entry::load() }.context("loading Vulkan")?;
|
||||
let app = vk::ApplicationInfo::default()
|
||||
.application_name(c"framely-capture")
|
||||
.api_version(vk::API_VERSION_1_1);
|
||||
let instance_exts: Vec<*const c_char> = extra.map_or_else(Vec::new, |e| e.instance.iter().map(|x| x.as_ptr()).collect());
|
||||
let instance = unsafe {
|
||||
entry.create_instance(
|
||||
&vk::InstanceCreateInfo::default().application_info(&app).enabled_extension_names(&instance_exts),
|
||||
None,
|
||||
)
|
||||
}
|
||||
.context("creating Vulkan instance")?;
|
||||
|
||||
Self::with_instance(entry, instance, layout, target_count, lut, filter, priority, extra)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn with_instance(
|
||||
entry: ash::Entry,
|
||||
instance: ash::Instance,
|
||||
layout: Nv12Layout,
|
||||
target_count: usize,
|
||||
lut: &Lut,
|
||||
filter: Filter,
|
||||
priority: Priority,
|
||||
extra: Option<&Extensions>,
|
||||
) -> Result<Self> {
|
||||
let pdev = unsafe { instance.enumerate_physical_devices() }?
|
||||
.into_iter()
|
||||
.next()
|
||||
.context("no Vulkan device")?;
|
||||
let props = unsafe { instance.get_physical_device_properties(pdev) };
|
||||
let name = unsafe { CStr::from_ptr(props.device_name.as_ptr()) };
|
||||
log::info!("gpu: {}", name.to_string_lossy());
|
||||
|
||||
let queue_family = unsafe { instance.get_physical_device_queue_family_properties(pdev) }
|
||||
.iter()
|
||||
.position(|q| q.queue_flags.contains(vk::QueueFlags::COMPUTE))
|
||||
.context("no compute queue")? as u32;
|
||||
|
||||
let mut exts: Vec<*const c_char> = vec![
|
||||
ash::khr::external_memory_fd::NAME.as_ptr(),
|
||||
ash::ext::external_memory_dma_buf::NAME.as_ptr(),
|
||||
ash::ext::image_drm_format_modifier::NAME.as_ptr(),
|
||||
// Required by the modifier extension on Vulkan 1.1.
|
||||
ash::khr::image_format_list::NAME.as_ptr(),
|
||||
ash::ext::queue_family_foreign::NAME.as_ptr(),
|
||||
];
|
||||
let wanted = extra.map(|e| (e.device)(pdev)).unwrap_or_default();
|
||||
for name in &wanted {
|
||||
if !exts.iter().any(|&e| unsafe { CStr::from_ptr(e) } == name.as_c_str()) {
|
||||
exts.push(name.as_ptr());
|
||||
}
|
||||
}
|
||||
// Last, so it can be dropped if the driver refuses the priority.
|
||||
exts.push(ash::ext::global_priority::NAME.as_ptr());
|
||||
let priorities = [0.0f32];
|
||||
let device = [true, false]
|
||||
.into_iter()
|
||||
.find_map(|with_priority| {
|
||||
let mut prio = vk::DeviceQueueGlobalPriorityCreateInfoKHR::default().global_priority(priority.vk());
|
||||
let mut qinfo = vk::DeviceQueueCreateInfo::default()
|
||||
.queue_family_index(queue_family)
|
||||
.queue_priorities(&priorities);
|
||||
if with_priority {
|
||||
qinfo = qinfo.push_next(&mut prio);
|
||||
}
|
||||
let qinfos = [qinfo];
|
||||
let n = if with_priority { exts.len() } else { exts.len() - 1 };
|
||||
let info = vk::DeviceCreateInfo::default()
|
||||
.queue_create_infos(&qinfos)
|
||||
.enabled_extension_names(&exts[..n]);
|
||||
match unsafe { instance.create_device(pdev, &info, None) } {
|
||||
Ok(d) => Some(d),
|
||||
Err(e) => {
|
||||
log::warn!("device creation failed ({priority:?} priority queue: {with_priority}): {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.context("creating Vulkan device")?;
|
||||
|
||||
let ext_fd = ash::khr::external_memory_fd::Device::new(&instance, &device);
|
||||
let mem_props = unsafe { instance.get_physical_device_memory_properties(pdev) };
|
||||
let queue = unsafe { device.get_device_queue(queue_family, 0) };
|
||||
|
||||
let mut gpu = Self {
|
||||
_entry: entry,
|
||||
instance,
|
||||
device,
|
||||
ext_fd,
|
||||
mem_props,
|
||||
timestamp_ns: props.limits.timestamp_period as f64,
|
||||
queue,
|
||||
queue_family,
|
||||
cmd_pool: vk::CommandPool::null(),
|
||||
desc_pool: vk::DescriptorPool::null(),
|
||||
set_layout: vk::DescriptorSetLayout::null(),
|
||||
pipeline_layout: vk::PipelineLayout::null(),
|
||||
pipeline: vk::Pipeline::null(),
|
||||
shader: vk::ShaderModule::null(),
|
||||
sampler: vk::Sampler::null(),
|
||||
fence: vk::Fence::null(),
|
||||
queries: vk::QueryPool::null(),
|
||||
lut_buffer: vk::Buffer::null(),
|
||||
lut_memory: vk::DeviceMemory::null(),
|
||||
params: Params {
|
||||
out_size: [layout.width, layout.height],
|
||||
lut_size: [lut.width, lut.height],
|
||||
y_stride: layout.y_stride,
|
||||
uv_offset: layout.uv_offset,
|
||||
lut_step: lut.step,
|
||||
flags: (lut.per_channel as u32)
|
||||
| (((filter == Filter::Supersample) as u32) << 1)
|
||||
| ((lut.rotated_eyes as u32) << 2)
|
||||
| (((filter == Filter::Sharp) as u32) << 3),
|
||||
},
|
||||
layout,
|
||||
targets: Vec::new(),
|
||||
sources: Vec::new(),
|
||||
tick: 0,
|
||||
copy: None,
|
||||
physical_device: pdev,
|
||||
};
|
||||
gpu.create_pipeline(target_count)?;
|
||||
gpu.upload_lut(lut)?;
|
||||
for _ in 0..target_count {
|
||||
let t = gpu.create_target()?;
|
||||
gpu.targets.push(t);
|
||||
}
|
||||
Ok(gpu)
|
||||
}
|
||||
|
||||
pub fn targets(&self) -> &[Target] {
|
||||
&self.targets
|
||||
}
|
||||
|
||||
fn create_pipeline(&mut self, target_count: usize) -> Result<()> {
|
||||
let d = &self.device;
|
||||
unsafe {
|
||||
let code = ash::util::read_spv(&mut std::io::Cursor::new(SHADER))?;
|
||||
self.shader = d.create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&code), None)?;
|
||||
|
||||
let bindings = [
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(0)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::COMPUTE),
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(1)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::COMPUTE),
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(2)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::COMPUTE),
|
||||
];
|
||||
self.set_layout = d.create_descriptor_set_layout(
|
||||
&vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
|
||||
None,
|
||||
)?;
|
||||
|
||||
let ranges = [vk::PushConstantRange::default()
|
||||
.stage_flags(vk::ShaderStageFlags::COMPUTE)
|
||||
.size(std::mem::size_of::<Params>() as u32)];
|
||||
let set_layouts = [self.set_layout];
|
||||
self.pipeline_layout = d.create_pipeline_layout(
|
||||
&vk::PipelineLayoutCreateInfo::default()
|
||||
.set_layouts(&set_layouts)
|
||||
.push_constant_ranges(&ranges),
|
||||
None,
|
||||
)?;
|
||||
|
||||
let stage = vk::PipelineShaderStageCreateInfo::default()
|
||||
.stage(vk::ShaderStageFlags::COMPUTE)
|
||||
.module(self.shader)
|
||||
.name(c"main");
|
||||
let info = vk::ComputePipelineCreateInfo::default()
|
||||
.stage(stage)
|
||||
.layout(self.pipeline_layout);
|
||||
self.pipeline = d
|
||||
.create_compute_pipelines(vk::PipelineCache::null(), &[info], None)
|
||||
.map_err(|(_, e)| e)?[0];
|
||||
|
||||
self.sampler = d.create_sampler(
|
||||
&vk::SamplerCreateInfo::default()
|
||||
.mag_filter(vk::Filter::LINEAR)
|
||||
.min_filter(vk::Filter::LINEAR)
|
||||
.mipmap_mode(vk::SamplerMipmapMode::NEAREST)
|
||||
.address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE)
|
||||
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE)
|
||||
.address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE),
|
||||
None,
|
||||
)?;
|
||||
|
||||
let max_sets = (MAX_SOURCES * target_count) as u32;
|
||||
let sizes = [
|
||||
vk::DescriptorPoolSize::default()
|
||||
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(max_sets),
|
||||
vk::DescriptorPoolSize::default()
|
||||
.ty(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.descriptor_count(max_sets * 2),
|
||||
];
|
||||
self.desc_pool = d.create_descriptor_pool(
|
||||
&vk::DescriptorPoolCreateInfo::default()
|
||||
.flags(vk::DescriptorPoolCreateFlags::FREE_DESCRIPTOR_SET)
|
||||
.max_sets(max_sets)
|
||||
.pool_sizes(&sizes),
|
||||
None,
|
||||
)?;
|
||||
|
||||
self.cmd_pool = d.create_command_pool(
|
||||
&vk::CommandPoolCreateInfo::default().queue_family_index(self.queue_family),
|
||||
None,
|
||||
)?;
|
||||
self.fence = d.create_fence(&vk::FenceCreateInfo::default(), None)?;
|
||||
self.queries = d.create_query_pool(
|
||||
&vk::QueryPoolCreateInfo::default().query_type(vk::QueryType::TIMESTAMP).query_count(2),
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_memory_type(&self, bits: u32, want: vk::MemoryPropertyFlags) -> Option<u32> {
|
||||
(0..self.mem_props.memory_type_count).find(|&i| {
|
||||
bits & (1 << i) != 0
|
||||
&& self.mem_props.memory_types[i as usize].property_flags.contains(want)
|
||||
})
|
||||
}
|
||||
|
||||
fn upload_lut(&mut self, lut: &Lut) -> Result<()> {
|
||||
let bytes: &[u8] = unsafe {
|
||||
std::slice::from_raw_parts(lut.points.as_ptr().cast(), std::mem::size_of_val(lut.points.as_slice()))
|
||||
};
|
||||
let d = &self.device;
|
||||
unsafe {
|
||||
self.lut_buffer = d.create_buffer(
|
||||
&vk::BufferCreateInfo::default()
|
||||
.size(bytes.len() as u64)
|
||||
.usage(vk::BufferUsageFlags::STORAGE_BUFFER),
|
||||
None,
|
||||
)?;
|
||||
let req = d.get_buffer_memory_requirements(self.lut_buffer);
|
||||
let ty = self
|
||||
.find_memory_type(
|
||||
req.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||
)
|
||||
.context("no host visible memory for the lookup grid")?;
|
||||
self.lut_memory = d.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default().allocation_size(req.size).memory_type_index(ty),
|
||||
None,
|
||||
)?;
|
||||
d.bind_buffer_memory(self.lut_buffer, self.lut_memory, 0)?;
|
||||
let ptr = d.map_memory(self.lut_memory, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())?;
|
||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr.cast(), bytes.len());
|
||||
d.unmap_memory(self.lut_memory);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_target(&self) -> Result<Target> {
|
||||
let d = &self.device;
|
||||
let handle = vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT;
|
||||
unsafe {
|
||||
let mut ext = vk::ExternalMemoryBufferCreateInfo::default().handle_types(handle);
|
||||
let buffer = d.create_buffer(
|
||||
&vk::BufferCreateInfo::default()
|
||||
.size(self.layout.size as u64)
|
||||
.usage(vk::BufferUsageFlags::STORAGE_BUFFER)
|
||||
.push_next(&mut ext),
|
||||
None,
|
||||
)?;
|
||||
let req = d.get_buffer_memory_requirements(buffer);
|
||||
let ty = self
|
||||
.find_memory_type(req.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL)
|
||||
.context("no device memory for encoder buffers")?;
|
||||
let mut export = vk::ExportMemoryAllocateInfo::default().handle_types(handle);
|
||||
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().buffer(buffer);
|
||||
let memory = d.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(ty)
|
||||
.push_next(&mut export)
|
||||
.push_next(&mut dedicated),
|
||||
None,
|
||||
)?;
|
||||
d.bind_buffer_memory(buffer, memory, 0)?;
|
||||
let fd = self.ext_fd.get_memory_fd(
|
||||
&vk::MemoryGetFdInfoKHR::default().memory(memory).handle_type(handle),
|
||||
)?;
|
||||
Ok(Target { buffer, memory, fd: OwnedFd::from_raw_fd(fd) })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_source(&self, fb_id: u32, buffer: u64) -> bool {
|
||||
self.sources.iter().any(|s| s.fb_id == fb_id && s.buffer == buffer)
|
||||
}
|
||||
|
||||
/// Forgets every imported scanout buffer.
|
||||
pub fn clear_sources(&mut self) {
|
||||
let (keep, gone): (Vec<_>, Vec<_>) = std::mem::take(&mut self.sources).into_iter().partition(|s| s.fb_id == COPY_SOURCE);
|
||||
self.sources = keep;
|
||||
for src in gone {
|
||||
self.destroy_source(src);
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports a scanout buffer. The compositor flips between a few buffers,
|
||||
/// so this happens a couple of times at startup and then never again.
|
||||
pub fn add_source(&mut self, buf: ScanoutBuffer, buffer: u64) -> Result<()> {
|
||||
// A recycled framebuffer id now points at a different buffer.
|
||||
if let Some(i) = self.sources.iter().position(|s| s.fb_id == buf.fb_id) {
|
||||
let src = self.sources.swap_remove(i);
|
||||
self.destroy_source(src);
|
||||
}
|
||||
if self.sources.len() >= MAX_SOURCES {
|
||||
let oldest = (0..self.sources.len())
|
||||
.min_by_key(|&i| self.sources[i].last_used)
|
||||
.unwrap();
|
||||
let src = self.sources.swap_remove(oldest);
|
||||
self.destroy_source(src);
|
||||
}
|
||||
let mut src = Source {
|
||||
fb_id: buf.fb_id,
|
||||
buffer,
|
||||
last_used: 0,
|
||||
image: vk::Image::null(),
|
||||
memory: vk::DeviceMemory::null(),
|
||||
view: vk::ImageView::null(),
|
||||
sets: Vec::new(),
|
||||
cmds: Vec::new(),
|
||||
};
|
||||
match self.import(buf, &mut src) {
|
||||
Ok(()) => {
|
||||
self.sources.push(src);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
self.destroy_source(src);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills in `src`; whatever got created is in there even on failure.
|
||||
fn import(&self, buf: ScanoutBuffer, src: &mut Source) -> Result<()> {
|
||||
let format = match buf.fourcc {
|
||||
DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => vk::Format::B8G8R8A8_UNORM,
|
||||
DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => vk::Format::R8G8B8A8_UNORM,
|
||||
other => bail!("unsupported scanout format {:?}", other.to_le_bytes().map(char::from)),
|
||||
};
|
||||
log::info!(
|
||||
"importing scanout fb {} ({}x{}, modifier {:#x}, pitch {})",
|
||||
buf.fb_id,
|
||||
buf.width,
|
||||
buf.height,
|
||||
buf.modifier,
|
||||
buf.pitch
|
||||
);
|
||||
|
||||
let d = &self.device;
|
||||
let handle = vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT;
|
||||
unsafe {
|
||||
let planes = [vk::SubresourceLayout {
|
||||
offset: buf.offset as u64,
|
||||
size: 0,
|
||||
row_pitch: buf.pitch as u64,
|
||||
array_pitch: 0,
|
||||
depth_pitch: 0,
|
||||
}];
|
||||
let mut modifier = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
|
||||
.drm_format_modifier(buf.modifier)
|
||||
.plane_layouts(&planes);
|
||||
let mut ext = vk::ExternalMemoryImageCreateInfo::default().handle_types(handle);
|
||||
src.image = d
|
||||
.create_image(
|
||||
&vk::ImageCreateInfo::default()
|
||||
.image_type(vk::ImageType::TYPE_2D)
|
||||
.format(format)
|
||||
.extent(vk::Extent3D { width: buf.width, height: buf.height, depth: 1 })
|
||||
.mip_levels(1)
|
||||
.array_layers(1)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
|
||||
.usage(vk::ImageUsageFlags::SAMPLED)
|
||||
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED)
|
||||
.push_next(&mut modifier)
|
||||
.push_next(&mut ext),
|
||||
None,
|
||||
)
|
||||
.context("creating image for the scanout buffer (modifier not supported?)")?;
|
||||
let image = src.image;
|
||||
|
||||
let mut fd_props = vk::MemoryFdPropertiesKHR::default();
|
||||
self.ext_fd
|
||||
.get_memory_fd_properties(handle, buf.fd.as_raw_fd(), &mut fd_props)?;
|
||||
let req = d.get_image_memory_requirements(image);
|
||||
let ty = self
|
||||
.find_memory_type(req.memory_type_bits & fd_props.memory_type_bits, vk::MemoryPropertyFlags::empty())
|
||||
.context("no memory type can import the scanout buffer")?;
|
||||
|
||||
// Vulkan takes ownership of the fd on success.
|
||||
let fd = buf.fd.into_raw_fd();
|
||||
let mut import = vk::ImportMemoryFdInfoKHR::default().handle_type(handle).fd(fd);
|
||||
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().image(image);
|
||||
src.memory = match d.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(ty)
|
||||
.push_next(&mut import)
|
||||
.push_next(&mut dedicated),
|
||||
None,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
libc::close(fd);
|
||||
return Err(e).context("importing scanout memory");
|
||||
}
|
||||
};
|
||||
d.bind_image_memory(image, src.memory, 0)?;
|
||||
|
||||
src.view = d.create_image_view(
|
||||
&vk::ImageViewCreateInfo::default()
|
||||
.image(image)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(format)
|
||||
.subresource_range(color_range()),
|
||||
None,
|
||||
)?;
|
||||
|
||||
let layouts = vec![self.set_layout; self.targets.len()];
|
||||
src.sets = d.allocate_descriptor_sets(
|
||||
&vk::DescriptorSetAllocateInfo::default()
|
||||
.descriptor_pool(self.desc_pool)
|
||||
.set_layouts(&layouts),
|
||||
)?;
|
||||
src.cmds = d.allocate_command_buffers(
|
||||
&vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(self.cmd_pool)
|
||||
.level(vk::CommandBufferLevel::PRIMARY)
|
||||
.command_buffer_count(self.targets.len() as u32),
|
||||
)?;
|
||||
|
||||
for (i, target) in self.targets.iter().enumerate() {
|
||||
self.write_set(src.sets[i], src.view, target);
|
||||
self.record(src.cmds[i], src.sets[i], image, target, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn write_set(&self, set: vk::DescriptorSet, view: vk::ImageView, target: &Target) {
|
||||
let image_info = [vk::DescriptorImageInfo::default()
|
||||
.sampler(self.sampler)
|
||||
.image_view(view)
|
||||
.image_layout(vk::ImageLayout::GENERAL)];
|
||||
let lut_info = [vk::DescriptorBufferInfo::default().buffer(self.lut_buffer).range(vk::WHOLE_SIZE)];
|
||||
let out_info = [vk::DescriptorBufferInfo::default().buffer(target.buffer).range(vk::WHOLE_SIZE)];
|
||||
let writes = [
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(set)
|
||||
.dst_binding(0)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.image_info(&image_info),
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(set)
|
||||
.dst_binding(1)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.buffer_info(&lut_info),
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(set)
|
||||
.dst_binding(2)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER)
|
||||
.buffer_info(&out_info),
|
||||
];
|
||||
self.device.update_descriptor_sets(&writes, &[]);
|
||||
}
|
||||
|
||||
/// `external` images belong to someone else (the display) and get
|
||||
/// borrowed and handed back around the dispatch; our own copy doesn't.
|
||||
unsafe fn record(
|
||||
&self,
|
||||
cmd: vk::CommandBuffer,
|
||||
set: vk::DescriptorSet,
|
||||
image: vk::Image,
|
||||
target: &Target,
|
||||
external: bool,
|
||||
) -> Result<()> {
|
||||
let d = &self.device;
|
||||
let foreign = vk::QUEUE_FAMILY_FOREIGN_EXT;
|
||||
let ours = self.queue_family;
|
||||
let (img_from, img_to) = if external { (foreign, ours) } else { (vk::QUEUE_FAMILY_IGNORED, vk::QUEUE_FAMILY_IGNORED) };
|
||||
d.begin_command_buffer(cmd, &vk::CommandBufferBeginInfo::default())?;
|
||||
d.cmd_reset_query_pool(cmd, self.queries, 0, 2);
|
||||
|
||||
// Borrow the scanout image from the display and the NV12 buffer from
|
||||
// the encoder. GENERAL in and out, so nothing gets decompressed or
|
||||
// discarded behind the compositor's back.
|
||||
let acquire_image = vk::ImageMemoryBarrier::default()
|
||||
.src_queue_family_index(img_from)
|
||||
.dst_queue_family_index(img_to)
|
||||
.old_layout(vk::ImageLayout::GENERAL)
|
||||
.new_layout(vk::ImageLayout::GENERAL)
|
||||
.dst_access_mask(vk::AccessFlags::SHADER_READ)
|
||||
.image(image)
|
||||
.subresource_range(color_range());
|
||||
let acquire_buffer = vk::BufferMemoryBarrier::default()
|
||||
.src_queue_family_index(foreign)
|
||||
.dst_queue_family_index(ours)
|
||||
.dst_access_mask(vk::AccessFlags::SHADER_WRITE)
|
||||
.buffer(target.buffer)
|
||||
.size(vk::WHOLE_SIZE);
|
||||
d.cmd_pipeline_barrier(
|
||||
cmd,
|
||||
vk::PipelineStageFlags::TOP_OF_PIPE,
|
||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||
vk::DependencyFlags::empty(),
|
||||
&[],
|
||||
&[acquire_buffer],
|
||||
&[acquire_image],
|
||||
);
|
||||
|
||||
d.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::COMPUTE, self.pipeline);
|
||||
d.cmd_bind_descriptor_sets(cmd, vk::PipelineBindPoint::COMPUTE, self.pipeline_layout, 0, &[set], &[]);
|
||||
let params: [u8; std::mem::size_of::<Params>()] = std::mem::transmute(self.params);
|
||||
d.cmd_push_constants(cmd, self.pipeline_layout, vk::ShaderStageFlags::COMPUTE, 0, ¶ms);
|
||||
let groups_x = (self.layout.width / 4).div_ceil(8);
|
||||
let groups_y = (self.layout.height / 2).div_ceil(8);
|
||||
d.cmd_write_timestamp(cmd, vk::PipelineStageFlags::TOP_OF_PIPE, self.queries, 0);
|
||||
d.cmd_dispatch(cmd, groups_x, groups_y, 1);
|
||||
d.cmd_write_timestamp(cmd, vk::PipelineStageFlags::BOTTOM_OF_PIPE, self.queries, 1);
|
||||
|
||||
let release_image = vk::ImageMemoryBarrier::default()
|
||||
.src_queue_family_index(img_to)
|
||||
.dst_queue_family_index(img_from)
|
||||
.old_layout(vk::ImageLayout::GENERAL)
|
||||
.new_layout(vk::ImageLayout::GENERAL)
|
||||
.src_access_mask(vk::AccessFlags::SHADER_READ)
|
||||
.image(image)
|
||||
.subresource_range(color_range());
|
||||
let release_buffer = vk::BufferMemoryBarrier::default()
|
||||
.src_queue_family_index(ours)
|
||||
.dst_queue_family_index(foreign)
|
||||
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
|
||||
.buffer(target.buffer)
|
||||
.size(vk::WHOLE_SIZE);
|
||||
d.cmd_pipeline_barrier(
|
||||
cmd,
|
||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
|
||||
vk::DependencyFlags::empty(),
|
||||
&[],
|
||||
&[release_buffer],
|
||||
&[release_image],
|
||||
);
|
||||
d.end_command_buffer(cmd)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts the given scanout buffer into target `index` and waits for the
|
||||
/// GPU to finish, so the encoder can take the buffer right away.
|
||||
/// Returns how long the shader ran on the GPU.
|
||||
pub fn convert(&mut self, fb_id: u32, buffer: u64, index: usize) -> Result<std::time::Duration> {
|
||||
self.tick += 1;
|
||||
let tick = self.tick;
|
||||
let src = self
|
||||
.sources
|
||||
.iter_mut()
|
||||
.find(|s| s.fb_id == fb_id && s.buffer == buffer)
|
||||
.context("scanout buffer wasn't imported")?;
|
||||
src.last_used = tick;
|
||||
let cmd = src.cmds[index];
|
||||
self.submit(&[cmd])
|
||||
}
|
||||
|
||||
/// Runs command buffers, waits for them, and returns the conversion
|
||||
/// shader's GPU time from the timestamps it wrote.
|
||||
fn submit(&self, cmds: &[vk::CommandBuffer]) -> Result<std::time::Duration> {
|
||||
unsafe {
|
||||
self.device.queue_submit(
|
||||
self.queue,
|
||||
&[vk::SubmitInfo::default().command_buffers(cmds)],
|
||||
self.fence,
|
||||
)?;
|
||||
self.device.wait_for_fences(&[self.fence], true, 1_000_000_000)?;
|
||||
self.device.reset_fences(&[self.fence])?;
|
||||
|
||||
let mut stamps = [0u64; 2];
|
||||
self.device
|
||||
.get_query_pool_results(self.queries, 0, &mut stamps, vk::QueryResultFlags::TYPE_64)?;
|
||||
let ns = stamps[1].saturating_sub(stamps[0]) as f64 * self.timestamp_ns;
|
||||
Ok(std::time::Duration::from_nanos(ns as u64))
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy_source(&self, src: Source) {
|
||||
unsafe {
|
||||
let d = &self.device;
|
||||
let _ = d.device_wait_idle();
|
||||
if !src.cmds.is_empty() {
|
||||
d.free_command_buffers(self.cmd_pool, &src.cmds);
|
||||
}
|
||||
if !src.sets.is_empty() {
|
||||
let _ = d.free_descriptor_sets(self.desc_pool, &src.sets);
|
||||
}
|
||||
d.destroy_image_view(src.view, None);
|
||||
d.destroy_image(src.image, None);
|
||||
d.free_memory(src.memory, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn color_range() -> vk::ImageSubresourceRange {
|
||||
vk::ImageSubresourceRange {
|
||||
aspect_mask: vk::ImageAspectFlags::COLOR,
|
||||
base_mip_level: 0,
|
||||
level_count: 1,
|
||||
base_array_layer: 0,
|
||||
layer_count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Gpu {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
let _ = self.device.device_wait_idle();
|
||||
for src in std::mem::take(&mut self.sources) {
|
||||
self.destroy_source(src);
|
||||
}
|
||||
let d = &self.device;
|
||||
if let Some(copy) = self.copy.take() {
|
||||
d.destroy_command_pool(copy.pool, None);
|
||||
}
|
||||
for t in self.targets.drain(..) {
|
||||
d.destroy_buffer(t.buffer, None);
|
||||
d.free_memory(t.memory, None);
|
||||
}
|
||||
d.destroy_buffer(self.lut_buffer, None);
|
||||
d.free_memory(self.lut_memory, None);
|
||||
d.destroy_fence(self.fence, None);
|
||||
d.destroy_query_pool(self.queries, None);
|
||||
d.destroy_command_pool(self.cmd_pool, None);
|
||||
d.destroy_descriptor_pool(self.desc_pool, None);
|
||||
d.destroy_sampler(self.sampler, None);
|
||||
d.destroy_pipeline(self.pipeline, None);
|
||||
d.destroy_pipeline_layout(self.pipeline_layout, None);
|
||||
d.destroy_descriptor_set_layout(self.set_layout, None);
|
||||
d.destroy_shader_module(self.shader, None);
|
||||
d.destroy_device(None);
|
||||
self.instance.destroy_instance(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Converting images someone else hands us on our own device (SteamVR's
|
||||
//! headset view): each frame gets copied into an image of ours, then runs
|
||||
//! through the same conversion as the panel scanout.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use ash::vk;
|
||||
|
||||
use super::{color_range, CopyTarget, Gpu, NativeHandles, Source, COPY_SOURCE};
|
||||
|
||||
impl Gpu {
|
||||
pub fn native(&self) -> NativeHandles {
|
||||
NativeHandles {
|
||||
instance: self.instance.handle(),
|
||||
device: self.device.handle(),
|
||||
physical_device: self.physical_device,
|
||||
queue: self.queue,
|
||||
queue_family: self.queue_family,
|
||||
}
|
||||
}
|
||||
|
||||
/// Size of the image frames get copied into, if it's set up.
|
||||
pub fn copy_size(&self) -> Option<(u32, u32)> {
|
||||
self.copy.as_ref().map(|c| (c.width, c.height))
|
||||
}
|
||||
|
||||
/// Sets up (or replaces) the image frames get copied into, `width`x`height` RGBA.
|
||||
pub fn add_copy_source(&mut self, width: u32, height: u32) -> Result<()> {
|
||||
if let Some(i) = self.sources.iter().position(|s| s.fb_id == COPY_SOURCE) {
|
||||
let old = self.sources.swap_remove(i);
|
||||
self.destroy_source(old);
|
||||
}
|
||||
if let Some(old) = self.copy.take() {
|
||||
unsafe { self.device.destroy_command_pool(old.pool, None) };
|
||||
}
|
||||
log::info!("copying {width}x{height} frames from SteamVR");
|
||||
let d = &self.device;
|
||||
let mut src = Source {
|
||||
fb_id: COPY_SOURCE,
|
||||
buffer: 0,
|
||||
last_used: 0,
|
||||
image: vk::Image::null(),
|
||||
memory: vk::DeviceMemory::null(),
|
||||
view: vk::ImageView::null(),
|
||||
sets: Vec::new(),
|
||||
cmds: Vec::new(),
|
||||
};
|
||||
let format = vk::Format::R8G8B8A8_UNORM;
|
||||
unsafe {
|
||||
src.image = d.create_image(
|
||||
&vk::ImageCreateInfo::default()
|
||||
.image_type(vk::ImageType::TYPE_2D)
|
||||
.format(format)
|
||||
.extent(vk::Extent3D { width, height, depth: 1 })
|
||||
.mip_levels(1)
|
||||
.array_layers(1)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.tiling(vk::ImageTiling::OPTIMAL)
|
||||
.usage(vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED),
|
||||
None,
|
||||
)?;
|
||||
let req = d.get_image_memory_requirements(src.image);
|
||||
let ty = self
|
||||
.find_memory_type(req.memory_type_bits, vk::MemoryPropertyFlags::DEVICE_LOCAL)
|
||||
.context("no memory for the copy image")?;
|
||||
src.memory = d.allocate_memory(&vk::MemoryAllocateInfo::default().allocation_size(req.size).memory_type_index(ty), None)?;
|
||||
d.bind_image_memory(src.image, src.memory, 0)?;
|
||||
src.view = d.create_image_view(
|
||||
&vk::ImageViewCreateInfo::default()
|
||||
.image(src.image)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(format)
|
||||
.subresource_range(color_range()),
|
||||
None,
|
||||
)?;
|
||||
let layouts = vec![self.set_layout; self.targets.len()];
|
||||
src.sets = d.allocate_descriptor_sets(
|
||||
&vk::DescriptorSetAllocateInfo::default().descriptor_pool(self.desc_pool).set_layouts(&layouts),
|
||||
)?;
|
||||
src.cmds = d.allocate_command_buffers(
|
||||
&vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(self.cmd_pool)
|
||||
.level(vk::CommandBufferLevel::PRIMARY)
|
||||
.command_buffer_count(self.targets.len() as u32),
|
||||
)?;
|
||||
for (i, target) in self.targets.iter().enumerate() {
|
||||
self.write_set(src.sets[i], src.view, target);
|
||||
self.record(src.cmds[i], src.sets[i], src.image, target, false)?;
|
||||
}
|
||||
|
||||
let pool = d.create_command_pool(
|
||||
&vk::CommandPoolCreateInfo::default()
|
||||
.queue_family_index(self.queue_family)
|
||||
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER),
|
||||
None,
|
||||
)?;
|
||||
let cmd = d.allocate_command_buffers(&vk::CommandBufferAllocateInfo::default().command_pool(pool).command_buffer_count(1))?[0];
|
||||
self.copy = Some(CopyTarget { pool, cmd, image: src.image, width, height });
|
||||
}
|
||||
self.sources.push(src);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copies `from` (in TRANSFER_SRC_OPTIMAL, the region starting at
|
||||
/// `origin`) into our image and converts it into target `index`.
|
||||
/// Returns how long the conversion shader ran.
|
||||
pub fn convert_copied(&mut self, from: vk::Image, origin: (i32, i32), index: usize) -> Result<std::time::Duration> {
|
||||
let copy = self.copy.as_ref().context("no copy source set up")?;
|
||||
let (cmd, to, width, height) = (copy.cmd, copy.image, copy.width, copy.height);
|
||||
let d = &self.device;
|
||||
unsafe {
|
||||
d.reset_command_buffer(cmd, vk::CommandBufferResetFlags::empty())?;
|
||||
d.begin_command_buffer(cmd, &vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT))?;
|
||||
let to_dst = vk::ImageMemoryBarrier::default()
|
||||
.old_layout(vk::ImageLayout::UNDEFINED)
|
||||
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.src_access_mask(vk::AccessFlags::SHADER_READ)
|
||||
.dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
|
||||
.image(to)
|
||||
.subresource_range(color_range());
|
||||
d.cmd_pipeline_barrier(
|
||||
cmd,
|
||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||
vk::PipelineStageFlags::TRANSFER,
|
||||
vk::DependencyFlags::empty(),
|
||||
&[],
|
||||
&[],
|
||||
&[to_dst],
|
||||
);
|
||||
let layers = vk::ImageSubresourceLayers {
|
||||
aspect_mask: vk::ImageAspectFlags::COLOR,
|
||||
mip_level: 0,
|
||||
base_array_layer: 0,
|
||||
layer_count: 1,
|
||||
};
|
||||
let region = vk::ImageCopy::default()
|
||||
.src_subresource(layers)
|
||||
.src_offset(vk::Offset3D { x: origin.0, y: origin.1, z: 0 })
|
||||
.dst_subresource(layers)
|
||||
.extent(vk::Extent3D { width, height, depth: 1 });
|
||||
d.cmd_copy_image(cmd, from, vk::ImageLayout::TRANSFER_SRC_OPTIMAL, to, vk::ImageLayout::TRANSFER_DST_OPTIMAL, &[region]);
|
||||
let to_general = vk::ImageMemoryBarrier::default()
|
||||
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.new_layout(vk::ImageLayout::GENERAL)
|
||||
.src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
|
||||
.dst_access_mask(vk::AccessFlags::SHADER_READ)
|
||||
.image(to)
|
||||
.subresource_range(color_range());
|
||||
d.cmd_pipeline_barrier(
|
||||
cmd,
|
||||
vk::PipelineStageFlags::TRANSFER,
|
||||
vk::PipelineStageFlags::COMPUTE_SHADER,
|
||||
vk::DependencyFlags::empty(),
|
||||
&[],
|
||||
&[],
|
||||
&[to_general],
|
||||
);
|
||||
d.end_command_buffer(cmd)?;
|
||||
}
|
||||
let convert = self
|
||||
.sources
|
||||
.iter()
|
||||
.find(|s| s.fb_id == COPY_SOURCE)
|
||||
.context("no copy source set up")?
|
||||
.cmds[index];
|
||||
self.submit(&[cmd, convert])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Framely's root framebuffer export helper and its private session socket.
|
||||
//! The worker holds no capabilities. The core starts a version-matched helper,
|
||||
//! which exports framebuffer DMA-BUF handles until the session socket closes.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use drm::control::{framebuffer, Device as ControlDevice};
|
||||
|
||||
/// IPC protocol shared by the packaged helper and capture worker.
|
||||
pub const VERSION: u32 = 1;
|
||||
const REPLY_LEN: usize = 32;
|
||||
|
||||
/// One scanout buffer, as a dmabuf.
|
||||
pub struct Exported {
|
||||
pub fd: OwnedFd,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fourcc: u32,
|
||||
pub modifier: u64,
|
||||
pub pitch: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
/// Exports a framebuffer, which only works with the permission. None when
|
||||
/// the kernel kept the buffer to itself, i.e. without it.
|
||||
pub fn export<D: ControlDevice>(card: &D, fb_id: u32) -> Result<Option<Exported>> {
|
||||
let handle: framebuffer::Handle = drm::control::from_u32(fb_id).context("bad framebuffer id")?;
|
||||
let info = card.get_planar_framebuffer(handle).with_context(|| format!("reading framebuffer {fb_id}"))?;
|
||||
let buffers = info.buffers();
|
||||
let Some(bo) = buffers[0] else { return Ok(None) };
|
||||
if buffers[1..].iter().any(Option::is_some) {
|
||||
bail!("multi-plane scanout buffers aren't supported yet");
|
||||
}
|
||||
let fd = card.buffer_to_prime_fd(bo, libc::O_CLOEXEC as u32).context("exporting scanout buffer as dmabuf");
|
||||
// The export holds its own reference, the GEM handle is ours to drop.
|
||||
let _ = card.close_buffer(bo);
|
||||
let (width, height) = info.size();
|
||||
Ok(Some(Exported {
|
||||
fd: fd?,
|
||||
width,
|
||||
height,
|
||||
fourcc: info.pixel_format() as u32,
|
||||
modifier: info.modifier().map(u64::from).unwrap_or(0),
|
||||
pitch: info.pitches()[0],
|
||||
offset: info.offsets()[0],
|
||||
}))
|
||||
}
|
||||
|
||||
struct Card(std::fs::File);
|
||||
|
||||
impl AsFd for Card {
|
||||
fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {
|
||||
self.0.as_fd()
|
||||
}
|
||||
}
|
||||
|
||||
impl drm::Device for Card {}
|
||||
impl ControlDevice for Card {}
|
||||
|
||||
/// The helper's side: says its version, then answers framebuffer ids on the
|
||||
/// socket it was started with (its stdin) until the recorder hangs up.
|
||||
pub fn serve(card_path: &Path) -> Result<()> {
|
||||
// Only ever the display, nothing else gets opened with the permission.
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
let real = std::fs::canonicalize(card_path).with_context(|| format!("finding {}", card_path.display()))?;
|
||||
let is_device = std::fs::metadata(&real).is_ok_and(|m| m.file_type().is_char_device());
|
||||
if !real.to_string_lossy().starts_with("/dev/dri/card") || !is_device {
|
||||
bail!("{} isn't a display", card_path.display());
|
||||
}
|
||||
let file = std::fs::OpenOptions::new().read(true).write(true).open(&real);
|
||||
let card = Card(file.with_context(|| format!("opening {}", real.display()))?);
|
||||
let mut sock = unsafe { UnixStream::from_raw_fd(0) };
|
||||
sock.write_all(&VERSION.to_le_bytes())?;
|
||||
let mut id = [0u8; 4];
|
||||
loop {
|
||||
match sock.read_exact(&mut id) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let fb_id = u32::from_le_bytes(id);
|
||||
match export(&card, fb_id) {
|
||||
Ok(Some(buf)) => send(&sock, &reply(0, &buf), Some(buf.fd.as_raw_fd()))?,
|
||||
Ok(None) => {
|
||||
eprintln!("framely-panel-grab: the kernel hid framebuffer {fb_id}, is the permission gone?");
|
||||
send(&sock, &[1; REPLY_LEN], None)?
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("framely-panel-grab: {e:#}");
|
||||
send(&sock, &[1; REPLY_LEN], None)?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(status: u32, b: &Exported) -> [u8; REPLY_LEN] {
|
||||
let mut out = [0u8; REPLY_LEN];
|
||||
for (i, v) in [status, b.width, b.height, b.fourcc, b.pitch, b.offset].into_iter().enumerate() {
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out[24..32].copy_from_slice(&b.modifier.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// The recorder's side: the helper, started and asked per frame.
|
||||
pub struct Grabber {
|
||||
sock: UnixStream,
|
||||
}
|
||||
|
||||
impl Grabber {
|
||||
/// A dedicated socket passed by Framely's root supervisor. It grants no
|
||||
/// general capability to the Steam user process and is never public.
|
||||
pub fn inherited() -> Result<Option<Self>> {
|
||||
let Some(raw) = std::env::var_os("FRAMELY_GRAB_FD") else { return Ok(None) };
|
||||
std::env::remove_var("FRAMELY_GRAB_FD");
|
||||
let fd: RawFd = raw.to_string_lossy().parse().context("invalid helper fd")?;
|
||||
if fd < 3 { bail!("invalid helper fd"); }
|
||||
let mut sock = unsafe { UnixStream::from_raw_fd(fd) };
|
||||
unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC); }
|
||||
sock.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
|
||||
sock.set_write_timeout(Some(std::time::Duration::from_secs(5)))?;
|
||||
let mut version = [0; 4];
|
||||
sock.read_exact(&mut version).context("Framely panel helper did not answer")?;
|
||||
if u32::from_le_bytes(version) != VERSION { bail!("panel helper version mismatch"); }
|
||||
Ok(Some(Self { sock }))
|
||||
}
|
||||
|
||||
pub fn export(&mut self, fb_id: u32) -> Result<Exported> {
|
||||
self.sock.write_all(&fb_id.to_le_bytes()).context("asking the panel helper")?;
|
||||
let (bytes, fd) = recv(&self.sock)?;
|
||||
let word = |i: usize| u32::from_le_bytes(bytes[i * 4..i * 4 + 4].try_into().unwrap());
|
||||
let (Some(fd), 0) = (fd, word(0)) else { bail!("the panel helper couldn't read framebuffer {fb_id}") };
|
||||
Ok(Exported {
|
||||
fd,
|
||||
width: word(1),
|
||||
height: word(2),
|
||||
fourcc: word(3),
|
||||
pitch: word(4),
|
||||
offset: word(5),
|
||||
modifier: u64::from_le_bytes(bytes[24..32].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn send(sock: &UnixStream, bytes: &[u8; REPLY_LEN], fd: Option<RawFd>) -> Result<()> {
|
||||
let mut iov = libc::iovec { iov_base: bytes.as_ptr() as *mut _, iov_len: bytes.len() };
|
||||
let space = unsafe { libc::CMSG_SPACE(std::mem::size_of::<RawFd>() as u32) } as usize;
|
||||
let mut control = vec![0u8; space];
|
||||
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
|
||||
msg.msg_iov = &mut iov;
|
||||
msg.msg_iovlen = 1;
|
||||
if let Some(fd) = fd {
|
||||
msg.msg_control = control.as_mut_ptr().cast();
|
||||
msg.msg_controllen = space as _;
|
||||
unsafe {
|
||||
let cmsg = libc::CMSG_FIRSTHDR(&msg);
|
||||
(*cmsg).cmsg_level = libc::SOL_SOCKET;
|
||||
(*cmsg).cmsg_type = libc::SCM_RIGHTS;
|
||||
(*cmsg).cmsg_len = libc::CMSG_LEN(std::mem::size_of::<RawFd>() as u32) as _;
|
||||
std::ptr::write_unaligned(libc::CMSG_DATA(cmsg).cast::<RawFd>(), fd);
|
||||
}
|
||||
}
|
||||
if unsafe { libc::sendmsg(sock.as_raw_fd(), &msg, libc::MSG_NOSIGNAL) } != REPLY_LEN as isize {
|
||||
return Err(std::io::Error::last_os_error()).context("answering the recorder");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recv(sock: &UnixStream) -> Result<([u8; REPLY_LEN], Option<OwnedFd>)> {
|
||||
let mut bytes = [0u8; REPLY_LEN];
|
||||
let mut iov = libc::iovec { iov_base: bytes.as_mut_ptr().cast(), iov_len: bytes.len() };
|
||||
let space = unsafe { libc::CMSG_SPACE(std::mem::size_of::<RawFd>() as u32) } as usize;
|
||||
let mut control = vec![0u8; space];
|
||||
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
|
||||
msg.msg_iov = &mut iov;
|
||||
msg.msg_iovlen = 1;
|
||||
msg.msg_control = control.as_mut_ptr().cast();
|
||||
msg.msg_controllen = space as _;
|
||||
let got = unsafe { libc::recvmsg(sock.as_raw_fd(), &mut msg, libc::MSG_CMSG_CLOEXEC | libc::MSG_WAITALL) };
|
||||
if got != REPLY_LEN as isize {
|
||||
bail!("the panel helper hung up");
|
||||
}
|
||||
let mut fd = None;
|
||||
unsafe {
|
||||
let cmsg = libc::CMSG_FIRSTHDR(&msg);
|
||||
if !cmsg.is_null() && (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS {
|
||||
fd = Some(OwnedFd::from_raw_fd(std::ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast::<RawFd>())));
|
||||
}
|
||||
}
|
||||
Ok((bytes, fd))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn supervisor_socket_uses_existing_helper_protocol() {
|
||||
use std::os::fd::IntoRawFd;
|
||||
let (ours, mut helper) = UnixStream::pair().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
helper.write_all(&VERSION.to_le_bytes()).unwrap();
|
||||
let mut id = [0; 4]; helper.read_exact(&mut id).unwrap();
|
||||
assert_eq!(u32::from_le_bytes(id), 42);
|
||||
let file = std::fs::File::open("/dev/null").unwrap();
|
||||
let buf = Exported {fd: OwnedFd::from(file),width:1920,height:1080,fourcc:0x34325258,modifier:0,pitch:7680,offset:0};
|
||||
send(&helper, &reply(0, &buf), Some(buf.fd.as_raw_fd())).unwrap();
|
||||
});
|
||||
std::env::set_var("FRAMELY_GRAB_FD", ours.into_raw_fd().to_string());
|
||||
let mut grabber = Grabber::inherited().unwrap().unwrap();
|
||||
assert!(std::env::var_os("FRAMELY_GRAB_FD").is_none());
|
||||
let buf = grabber.export(42).unwrap();
|
||||
assert_eq!((buf.width, buf.height), (1920, 1080));
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_round_trip_with_a_file() {
|
||||
let (a, b) = UnixStream::pair().unwrap();
|
||||
let file = std::fs::File::open("/dev/null").unwrap();
|
||||
let buf = Exported {
|
||||
fd: OwnedFd::from(file),
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fourcc: 0x34325258,
|
||||
modifier: 0x0500000000000001,
|
||||
pitch: 7680,
|
||||
offset: 64,
|
||||
};
|
||||
send(&a, &reply(0, &buf), Some(buf.fd.as_raw_fd())).unwrap();
|
||||
let (bytes, fd) = recv(&b).unwrap();
|
||||
assert!(fd.is_some());
|
||||
assert_eq!(bytes, reply(0, &buf));
|
||||
send(&a, &[1; REPLY_LEN], None).unwrap();
|
||||
let (_, fd) = recv(&b).unwrap();
|
||||
assert!(fd.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Finds the plane the VR compositor scans out and hands its buffers over as
|
||||
//! dmabufs. The compositor keeps DRM master; we only look.
|
||||
//!
|
||||
//! Getting buffer handles for someone else's framebuffer needs CAP_SYS_ADMIN.
|
||||
//! The recorder uses it if it has it itself, the panel helper otherwise (see
|
||||
//! framely_capture::grab).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use drm::control::{crtc, plane, Device as ControlDevice};
|
||||
use framely_capture::grab;
|
||||
use drm::{ClientCapability, Device, VblankWaitFlags, VblankWaitTarget};
|
||||
|
||||
struct Card(File);
|
||||
|
||||
impl AsFd for Card {
|
||||
fn as_fd(&self) -> BorrowedFd<'_> {
|
||||
self.0.as_fd()
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for Card {}
|
||||
impl ControlDevice for Card {}
|
||||
|
||||
/// One scanout buffer, exported as a dmabuf.
|
||||
pub struct ScanoutBuffer {
|
||||
pub fb_id: u32,
|
||||
pub fd: OwnedFd,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fourcc: u32,
|
||||
pub modifier: u64,
|
||||
pub pitch: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
pub struct Kms {
|
||||
card: Card,
|
||||
helper: RefCell<Option<grab::Grabber>>,
|
||||
pipe: u32,
|
||||
plane: Cell<plane::Handle>,
|
||||
crtc: crtc::Handle,
|
||||
next_plane_probe: Cell<Instant>,
|
||||
pub refresh_hz: f64,
|
||||
pub mode_size: (u32, u32),
|
||||
}
|
||||
|
||||
impl Kms {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("opening {}", path.display()))?;
|
||||
let card = Card(file);
|
||||
card.set_client_capability(ClientCapability::UniversalPlanes, true)
|
||||
.context("enabling universal planes")?;
|
||||
|
||||
let res = card.resource_handles().context("reading DRM resources")?;
|
||||
let (pipe, crtc, mode) = res
|
||||
.crtcs()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, &h)| {
|
||||
let info = card.get_crtc(h).ok()?;
|
||||
Some((i as u32, h, info.mode()?))
|
||||
})
|
||||
.context("no active display found, is the headset on?")?;
|
||||
|
||||
let plane = find_scanout_plane(&card, crtc)?;
|
||||
let (w, h) = mode.size();
|
||||
let refresh_hz = mode_refresh(&mode);
|
||||
log::info!(
|
||||
"display: {}x{} @ {:.2} Hz, crtc {:?}, plane {:?}",
|
||||
w,
|
||||
h,
|
||||
refresh_hz,
|
||||
crtc,
|
||||
plane
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
card,
|
||||
helper: RefCell::new(grab::Grabber::inherited()?),
|
||||
pipe,
|
||||
plane: Cell::new(plane),
|
||||
crtc,
|
||||
next_plane_probe: Cell::new(Instant::now()),
|
||||
refresh_hz,
|
||||
mode_size: (w as u32, h as u32),
|
||||
})
|
||||
}
|
||||
|
||||
/// Blocks until the next vblank and returns its CLOCK_MONOTONIC timestamp.
|
||||
pub fn wait_vblank(&self) -> Result<Duration> {
|
||||
let reply = loop {
|
||||
match self.card.wait_vblank(VblankWaitTarget::Relative(1), VblankWaitFlags::empty(), self.pipe, 0) {
|
||||
// A pause or stop signal landed mid-wait; just wait again.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
other => break other.context("waiting for vblank")?,
|
||||
}
|
||||
};
|
||||
Ok(reply.time().unwrap_or_else(crate::clock::now))
|
||||
}
|
||||
|
||||
/// Id of the framebuffer being scanned out right now, if any.
|
||||
pub fn current_fb(&self) -> Result<Option<u32>> {
|
||||
let info = self.card.get_plane(self.plane.get()).context("reading scanout plane")?;
|
||||
if info.crtc() == Some(self.crtc) && info.framebuffer().is_some() {
|
||||
return Ok(info.framebuffer().map(u32::from));
|
||||
}
|
||||
// SteamVR can move scanout to another plane when waking or restarting.
|
||||
// Keep normal capture cheap, and bound probes while the display is off.
|
||||
if Instant::now() < self.next_plane_probe.get() { return Ok(None); }
|
||||
self.next_plane_probe.set(Instant::now() + Duration::from_secs(1));
|
||||
let Ok(plane) = find_scanout_plane(&self.card, self.crtc) else { return Ok(None) };
|
||||
if plane != self.plane.get() {
|
||||
log::info!("scanout moved to plane {plane:?}");
|
||||
self.plane.set(plane);
|
||||
}
|
||||
Ok(self.card.get_plane(plane).context("reading replacement scanout plane")?.framebuffer().map(u32::from))
|
||||
}
|
||||
|
||||
pub fn export(&self, fb_id: u32) -> Result<ScanoutBuffer> {
|
||||
let buf = match grab::export(&self.card, fb_id)? {
|
||||
Some(buf) => buf,
|
||||
// No permission of our own: the helper has it, if it's installed.
|
||||
None => self.export_through_helper(fb_id)?,
|
||||
};
|
||||
Ok(ScanoutBuffer {
|
||||
fb_id,
|
||||
fd: buf.fd,
|
||||
width: buf.width,
|
||||
height: buf.height,
|
||||
fourcc: buf.fourcc,
|
||||
modifier: buf.modifier,
|
||||
pitch: buf.pitch,
|
||||
offset: buf.offset,
|
||||
})
|
||||
}
|
||||
|
||||
fn export_through_helper(&self, fb_id: u32) -> Result<grab::Exported> {
|
||||
self.helper.borrow_mut().as_mut()
|
||||
.context("Framely panel helper is not connected; start capture from Framely's casting page")?
|
||||
.export(fb_id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn find_scanout_plane(card: &Card, crtc: crtc::Handle) -> Result<plane::Handle> {
|
||||
let mut best: Option<(plane::Handle, u64)> = None;
|
||||
for handle in card.plane_handles().context("listing planes")? {
|
||||
let Ok(info) = card.get_plane(handle) else { continue };
|
||||
if info.crtc() != Some(crtc) {
|
||||
continue;
|
||||
}
|
||||
let Some(fb) = info.framebuffer() else { continue };
|
||||
// GETFB2 exposes dimensions even when the unprivileged recorder cannot
|
||||
// see GEM handles. Legacy GETFB can fail on modern scanout formats.
|
||||
// If it fails for every plane, choosing the first one can select a cursor.
|
||||
let props = card.get_properties(handle).ok();
|
||||
let mut cursor = false;
|
||||
let (mut width, mut height) = (0, 0);
|
||||
if let Some(props) = props {
|
||||
for (&property, &value) in props.iter() {
|
||||
let Ok(info) = card.get_property(property) else { continue };
|
||||
match info.name().to_bytes() {
|
||||
b"type" => cursor = value == 2, // DRM_PLANE_TYPE_CURSOR
|
||||
b"CRTC_W" => width = value,
|
||||
b"CRTC_H" => height = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let size = card.get_planar_framebuffer(fb).map(|f| f.size())
|
||||
.or_else(|_| card.get_framebuffer(fb).map(|f| f.size()));
|
||||
let area = size.map(|(w,h)| w as u64 * h as u64).unwrap_or(width.saturating_mul(height));
|
||||
if cursor || area == 0 { continue; }
|
||||
if best.is_none_or(|(_, a)| area > a) {
|
||||
best = Some((handle, area));
|
||||
}
|
||||
}
|
||||
best.map(|(h, _)| h).context("no plane is scanning out, is the VR compositor running?")
|
||||
}
|
||||
|
||||
fn mode_refresh(mode: &drm::control::Mode) -> f64 {
|
||||
let (htotal, vtotal) = (mode.hsync().2 as f64, mode.vsync().2 as f64);
|
||||
if htotal > 0.0 && vtotal > 0.0 {
|
||||
mode.clock() as f64 * 1000.0 / (htotal * vtotal)
|
||||
} else {
|
||||
mode.vrefresh() as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies the buffer behind a dmabuf fd. Every export of the same buffer
|
||||
/// shares one dma_buf file, so its inode is stable for the buffer's lifetime.
|
||||
pub fn buffer_id(fd: &OwnedFd) -> Result<u64> {
|
||||
let mut st: libc::stat = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::fstat(fd.as_raw_fd(), &mut st) } != 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("looking at a scanout buffer");
|
||||
}
|
||||
Ok(st.st_ino)
|
||||
}
|
||||
|
||||
/// Waits (briefly) until nobody is still drawing into the buffer.
|
||||
pub fn wait_idle(fd: &OwnedFd, timeout: Duration) {
|
||||
let mut pfd = libc::pollfd { fd: fd.as_raw_fd(), events: libc::POLLIN, revents: 0 };
|
||||
unsafe { libc::poll(&mut pfd, 1, timeout.as_millis() as libc::c_int) };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod openvr;
|
||||
pub mod grab;
|
||||
@@ -0,0 +1,560 @@
|
||||
//! The lookup grid the shader uses to find, for each output pixel, where on
|
||||
//! the panel to read from. Built once at startup, so the per-frame GPU work is
|
||||
//! just a few texture reads per pixel.
|
||||
|
||||
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;
|
||||
/// Samples per side when reading SteamVR's distortion function.
|
||||
const DISTORTION_GRID: usize = 97;
|
||||
const INVALID: [f32; 2] = [-1.0, -1.0];
|
||||
/// Color fringes narrower than this (in output pixels) are left alone.
|
||||
const MIN_FRINGE_PX: f64 = 0.35;
|
||||
/// Supersample once an output pixel covers more than this many panel pixels.
|
||||
const SUPERSAMPLE_ABOVE: f64 = 1.5;
|
||||
/// Panel pixels across one eye.
|
||||
const EYE_PX: f64 = 2160.0;
|
||||
/// The Frame's compositor scans each eye out turned 180° in its own half,
|
||||
/// while SteamVR's distortion data describes the panels upright. Checked on
|
||||
/// a headset: straight ahead lands where the in-place rotation puts it (not
|
||||
/// where a whole-display rotation would), and recordings came out upside
|
||||
/// down without this.
|
||||
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,
|
||||
/// Whether the red and blue channels have their own positions (they do
|
||||
/// when the compositor corrects chromatic aberration).
|
||||
pub per_channel: bool,
|
||||
/// Whether the output shrinks the panel enough that single taps would
|
||||
/// alias, so the shader should average a few.
|
||||
pub supersample: bool,
|
||||
/// Grid positions are in SteamVR's panel space; the scanout has each
|
||||
/// eye's half turned 180° from that. See `PANELS_ROTATED`.
|
||||
pub rotated_eyes: bool,
|
||||
/// Scanout texture coordinates, 3 per grid point (r, g, b).
|
||||
pub points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl Lut {
|
||||
fn new(out_w: u32, out_h: u32) -> Self {
|
||||
let width = out_w.div_ceil(STEP) + 1;
|
||||
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,
|
||||
per_channel: false,
|
||||
supersample: false,
|
||||
rotated_eyes: PANELS_ROTATED,
|
||||
points: vec![INVALID; (width * height * 3) as usize],
|
||||
}
|
||||
}
|
||||
|
||||
fn grid(&self) -> impl Iterator<Item = (usize, f64, f64)> + '_ {
|
||||
(0..self.height).flat_map(move |j| {
|
||||
(0..self.width).map(move |i| (((j * self.width + i) * 3) as usize, (i * STEP) as f64, (j * STEP) as f64))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole panel, both eyes, lens distortion and all, fitted into the
|
||||
/// output with black bars.
|
||||
pub fn raw(out_w: u32, out_h: u32, src_w: u32, src_h: u32) -> Lut {
|
||||
let mut lut = Lut::new(out_w, out_h);
|
||||
let scale = (out_w as f64 / src_w as f64).min(out_h as f64 / src_h as f64);
|
||||
lut.supersample = 1.0 / scale > SUPERSAMPLE_ABOVE;
|
||||
let (fit_w, fit_h) = (src_w as f64 * scale, src_h as f64 * scale);
|
||||
let (ox, oy) = ((out_w as f64 - fit_w) / 2.0, (out_h as f64 - fit_h) / 2.0);
|
||||
|
||||
let cells: Vec<_> = lut.grid().collect();
|
||||
for (idx, x, y) in cells {
|
||||
let u = (x - ox) / fit_w;
|
||||
let v = (y - oy) / fit_h;
|
||||
// Let the grid run a bit past the picture so the edges interpolate
|
||||
// cleanly, anything further out is a black bar.
|
||||
let margin = STEP as f64 / fit_w.min(fit_h);
|
||||
let p = if (-margin..=1.0 + margin).contains(&u) && (-margin..=1.0 + margin).contains(&v) {
|
||||
[u.clamp(0.0, 1.0) as f32, v.clamp(0.0, 1.0) as f32]
|
||||
} else {
|
||||
INVALID
|
||||
};
|
||||
lut.points[idx..idx + 3].fill(p);
|
||||
}
|
||||
lut
|
||||
}
|
||||
|
||||
/// Output edge points checked per side when looking for the widest clean view.
|
||||
const EDGE_SAMPLES: usize = 64;
|
||||
/// Pulls the automatic view in a little from the very edge of what's drawn,
|
||||
/// where the lens is at its blurriest.
|
||||
const AUTO_FOV_MARGIN: f64 = 0.97;
|
||||
/// Resolution of the hidden area mask, per side.
|
||||
const MASK_SIZE: usize = 512;
|
||||
/// How far off straight ahead the automatic view may center itself. Zero:
|
||||
/// a few extra degrees aren't worth the middle of the video not being where
|
||||
/// you looked.
|
||||
const MAX_CENTER_SHIFT_DEG: f64 = 0.0;
|
||||
/// Candidate centers per direction on each side of straight ahead.
|
||||
const CENTER_STEPS: i32 = 5;
|
||||
|
||||
/// Everything we know about how one eye maps to the panel.
|
||||
struct EyeModel {
|
||||
eye: usize,
|
||||
l: f64,
|
||||
r: f64,
|
||||
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.
|
||||
hidden: Vec<bool>,
|
||||
/// Render target spots that land somewhere on the panel.
|
||||
covered: Vec<bool>,
|
||||
}
|
||||
|
||||
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(),
|
||||
roll.to_degrees()
|
||||
);
|
||||
if proj.left >= 0.0 || proj.right <= 0.0 || proj.top >= 0.0 || proj.bottom <= 0.0 {
|
||||
bail!("SteamVR reported a weird projection: {proj:?}");
|
||||
}
|
||||
|
||||
let n = DISTORTION_GRID;
|
||||
let mut forward = vec![[[f64::NAN; 2]; 3]; n * n];
|
||||
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);
|
||||
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) {
|
||||
let rows: Vec<String> = (0..32)
|
||||
.map(|y| (0..64).map(|x| if hidden[(y * 16) * MASK_SIZE + x * 8] { '#' } else { '.' }).collect())
|
||||
.collect();
|
||||
log::debug!("hidden area of eye {eye}:\n{}", rows.join("\n"));
|
||||
}
|
||||
log::info!(
|
||||
"hidden area: {} triangles, {:.1}% of the render target",
|
||||
triangles.len(),
|
||||
hidden.iter().filter(|h| **h).count() as f64 * 100.0 / hidden.len() as f64
|
||||
);
|
||||
|
||||
let covered = rasterize(&panel_coverage(&forward, n));
|
||||
|
||||
Ok(Self {
|
||||
eye,
|
||||
l: proj.left as f64,
|
||||
r: proj.right as f64,
|
||||
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] {
|
||||
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, 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;
|
||||
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`.
|
||||
fn solve(&self, c: usize, target: [f64; 2], guess: [f64; 2]) -> Option<[f64; 2]> {
|
||||
let n = DISTORTION_GRID;
|
||||
invert(&self.forward, n, c, target, guess).or_else(|| {
|
||||
let start = nearest(&self.forward, n, c, target)?;
|
||||
invert(&self.forward, n, c, target, start)
|
||||
})
|
||||
}
|
||||
|
||||
/// Widest view at this aspect ratio that's entirely drawn, as the
|
||||
/// 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 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;
|
||||
[
|
||||
(cx + f * tan_h, cy - tan_v),
|
||||
(cx + f * tan_h, cy + tan_v),
|
||||
(cx - tan_h, cy + f * tan_v),
|
||||
(cx + tan_h, cy + f * tan_v),
|
||||
]
|
||||
.iter()
|
||||
.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);
|
||||
if !fits(1e-3, cx, cy) {
|
||||
return 0.0;
|
||||
}
|
||||
for _ in 0..30 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if fits(mid, cx, cy) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
lo
|
||||
};
|
||||
|
||||
let centered = search(0.0, 0.0);
|
||||
let max_shift = MAX_CENTER_SHIFT_DEG.to_radians().tan();
|
||||
let mut best = (centered, 0.0, 0.0);
|
||||
for i in -CENTER_STEPS..=CENTER_STEPS {
|
||||
for j in -CENTER_STEPS..=CENTER_STEPS {
|
||||
let cx = max_shift * i as f64 / CENTER_STEPS as f64;
|
||||
let cy = max_shift * j as f64 / CENTER_STEPS as f64;
|
||||
let w = search(cx, cy);
|
||||
if w > best.0 {
|
||||
best = (w, cx, cy);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only move off center when it's worth it.
|
||||
if best.0 < centered * 1.02 {
|
||||
best = (centered, 0.0, 0.0);
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel's outline in render target space, as triangles.
|
||||
fn panel_coverage(forward: &[[[f64; 2]; 3]], n: usize) -> Vec<[[f32; 2]; 3]> {
|
||||
let mut triangles = Vec::new();
|
||||
let at = |i: usize, j: usize| forward[j * n + i][1];
|
||||
for j in 0..n - 1 {
|
||||
for i in 0..n - 1 {
|
||||
let q = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)];
|
||||
if q.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let q = q.map(|p| [p[0] as f32, p[1] as f32]);
|
||||
triangles.push([q[0], q[1], q[2]]);
|
||||
triangles.push([q[0], q[2], q[3]]);
|
||||
}
|
||||
}
|
||||
triangles
|
||||
}
|
||||
|
||||
/// Turns the hidden area triangles into a lookup mask.
|
||||
fn rasterize(triangles: &[[[f32; 2]; 3]]) -> Vec<bool> {
|
||||
let mut mask = vec![false; MASK_SIZE * MASK_SIZE];
|
||||
let size = MASK_SIZE as f64;
|
||||
for tri in triangles {
|
||||
let p = tri.map(|v| [v[0] as f64 * size, v[1] as f64 * size]);
|
||||
let min_x = p.iter().map(|v| v[0]).fold(f64::INFINITY, f64::min).floor().max(0.0) as usize;
|
||||
let max_x = p.iter().map(|v| v[0]).fold(f64::NEG_INFINITY, f64::max).ceil().min(size) as usize;
|
||||
let min_y = p.iter().map(|v| v[1]).fold(f64::INFINITY, f64::min).floor().max(0.0) as usize;
|
||||
let max_y = p.iter().map(|v| v[1]).fold(f64::NEG_INFINITY, f64::max).ceil().min(size) as usize;
|
||||
let edge = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
let area = edge(p[0], p[1], p[2]);
|
||||
if area.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
for y in min_y..max_y {
|
||||
for x in min_x..max_x {
|
||||
let c = [x as f64 + 0.5, y as f64 + 0.5];
|
||||
let w = [edge(p[1], p[2], c), edge(p[2], p[0], c), edge(p[0], p[1], c)];
|
||||
if w.iter().all(|w| w * area.signum() >= 0.0) {
|
||||
mask[y * MASK_SIZE + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
/// Fits an upright flat source (SteamVR's headset view) into the output,
|
||||
/// letterboxed if the shapes differ.
|
||||
pub fn flat(out_w: u32, out_h: u32, src_w: u32, src_h: u32) -> Lut {
|
||||
let mut lut = raw(out_w, out_h, src_w, src_h);
|
||||
lut.rotated_eyes = false;
|
||||
lut
|
||||
}
|
||||
|
||||
/// One eye as a flat 2D picture: the lens distortion SteamVR applied gets
|
||||
/// undone, cropped to the output's aspect ratio around straight ahead. With
|
||||
/// no field of view given, it picks the widest one that has no black edges.
|
||||
pub fn undistorted(vr: &OpenVr, eye: usize, fov_deg: Option<f64>, out_w: u32, out_h: u32, center_x: f64, center_y: f64) -> Result<Lut> {
|
||||
let model = EyeModel::new(vr, eye)?;
|
||||
let aspect = out_h as f64 / out_w as f64;
|
||||
|
||||
let (widest, wide_cx, wide_cy) = model.widest(aspect);
|
||||
if widest <= 0.0 {
|
||||
bail!("couldn't find any clean part of the view, SteamVR's lens data looks off");
|
||||
}
|
||||
log::info!(
|
||||
"widest clean view at this aspect ratio: {:.1}° x {:.1}°, centered {:.1}° right and {:.1}° down",
|
||||
2.0 * widest.atan().to_degrees(),
|
||||
2.0 * (widest * aspect).atan().to_degrees(),
|
||||
wide_cx.atan().to_degrees(),
|
||||
wide_cy.atan().to_degrees()
|
||||
);
|
||||
let (tan_h, cx, cy) = match fov_deg {
|
||||
Some(fov) => {
|
||||
let wanted = (fov.to_radians() / 2.0).tan();
|
||||
if wanted > widest {
|
||||
log::warn!("a {fov}° view reaches past what the lens shows, the edges will be black");
|
||||
}
|
||||
(wanted, 0.0, 0.0)
|
||||
}
|
||||
None => (widest * AUTO_FOV_MARGIN, wide_cx, wide_cy),
|
||||
};
|
||||
let cx = cx + center_x.to_radians().tan();
|
||||
let cy = cy + center_y.to_radians().tan();
|
||||
let tan_v = tan_h * aspect;
|
||||
log::info!(
|
||||
"recording {:.1}° x {:.1}° of the eye's view",
|
||||
2.0 * tan_h.atan().to_degrees(),
|
||||
2.0 * tan_v.atan().to_degrees()
|
||||
);
|
||||
|
||||
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;
|
||||
let mut max_minify = 0.0f64;
|
||||
let mut prev_green: Option<[f64; 2]> = None;
|
||||
|
||||
let cells: Vec<_> = lut.grid().collect();
|
||||
for (idx, x, y) in cells {
|
||||
if x == 0.0 {
|
||||
prev_green = None;
|
||||
}
|
||||
let tx = cx + (x / out_w as f64 * 2.0 - 1.0) * tan_h;
|
||||
let ty = cy + (y / out_h as f64 * 2.0 - 1.0) * tan_v;
|
||||
let target = model.target(tx, ty);
|
||||
|
||||
let mut solved = [None; 3];
|
||||
for c in 0..3 {
|
||||
if let Some(d) = model.solve(c, target, guess[c]) {
|
||||
guess[c] = d;
|
||||
solved[c] = Some(d);
|
||||
lut.points[idx + c] = [(eye_offset + d[0] * 0.5) as f32, d[1] as f32];
|
||||
}
|
||||
}
|
||||
if let (Some(rd), Some(gd), Some(bd), Some(prev)) = (solved[0], solved[1], solved[2], prev_green) {
|
||||
// How far the panel moves per output pixel, to express the split in output pixels.
|
||||
let panel_per_out = dist(gd, prev) / STEP as f64;
|
||||
if panel_per_out > 0.0 {
|
||||
max_split = max_split.max(dist(rd, gd).max(dist(bd, gd)) / panel_per_out);
|
||||
max_minify = max_minify.max(panel_per_out * EYE_PX);
|
||||
}
|
||||
}
|
||||
prev_green = solved[1];
|
||||
}
|
||||
|
||||
// A fringe well under a pixel in the video isn't worth tripling the
|
||||
// texture reads for.
|
||||
lut.per_channel = max_split > MIN_FRINGE_PX;
|
||||
lut.supersample = max_minify > SUPERSAMPLE_ABOVE;
|
||||
log::info!(
|
||||
"distortion grid ready: color fringe up to {:.2} px (correcting: {}), up to {:.2} panel px per output px (supersampling: {})",
|
||||
max_split,
|
||||
lut.per_channel,
|
||||
max_minify,
|
||||
lut.supersample
|
||||
);
|
||||
Ok(lut)
|
||||
}
|
||||
|
||||
fn dist(a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// Bilinear read of the sampled forward mapping. None outside the panel.
|
||||
fn eval(forward: &[[[f64; 2]; 3]], n: usize, c: usize, d: [f64; 2]) -> Option<[f64; 2]> {
|
||||
if !(0.0..=1.0).contains(&d[0]) || !(0.0..=1.0).contains(&d[1]) {
|
||||
return None;
|
||||
}
|
||||
let gx = d[0] * (n - 1) as f64;
|
||||
let gy = d[1] * (n - 1) as f64;
|
||||
let (i, j) = ((gx.floor() as usize).min(n - 2), (gy.floor() as usize).min(n - 2));
|
||||
let (fx, fy) = (gx - i as f64, gy - j as f64);
|
||||
let p = |ii: usize, jj: usize| forward[jj * n + ii][c];
|
||||
let (a, b, e, f) = (p(i, j), p(i + 1, j), p(i, j + 1), p(i + 1, j + 1));
|
||||
let out = [0, 1].map(|k| {
|
||||
let top = a[k] + (b[k] - a[k]) * fx;
|
||||
let bottom = e[k] + (f[k] - e[k]) * fx;
|
||||
top + (bottom - top) * fy
|
||||
});
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
}
|
||||
|
||||
/// Newton's method on the forward mapping.
|
||||
fn invert(forward: &[[[f64; 2]; 3]], n: usize, c: usize, target: [f64; 2], start: [f64; 2]) -> Option<[f64; 2]> {
|
||||
const H: f64 = 1e-4;
|
||||
let mut d = start;
|
||||
for _ in 0..24 {
|
||||
let f = eval(forward, n, c, d)?;
|
||||
let err = [f[0] - target[0], f[1] - target[1]];
|
||||
if err[0].abs() < 1e-7 && err[1].abs() < 1e-7 {
|
||||
return Some(d);
|
||||
}
|
||||
let dx0 = eval(forward, n, c, [(d[0] - H).max(0.0), d[1]])?;
|
||||
let dx1 = eval(forward, n, c, [(d[0] + H).min(1.0), d[1]])?;
|
||||
let dy0 = eval(forward, n, c, [d[0], (d[1] - H).max(0.0)])?;
|
||||
let dy1 = eval(forward, n, c, [d[0], (d[1] + H).min(1.0)])?;
|
||||
let hx = (d[0] + H).min(1.0) - (d[0] - H).max(0.0);
|
||||
let hy = (d[1] + H).min(1.0) - (d[1] - H).max(0.0);
|
||||
let j = [
|
||||
[(dx1[0] - dx0[0]) / hx, (dy1[0] - dy0[0]) / hy],
|
||||
[(dx1[1] - dx0[1]) / hx, (dy1[1] - dy0[1]) / hy],
|
||||
];
|
||||
let det = j[0][0] * j[1][1] - j[0][1] * j[1][0];
|
||||
if det.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let step = [
|
||||
(j[1][1] * err[0] - j[0][1] * err[1]) / det,
|
||||
(-j[1][0] * err[0] + j[0][0] * err[1]) / det,
|
||||
];
|
||||
d = [(d[0] - step[0]).clamp(0.0, 1.0), (d[1] - step[1]).clamp(0.0, 1.0)];
|
||||
}
|
||||
let f = eval(forward, n, c, d)?;
|
||||
(dist(f, target) < 1e-5).then_some(d)
|
||||
}
|
||||
|
||||
fn nearest(forward: &[[[f64; 2]; 3]], n: usize, c: usize, target: [f64; 2]) -> Option<[f64; 2]> {
|
||||
let (best, _) = forward
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p[c][0].is_finite())
|
||||
.map(|(k, p)| (k, dist(p[c], target)))
|
||||
.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.));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Framely's panel capture worker. stdout is Annex-B H.264, stderr is logging.
|
||||
mod clock;
|
||||
mod encoder;
|
||||
mod gpu;
|
||||
mod kms;
|
||||
mod lut;
|
||||
mod panel_geometry;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use framely_capture::openvr::OpenVr;
|
||||
use std::{io::{self, Write}, os::fd::AsRawFd, path::Path, time::{Duration, Instant}};
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Args {
|
||||
#[arg(long, default_value_t = 1920)] width: 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,
|
||||
#[arg(long, default_value_t = 0.)] center_y: f64,
|
||||
}
|
||||
fn run(a: Args) -> Result<()> {
|
||||
anyhow::ensure!(a.width >= 160 && a.width <= 4096 && a.width % 2 == 0
|
||||
&& a.height >= 160 && a.height <= 4096 && a.height % 2 == 0
|
||||
&& a.width as u64 * a.height as u64 <= 8_847_360
|
||||
&& (1..=120).contains(&a.fps) && (1..=80).contains(&a.bitrate) && a.eye <= 1,
|
||||
"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");
|
||||
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 { 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 {
|
||||
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,
|
||||
}, 3)?;
|
||||
let filter = if map.supersample { gpu::Filter::Supersample } else { gpu::Filter::Sharp };
|
||||
let mut gpu = gpu::Gpu::new(enc.layout(), 3, &map, filter, gpu::Priority::Low, None)?;
|
||||
let mut output = io::BufWriter::new(io::stdout().lock());
|
||||
let mut origin = None;
|
||||
let mut next = Duration::ZERO;
|
||||
let interval = Duration::from_secs_f64(1. / a.fps as f64);
|
||||
let tolerance = Duration::from_secs_f64(0.5 / kms.refresh_hz);
|
||||
let mut frames = 0u64;
|
||||
let mut last_status = Instant::now();
|
||||
loop {
|
||||
let time = kms.wait_vblank().context("Headset display is unavailable")?;
|
||||
let first = *origin.get_or_insert(time);
|
||||
let elapsed = time.saturating_sub(first);
|
||||
if elapsed + tolerance < next { continue; }
|
||||
// Keep a stable cadence when output FPS is not a divisor of display refresh.
|
||||
while next <= elapsed + tolerance { next += interval; }
|
||||
let Some(fb) = kms.current_fb()? else { continue; };
|
||||
let buf = kms.export(fb)?;
|
||||
kms::wait_idle(&buf.fd, Duration::from_millis(4));
|
||||
let id = kms::buffer_id(&buf.fd)?;
|
||||
if !gpu.has_source(fb, id) { gpu.add_source(buf, id)?; }
|
||||
if let Some(slot) = enc.free_slot()? {
|
||||
gpu.convert(fb, id, slot)?;
|
||||
enc.queue_frame(slot, gpu.targets()[slot].fd.as_raw_fd(), elapsed.as_micros() as u64)?;
|
||||
frames += 1;
|
||||
}
|
||||
let mut error = None;
|
||||
enc.poll_packets(|packet| {
|
||||
if let Err(e) = output.write_all(packet.data).and_then(|_| output.flush()) { error = Some(e); }
|
||||
})?;
|
||||
if let Some(e) = error { return Err(e.into()); }
|
||||
if frames == 1 || last_status.elapsed() > Duration::from_secs(5) {
|
||||
eprintln!("{}", serde_json::json!({"event":"capture","width":a.width,"height":height,"frames":frames}));
|
||||
last_status = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
if let Err(e) = run(Args::parse()) { eprintln!("{e:#}"); std::process::exit(1); }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Just enough OpenVR to ask SteamVR what its lens distortion looks like.
|
||||
//! We connect as a background app, grab the numbers and disconnect again, so
|
||||
//! nothing stays attached to the compositor while recording.
|
||||
|
||||
use std::ffi::{c_char, c_void, CStr, CString};
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
const DEFAULT_LIB: &str = "/opt/steamvr/bin/linuxarm64/libopenvr_api.so";
|
||||
|
||||
/// How we present ourselves to SteamVR.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum AppType {
|
||||
/// Doesn't show anything and doesn't start SteamVR.
|
||||
Background = 3,
|
||||
/// Draws overlays (the dashboard tab). Careful: connecting like this
|
||||
/// starts SteamVR's server if it isn't running, and a server started
|
||||
/// from outside SteamVR's own launcher leaves the headset without a
|
||||
/// working compositor. Check `server_running` first.
|
||||
Overlay = 2,
|
||||
}
|
||||
|
||||
/// Interface versions we know the function table layout of. The first four
|
||||
/// slots haven't moved in any of them; the rest only get used on the newest.
|
||||
const SYSTEM_VERSIONS: &[&str] = &[
|
||||
"IVRSystem_026",
|
||||
"IVRSystem_023",
|
||||
"IVRSystem_022",
|
||||
"IVRSystem_021",
|
||||
"IVRSystem_020",
|
||||
"IVRSystem_019",
|
||||
];
|
||||
const FULL_LAYOUT: &str = "IVRSystem_026";
|
||||
|
||||
// Slots in VR_IVRSystem_FnTable (openvr_capi.h).
|
||||
const SLOT_RENDER_TARGET_SIZE: usize = 0;
|
||||
const SLOT_PROJECTION_RAW: usize = 2;
|
||||
const SLOT_COMPUTE_DISTORTION: usize = 3;
|
||||
const SLOT_EYE_TO_HEAD: usize = 5;
|
||||
const SLOT_HIDDEN_AREA_MESH: usize = 34;
|
||||
|
||||
const HIDDEN_AREA_STANDARD: i32 = 0;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct DistortionCoordinates {
|
||||
red: [f32; 2],
|
||||
green: [f32; 2],
|
||||
blue: [f32; 2],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct HmdMatrix34 {
|
||||
m: [[f32; 4]; 3],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct HiddenAreaMesh {
|
||||
vertices: *const [f32; 2],
|
||||
triangles: u32,
|
||||
}
|
||||
|
||||
type InitFn = unsafe extern "C" fn(*mut i32, i32, *const c_char) -> isize;
|
||||
type ShutdownFn = unsafe extern "C" fn();
|
||||
type GetInterfaceFn = unsafe extern "C" fn(*const c_char, *mut i32) -> *mut c_void;
|
||||
type ErrorNameFn = unsafe extern "C" fn(i32) -> *const c_char;
|
||||
|
||||
/// Tangents of the half angles an eye's render target covers. Top is
|
||||
/// negative, since OpenVR's y points down in texture space.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Projection {
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
}
|
||||
|
||||
pub struct OpenVr {
|
||||
lib: *mut c_void,
|
||||
table: *const *const c_void,
|
||||
full_layout: bool,
|
||||
shutdown: ShutdownFn,
|
||||
get_interface: GetInterfaceFn,
|
||||
}
|
||||
|
||||
impl OpenVr {
|
||||
pub fn connect() -> Result<Self> {
|
||||
Self::connect_as(AppType::Background)
|
||||
}
|
||||
|
||||
/// Whether SteamVR's server is up. Connects as a background app, which
|
||||
/// SteamVR never starts a server for, so this can't launch one by accident.
|
||||
pub fn server_running() -> bool {
|
||||
Self::connect_as(AppType::Background).is_ok()
|
||||
}
|
||||
|
||||
pub fn connect_as(app: AppType) -> Result<Self> {
|
||||
let path = std::env::var("FRAMECORDER_OPENVR_LIB").unwrap_or_else(|_| DEFAULT_LIB.into());
|
||||
let cpath = CString::new(path.clone())?;
|
||||
let lib = unsafe { libc::dlopen(cpath.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL) };
|
||||
if lib.is_null() {
|
||||
bail!("couldn't load {path}: {}", dl_error());
|
||||
}
|
||||
|
||||
let init: InitFn = unsafe { sym(lib, c"VR_InitInternal2")? };
|
||||
let shutdown: ShutdownFn = unsafe { sym(lib, c"VR_ShutdownInternal")? };
|
||||
let get_interface: GetInterfaceFn = unsafe { sym(lib, c"VR_GetGenericInterface")? };
|
||||
let error_name: Option<ErrorNameFn> =
|
||||
unsafe { sym(lib, c"VR_GetVRInitErrorAsEnglishDescription").ok() };
|
||||
|
||||
let mut err = 0i32;
|
||||
unsafe { init(&mut err, app as i32, std::ptr::null()) };
|
||||
if err != 0 {
|
||||
let msg = error_name
|
||||
.map(|f| {
|
||||
unsafe { CStr::from_ptr(f(err)) }
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
unsafe { libc::dlclose(lib) };
|
||||
bail!("SteamVR isn't available ({err}: {msg})");
|
||||
}
|
||||
|
||||
let found = SYSTEM_VERSIONS.iter().find_map(|v| {
|
||||
let name = CString::new(format!("FnTable:{v}")).ok()?;
|
||||
let mut err = 0i32;
|
||||
let table = unsafe { get_interface(name.as_ptr(), &mut err) };
|
||||
(err == 0 && !table.is_null()).then_some((*v, table as *const *const c_void))
|
||||
});
|
||||
let Some((version, table)) = found else {
|
||||
unsafe {
|
||||
shutdown();
|
||||
libc::dlclose(lib);
|
||||
}
|
||||
bail!("this SteamVR doesn't offer a known IVRSystem version");
|
||||
};
|
||||
log::debug!("using {version}");
|
||||
if version != FULL_LAYOUT {
|
||||
log::warn!(
|
||||
"SteamVR only offers {version}, so the hidden area and eye roll can't be read"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
lib,
|
||||
table,
|
||||
full_layout: version == FULL_LAYOUT,
|
||||
shutdown,
|
||||
get_interface,
|
||||
})
|
||||
}
|
||||
|
||||
/// Function table of another OpenVR interface, e.g. `IVROverlay_028`.
|
||||
pub fn interface(&self, version: &str) -> Option<*const *const c_void> {
|
||||
let name = CString::new(format!("FnTable:{version}")).ok()?;
|
||||
let mut err = 0i32;
|
||||
let table = unsafe { (self.get_interface)(name.as_ptr(), &mut err) };
|
||||
(err == 0 && !table.is_null()).then_some(table as *const *const c_void)
|
||||
}
|
||||
|
||||
unsafe fn slot<T: Copy>(&self, index: usize) -> T {
|
||||
std::mem::transmute_copy(&*self.table.add(index))
|
||||
}
|
||||
|
||||
pub fn render_target_size(&self) -> (u32, u32) {
|
||||
let (mut w, mut h) = (0, 0);
|
||||
unsafe {
|
||||
let f: unsafe extern "C" fn(*mut u32, *mut u32) = self.slot(SLOT_RENDER_TARGET_SIZE);
|
||||
f(&mut w, &mut h);
|
||||
}
|
||||
(w, h)
|
||||
}
|
||||
|
||||
pub fn projection(&self, eye: usize) -> Projection {
|
||||
let (mut l, mut r, mut t, mut b) = (0.0, 0.0, 0.0, 0.0);
|
||||
unsafe {
|
||||
let f: unsafe extern "C" fn(i32, *mut f32, *mut f32, *mut f32, *mut f32) =
|
||||
self.slot(SLOT_PROJECTION_RAW);
|
||||
f(eye as i32, &mut l, &mut r, &mut t, &mut b);
|
||||
}
|
||||
Projection {
|
||||
left: l,
|
||||
right: r,
|
||||
top: t,
|
||||
bottom: b,
|
||||
}
|
||||
}
|
||||
|
||||
/// For a point on the panel (eye local, 0..1), where in the undistorted
|
||||
/// render target each color channel comes from.
|
||||
pub fn distortion(&self, eye: usize, u: f32, v: f32) -> Option<[[f32; 2]; 3]> {
|
||||
let mut out = DistortionCoordinates::default();
|
||||
let ok = unsafe {
|
||||
let f: unsafe extern "C" fn(i32, f32, f32, *mut DistortionCoordinates) -> bool =
|
||||
self.slot(SLOT_COMPUTE_DISTORTION);
|
||||
f(eye as i32, u, v, &mut out)
|
||||
};
|
||||
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 {
|
||||
if !self.full_layout {
|
||||
return 0.0;
|
||||
}
|
||||
let t = unsafe {
|
||||
let f: unsafe extern "C" fn(i32) -> HmdMatrix34 = self.slot(SLOT_EYE_TO_HEAD);
|
||||
f(eye as i32)
|
||||
};
|
||||
log::debug!("eye {eye} to head: {:?}", t.m);
|
||||
(t.m[1][0] as f64).atan2(t.m[0][0] as f64)
|
||||
}
|
||||
|
||||
/// Triangles, in render target UV, covering what the compositor never
|
||||
/// draws for this eye.
|
||||
pub fn hidden_area(&self, eye: usize) -> Vec<[[f32; 2]; 3]> {
|
||||
if !self.full_layout {
|
||||
return Vec::new();
|
||||
}
|
||||
unsafe {
|
||||
let f: unsafe extern "C" fn(i32, i32) -> HiddenAreaMesh =
|
||||
self.slot(SLOT_HIDDEN_AREA_MESH);
|
||||
let mesh = f(eye as i32, HIDDEN_AREA_STANDARD);
|
||||
if mesh.vertices.is_null() || mesh.triangles == 0 || mesh.triangles > 1 << 20 {
|
||||
return Vec::new();
|
||||
}
|
||||
std::slice::from_raw_parts(mesh.vertices, mesh.triangles as usize * 3)
|
||||
.as_chunks::<3>()
|
||||
.0
|
||||
.to_vec()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OpenVr {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
(self.shutdown)();
|
||||
libc::dlclose(self.lib);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn sym<T: Copy>(lib: *mut c_void, name: &CStr) -> Result<T> {
|
||||
let ptr = libc::dlsym(lib, name.as_ptr());
|
||||
if ptr.is_null() {
|
||||
bail!("{} is missing from libopenvr_api", name.to_string_lossy());
|
||||
}
|
||||
Ok(std::mem::transmute_copy(&ptr))
|
||||
}
|
||||
|
||||
fn dl_error() -> String {
|
||||
let err = unsafe { libc::dlerror() };
|
||||
if err.is_null() {
|
||||
return "unknown error".into();
|
||||
}
|
||||
unsafe { CStr::from_ptr(err) }
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"mediamtx": {
|
||||
"version": "1.21.2",
|
||||
"url": "https://github.com/bluenviron/mediamtx/releases/download/v1.21.2/mediamtx_v1.21.2_linux_arm64.tar.gz",
|
||||
"sha256": "180a235374a548d9133cdd5e20f5dbd8b01317be1c86e0d7e66df585dc42ed6f"
|
||||
},
|
||||
"uxplay": {
|
||||
"version": "1.73.7",
|
||||
"url": "https://github.com/FDH2/UxPlay/archive/refs/tags/v1.73.7.tar.gz",
|
||||
"sha256": "65feb8732de666a7161a9e562aa603a7f5fe0ebb890c2a30c5e1a9c85c76309f"
|
||||
},
|
||||
"gst-libav": {
|
||||
"version": "1.24.13",
|
||||
"url": "https://gstreamer.freedesktop.org/src/gst-libav/gst-libav-1.24.13.tar.xz",
|
||||
"sha256": "150e2b70588fa32a1294f42665756f2175417ce4b5988e2c2081b683719aa6c1"
|
||||
},
|
||||
"ffmpeg-build-api": {
|
||||
"version": "7.0",
|
||||
"url": "https://ffmpeg.org/releases/ffmpeg-7.0.tar.xz",
|
||||
"sha256": "4426a94dd2c814945456600c8adfc402bee65ec14a70e8c531ec9a2cd651da7b"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#include "frame_sink.h"
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
struct FramelySink {GstBin parent;FrameSink* frame;GstElement* gate;GstElement* gain;GstElement* output;guint timer;bool audio;};
|
||||
struct FramelySinkClass {GstBinClass parent;};
|
||||
typedef FramelySink FramelyAudio;typedef FramelySinkClass FramelyAudioClass;
|
||||
G_DEFINE_TYPE(FramelySink,framely_sink,GST_TYPE_BIN)
|
||||
G_DEFINE_TYPE(FramelyAudio,framely_audio,GST_TYPE_BIN)
|
||||
static gboolean tick(gpointer p){auto* s=(FramelySink*)p;bool allowed=s->frame->accepted();g_object_set(s->gate,"drop",!allowed,nullptr);
|
||||
std::ifstream volume(s->frame->directory+"/"+s->frame->id+".volume");double gain=1.;if(volume>>gain)g_object_set(s->gain,"volume",std::clamp(gain,0.,1.),nullptr);return G_SOURCE_CONTINUE;}
|
||||
static void audio_frame(GstElement*,GstBuffer*,gpointer p){auto* s=(FramelySink*)p;tick(s);if(!s->frame->announced.exchange(true))s->frame->event("audio");}
|
||||
static void setup(FramelySink* s,bool audio){
|
||||
s->audio=audio;GError* error=nullptr;
|
||||
auto* bin=gst_parse_bin_from_description(audio?"identity name=tap signal-handoffs=true ! valve name=gate drop=true drop-mode=transform-to-gap ! volume name=gain ! pulsesink name=output async=false":"videoflip video-direction=auto ! videoconvert ! videoscale ! video/x-raw,pixel-aspect-ratio=1/1,format=BGRA ! appsink name=frames emit-signals=true sync=true max-buffers=1 drop=true",true,&error);
|
||||
if(!bin){g_warning("Framely sink: %s",error?error->message:"pipeline error");g_clear_error(&error);return;}
|
||||
gst_bin_add(GST_BIN(s),bin);auto* pad=gst_element_get_static_pad(bin,"sink");gst_element_add_pad(GST_ELEMENT(s),gst_ghost_pad_new("sink",pad));gst_object_unref(pad);
|
||||
s->output=gst_bin_get_by_name(GST_BIN(bin),audio?"output":"frames");
|
||||
if(audio){s->gate=gst_bin_get_by_name(GST_BIN(bin),"gate");s->gain=gst_bin_get_by_name(GST_BIN(bin),"gain");auto* tap=gst_bin_get_by_name(GST_BIN(bin),"tap");g_signal_connect(tap,"handoff",G_CALLBACK(audio_frame),s);gst_object_unref(tap);}
|
||||
else {auto* sink=gst_bin_get_by_name(GST_BIN(bin),"frames");g_signal_connect(sink,"new-sample",G_CALLBACK(frame_sample),s->frame);g_signal_connect(sink,"new-preroll",G_CALLBACK(frame_preroll),s->frame);gst_object_unref(sink);}
|
||||
}
|
||||
static void finalize(GObject* p){auto* s=(FramelySink*)p;if(s->timer)g_source_remove(s->timer);if(s->gate)gst_object_unref(s->gate);if(s->gain)gst_object_unref(s->gain);if(s->output)gst_object_unref(s->output);delete s->frame;G_OBJECT_CLASS(framely_sink_parent_class)->finalize(p);}
|
||||
static void set_property(GObject* p,guint property,const GValue* value,GParamSpec* spec){
|
||||
auto* s=(FramelySink*)p;if(property==1 && s->output)g_object_set(s->output,"sync",g_value_get_boolean(value),nullptr);else G_OBJECT_WARN_INVALID_PROPERTY_ID(p,property,spec);
|
||||
}
|
||||
static void get_property(GObject* p,guint property,GValue* value,GParamSpec* spec){
|
||||
auto* s=(FramelySink*)p;if(property==1 && s->output){gboolean sync=true;g_object_get(s->output,"sync",&sync,nullptr);g_value_set_boolean(value,sync);}else G_OBJECT_WARN_INVALID_PROPERTY_ID(p,property,spec);
|
||||
}
|
||||
static void install_properties(GObjectClass* c){c->set_property=set_property;c->get_property=get_property;g_object_class_install_property(c,1,g_param_spec_boolean("sync","Sync","Synchronize playback to the pipeline clock",true,G_PARAM_READWRITE));}
|
||||
static void framely_sink_class_init(FramelySinkClass* c){G_OBJECT_CLASS(c)->finalize=finalize;install_properties(G_OBJECT_CLASS(c));gst_element_class_set_static_metadata(GST_ELEMENT_CLASS(c),"Framely video consent sink","Sink/Video","Gated AirPlay receiver","Framely");}
|
||||
static void initialize(FramelySink* s,bool audio){const char* d=getenv("FRAMELY_CAST_DIR"),*i=getenv("FRAMELY_CAST_ID");s->frame=new FrameSink(d?d:"/tmp",i?i:"airplay","AirPlay");GST_OBJECT_FLAG_SET(s,GST_ELEMENT_FLAG_SINK);setup(s,audio);}
|
||||
static void framely_sink_init(FramelySink* s){initialize(s,false);}
|
||||
static void framely_audio_class_init(FramelyAudioClass* c){G_OBJECT_CLASS(c)->finalize=finalize;install_properties(G_OBJECT_CLASS(c));gst_element_class_set_static_metadata(GST_ELEMENT_CLASS(c),"Framely audio consent sink","Sink/Audio","Gated AirPlay receiver","Framely");}
|
||||
static void framely_audio_init(FramelyAudio* s){initialize(s,true);}
|
||||
static gboolean plugin_init(GstPlugin* p){return gst_element_register(p,"framelyvideosink",GST_RANK_NONE,framely_sink_get_type()) && gst_element_register(p,"framelyaudiosink",GST_RANK_NONE,framely_audio_get_type());}
|
||||
#ifndef PACKAGE
|
||||
#define PACKAGE "framely"
|
||||
#endif
|
||||
GST_PLUGIN_DEFINE(GST_VERSION_MAJOR,GST_VERSION_MINOR,framely,"Framely casting",plugin_init,"1.0","AGPL","Framely","https://github.com/SteamFramelyHomebrew/framely")
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
#include <gst/gst.h>
|
||||
#include <gst/app/gstappsink.h>
|
||||
#include <gst/video/video.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
#include <atomic>
|
||||
// A frame file is written under an advisory lock. Readers never see a partial frame.
|
||||
struct FrameSink {
|
||||
std::string directory,id,protocol; std::atomic<bool> announced{false}; std::atomic<unsigned> width{0},height{0};
|
||||
FrameSink(std::string d,std::string i,std::string p):directory(d),id(i),protocol(p){}
|
||||
void event(const std::string& type,unsigned w=0,unsigned h=0,const std::string& media=""){
|
||||
int fd=socket(AF_UNIX,SOCK_DGRAM|SOCK_CLOEXEC,0); if(fd<0)return;
|
||||
sockaddr_un a{};a.sun_family=AF_UNIX; auto path=directory+"/events.sock";if(path.size()>=sizeof(a.sun_path)){close(fd);return;}strcpy(a.sun_path,path.c_str());
|
||||
auto msg="{\"event\":\""+type+"\",\"id\":\""+id+"\",\"protocol\":\""+protocol+"\",\"width\":"+std::to_string(w)+",\"height\":"+std::to_string(h)+",\"mediaType\":\""+media+"\"}";
|
||||
sendto(fd,msg.data(),msg.size(),MSG_DONTWAIT,(sockaddr*)&a,sizeof(a));close(fd);
|
||||
}
|
||||
bool accepted() const {return access((directory+"/"+id+".accept").c_str(),F_OK)==0;}
|
||||
GstFlowReturn sample(GstAppSink* sink,bool preroll=false){
|
||||
auto* sample=preroll?gst_app_sink_pull_preroll(sink):gst_app_sink_pull_sample(sink);if(!sample)return GST_FLOW_EOS;
|
||||
GstVideoInfo info{};GstVideoFrame frame{};
|
||||
if(gst_video_info_from_caps(&info,gst_sample_get_caps(sample))&&GST_VIDEO_INFO_FORMAT(&info)==GST_VIDEO_FORMAT_BGRA&&gst_video_frame_map(&frame,&info,gst_sample_get_buffer(sample),GST_MAP_READ)){
|
||||
unsigned w=GST_VIDEO_INFO_WIDTH(&info),h=GST_VIDEO_INFO_HEIGHT(&info);
|
||||
if(w&&h&&w<=4096&&h<=4096&&uint64_t(w)*h<=16777216){
|
||||
bool resized=width.exchange(w)!=w;resized=(height.exchange(h)!=h)||resized;bool first=!announced.exchange(true);if(resized||first)event("video",w,h);
|
||||
if(accepted()){
|
||||
std::string path=directory+"/"+id+".frame";int fd=open(path.c_str(),O_CREAT|O_WRONLY|O_CLOEXEC|O_NOFOLLOW,0600);
|
||||
if(fd>=0){flock(fd,LOCK_EX);uint32_t header[]={0x46434153,w,h,w*4};bool ok=pwrite(fd,header,sizeof(header),0)==sizeof(header);
|
||||
for(unsigned y=0;ok&&y<h;y++){auto* row=(uint8_t*)GST_VIDEO_FRAME_PLANE_DATA(&frame,0)+y*GST_VIDEO_FRAME_PLANE_STRIDE(&frame,0);ok=pwrite(fd,row,w*4,16+size_t(y)*w*4)==ssize_t(w*4);}
|
||||
if(ok)ftruncate(fd,16+size_t(w)*h*4);flock(fd,LOCK_UN);close(fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
gst_video_frame_unmap(&frame);
|
||||
}
|
||||
gst_sample_unref(sample);return GST_FLOW_OK;
|
||||
}
|
||||
};
|
||||
inline GstFlowReturn frame_sample(GstAppSink* sink,gpointer data){return static_cast<FrameSink*>(data)->sample(sink);}
|
||||
inline GstFlowReturn frame_preroll(GstAppSink* sink,gpointer data){return static_cast<FrameSink*>(data)->sample(sink,true);}
|
||||
@@ -0,0 +1,289 @@
|
||||
#include "frame_sink.h"
|
||||
#include <libgupnp/gupnp.h>
|
||||
#include "json.hpp"
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <cstdio>
|
||||
#include <mutex>
|
||||
#include <ifaddrs.h>
|
||||
#include <net/if.h>
|
||||
|
||||
using json = nlohmann::json;
|
||||
static std::string directory, current, own_udn, xml_directory;
|
||||
static GMainLoop* loop;
|
||||
static std::mutex output_mutex;
|
||||
static bool receive_enabled = false;
|
||||
static constexpr const char* protocols = "http-get:*:video/mp4:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp4:*,http-get:*:audio/flac:*,http-get:*:audio/wav:*,http-get:*:application/vnd.apple.mpegurl:*,http-get:*:application/x-mpegURL:*,http-get:*:video/mpeg:*";
|
||||
static void output(json j) { std::lock_guard<std::mutex> lock(output_mutex); std::cout << j.dump() << std::endl; }
|
||||
struct Media {
|
||||
FrameSink sink;
|
||||
GstElement* pipeline = nullptr;
|
||||
std::string uri, metadata;
|
||||
bool accepted = false, paused = false, muted = false;
|
||||
double volume = 1.;
|
||||
guint watch = 0;
|
||||
Media(std::string id, std::string u, std::string m): sink(directory,id,"DLNA"), uri(u), metadata(m) {}
|
||||
~Media() {
|
||||
if (watch) g_source_remove(watch);
|
||||
if (pipeline) { gst_element_set_state(pipeline,GST_STATE_NULL); gst_object_unref(pipeline); }
|
||||
}
|
||||
};
|
||||
static std::map<std::string,std::unique_ptr<Media>> sessions;
|
||||
static std::map<std::string,GUPnPServiceProxy*> devices;
|
||||
static std::vector<GUPnPService*> transports, renderers;
|
||||
static Media* active() { auto i = sessions.find(current); return i == sessions.end() ? nullptr : i->second.get(); }
|
||||
static std::string state() { auto* m = active(); return !m ? "NO_MEDIA_PRESENT" : !m->accepted ? "STOPPED" : m->paused ? "PAUSED_PLAYBACK" : "PLAYING"; }
|
||||
static gint64 position(Media* m, bool duration) {
|
||||
gint64 value = 0;
|
||||
if (m && m->pipeline) {
|
||||
if (duration) gst_element_query_duration(m->pipeline,GST_FORMAT_TIME,&value);
|
||||
else gst_element_query_position(m->pipeline,GST_FORMAT_TIME,&value);
|
||||
}
|
||||
return std::max(gint64(0),value);
|
||||
}
|
||||
static std::string time_string(gint64 ns) {
|
||||
auto seconds = ns / GST_SECOND;
|
||||
char text[48]; snprintf(text,sizeof(text),"%02lld:%02lld:%02lld",(long long)(seconds/3600),(long long)(seconds/60%60),(long long)(seconds%60));
|
||||
return text;
|
||||
}
|
||||
static std::string last_change(bool rendering) {
|
||||
std::string values;
|
||||
if (rendering) {
|
||||
auto* m = active();
|
||||
values = "<Volume channel=\"Master\" val=\"" + std::to_string(m ? unsigned(m->volume*100) : 100) + "\"/><Mute channel=\"Master\" val=\"" + std::string(m && m->muted ? "1" : "0") + "\"/>";
|
||||
} else values = "<TransportState val=\"" + state() + "\"/><TransportStatus val=\"OK\"/><TransportPlaySpeed val=\"1\"/>";
|
||||
return "<Event xmlns=\"urn:schemas-upnp-org:metadata-1-0/" + std::string(rendering ? "RCS" : "AVT") + "/\"><InstanceID val=\"0\">" + values + "</InstanceID></Event>";
|
||||
}
|
||||
static void notify() {
|
||||
for (auto* service : transports) { auto value = last_change(false); gupnp_service_notify(service,"LastChange",G_TYPE_STRING,value.c_str(),nullptr); }
|
||||
for (auto* service : renderers) { auto value = last_change(true); gupnp_service_notify(service,"LastChange",G_TYPE_STRING,value.c_str(),nullptr); }
|
||||
}
|
||||
static void erase_media(const std::string& id) {
|
||||
sessions.erase(id); unlink((directory+"/"+id+".accept").c_str());
|
||||
if (current == id) current.clear();
|
||||
notify();
|
||||
}
|
||||
static gboolean erase_later(gpointer data) { std::unique_ptr<std::string> id((std::string*)data); erase_media(*id); return G_SOURCE_REMOVE; }
|
||||
static gboolean bus(GstBus*, GstMessage* message, gpointer data) {
|
||||
auto* media = (Media*)data;
|
||||
if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR || GST_MESSAGE_TYPE(message) == GST_MESSAGE_EOS) {
|
||||
if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_ERROR) {
|
||||
GError* error=nullptr; gchar* debug=nullptr; gst_message_parse_error(message,&error,&debug);
|
||||
output({{"event","error"},{"id",media->sink.id},{"error",error ? error->message : "Media playback failed"}});
|
||||
g_clear_error(&error); g_free(debug);
|
||||
} else output({{"event","ended"},{"id",media->sink.id}});
|
||||
// Destroy on the main loop after this callback has returned. NULL state joins
|
||||
// streaming threads before their FrameSink and Media pointers are released.
|
||||
media->watch=0;
|
||||
g_idle_add(erase_later,new std::string(media->sink.id));
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
static void streams_changed(GstElement* p, gpointer data) {
|
||||
auto* m=(Media*)data; gint video=0,audio=0; g_object_get(p,"n-video",&video,"n-audio",&audio,nullptr);
|
||||
if (audio>0 && video==0 && !m->sink.announced.exchange(true)) m->sink.event("audio");
|
||||
}
|
||||
static void play(Media& media) {
|
||||
if (!media.pipeline) {
|
||||
media.pipeline=gst_element_factory_make("playbin",nullptr);
|
||||
if (!media.pipeline) throw std::runtime_error("GStreamer playbin unavailable");
|
||||
gst_object_ref_sink(media.pipeline);
|
||||
g_signal_connect(media.pipeline,"audio-changed",G_CALLBACK(streams_changed),&media);
|
||||
g_signal_connect(media.pipeline,"video-changed",G_CALLBACK(streams_changed),&media);
|
||||
GError* error=nullptr;
|
||||
auto* video=gst_parse_bin_from_description("videoflip video-direction=auto ! videoconvert ! videoscale ! video/x-raw,pixel-aspect-ratio=1/1,format=BGRA ! appsink name=frames emit-signals=true max-buffers=1 drop=true sync=true",true,&error);
|
||||
if (!video) { std::string reason=error ? error->message : "GStreamer video sink unavailable"; g_clear_error(&error); throw std::runtime_error(reason); }
|
||||
gst_object_ref_sink(video);
|
||||
auto* sink=gst_bin_get_by_name(GST_BIN(video),"frames");
|
||||
g_signal_connect(sink,"new-sample",G_CALLBACK(frame_sample),&media.sink);
|
||||
g_signal_connect(sink,"new-preroll",G_CALLBACK(frame_preroll),&media.sink); gst_object_unref(sink);
|
||||
auto* audio=gst_element_factory_make("pulsesink",nullptr);
|
||||
if (!audio) { gst_object_unref(video); throw std::runtime_error("GStreamer PulseAudio sink unavailable"); }
|
||||
gst_object_ref_sink(audio);
|
||||
g_object_set(media.pipeline,"uri",media.uri.c_str(),"video-sink",video,"audio-sink",audio,"volume",media.volume,"mute",media.muted,nullptr);
|
||||
gst_object_unref(audio); gst_object_unref(video);
|
||||
auto* b=gst_element_get_bus(media.pipeline); media.watch=gst_bus_add_watch(b,bus,&media); gst_object_unref(b);
|
||||
}
|
||||
if (gst_element_set_state(media.pipeline,media.paused ? GST_STATE_PAUSED : GST_STATE_PLAYING)==GST_STATE_CHANGE_FAILURE)
|
||||
throw std::runtime_error("Media could not start playback");
|
||||
}
|
||||
static bool seekable(Media& m) {
|
||||
if(!m.pipeline)return false;
|
||||
auto* query=gst_query_new_seeking(GST_FORMAT_TIME);gboolean allowed=false;
|
||||
if(gst_element_query(m.pipeline,query))gst_query_parse_seeking(query,nullptr,&allowed,nullptr,nullptr);
|
||||
gst_query_unref(query);return allowed;
|
||||
}
|
||||
static void seek(Media& m, double seconds) {
|
||||
if (!seekable(m) || !std::isfinite(seconds) || seconds<0 || seconds>86400*365 ||
|
||||
!gst_element_seek_simple(m.pipeline,GST_FORMAT_TIME,(GstSeekFlags)(GST_SEEK_FLAG_FLUSH|GST_SEEK_FLAG_ACCURATE),gint64(seconds*GST_SECOND)))
|
||||
throw std::runtime_error("This media cannot seek to the requested position");
|
||||
}
|
||||
static void set_volume(Media& m, double volume) {
|
||||
if (!std::isfinite(volume) || volume<0 || volume>1) throw std::runtime_error("Invalid volume");
|
||||
m.volume=volume; if (m.pipeline) g_object_set(m.pipeline,"volume",volume,nullptr); notify();
|
||||
}
|
||||
static void invoke(GUPnPServiceProxy* proxy,GUPnPServiceProxyAction* action) {
|
||||
GError* error=nullptr;
|
||||
if (!gupnp_service_proxy_call_action(proxy,action,nullptr,&error)) {
|
||||
std::string message=error ? error->message : "UPnP action failed"; g_clear_error(&error); gupnp_service_proxy_action_unref(action); throw std::runtime_error(message);
|
||||
}
|
||||
gupnp_service_proxy_action_unref(action);
|
||||
}
|
||||
static gboolean input(GIOChannel* channel,GIOCondition condition,gpointer) {
|
||||
if (condition&G_IO_HUP) { g_main_loop_quit(loop); return G_SOURCE_REMOVE; }
|
||||
gchar* line=nullptr; gsize length=0;
|
||||
if (g_io_channel_read_line(channel,&line,&length,nullptr,nullptr)!=G_IO_STATUS_NORMAL) return G_SOURCE_CONTINUE;
|
||||
std::unique_ptr<char,decltype(&g_free)> owner(line,g_free); json request;
|
||||
try {
|
||||
request=json::parse(line); std::string kind=request.value("kind",""), id=request.value("id",""); json result=true;
|
||||
if (kind=="devices") {
|
||||
result=json::array();
|
||||
for (auto& [id,p]:devices) {
|
||||
auto* info=GUPNP_DEVICE_INFO(g_object_get_data(G_OBJECT(p),"device")); auto* name=gupnp_device_info_get_friendly_name(info);
|
||||
auto* context=gupnp_service_info_get_context(GUPNP_SERVICE_INFO(p));
|
||||
result.push_back({{"id",id},{"name",name ? name : id},{"localAddress",gssdp_client_get_host_ip(GSSDP_CLIENT(context))}}); g_free(name);
|
||||
}
|
||||
} else if (kind=="send" || kind=="send.stop") {
|
||||
auto it=devices.find(id); if (it==devices.end()) throw std::runtime_error("Device is no longer available");
|
||||
if (kind=="send") {
|
||||
invoke(it->second,gupnp_service_proxy_action_new("SetAVTransportURI","InstanceID",G_TYPE_UINT,0,"CurrentURI",G_TYPE_STRING,request.at("uri").get<std::string>().c_str(),"CurrentURIMetaData",G_TYPE_STRING,"",nullptr));
|
||||
invoke(it->second,gupnp_service_proxy_action_new("Play","InstanceID",G_TYPE_UINT,0,"Speed",G_TYPE_STRING,"1",nullptr));
|
||||
} else invoke(it->second,gupnp_service_proxy_action_new("Stop","InstanceID",G_TYPE_UINT,0,nullptr));
|
||||
} else {
|
||||
auto it=sessions.find(id); if (it==sessions.end()) throw std::runtime_error("Media request expired"); auto& m=*it->second;
|
||||
if (kind=="accept") {
|
||||
if (m.accepted) throw std::runtime_error("Media already accepted");
|
||||
std::ofstream gate(directory+"/"+id+".accept"); if (!gate) throw std::runtime_error("Cannot accept media"); gate.close();
|
||||
m.accepted=true; play(m); notify();
|
||||
} else if (kind=="stop" || kind=="reject") { erase_media(id); output({{"event","ended"},{"id",id}}); }
|
||||
else {
|
||||
if (!m.accepted) throw std::runtime_error("Media has not been accepted");
|
||||
if (kind=="pause") { m.paused=request.value("paused",true); gst_element_set_state(m.pipeline,m.paused ? GST_STATE_PAUSED : GST_STATE_PLAYING); notify(); }
|
||||
else if (kind=="volume") set_volume(m,request.value("volume",1.));
|
||||
else if (kind=="seek") seek(m,request.value("seconds",0.));
|
||||
else if (kind=="status") result={{"position",position(&m,false)/double(GST_SECOND)},{"duration",position(&m,true)/double(GST_SECOND)},{"paused",m.paused},{"volume",m.volume},{"seekable",seekable(m)}};
|
||||
else throw std::runtime_error("Unknown media action");
|
||||
}
|
||||
}
|
||||
output({{"request",request.value("request",0)},{"result",result}});
|
||||
} catch (const std::exception& e) { output({{"request",request.value("request",0)},{"error",e.what()}}); }
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
static void available(GUPnPControlPoint*,GUPnPDeviceProxy* device,gpointer) {
|
||||
auto* service=gupnp_device_info_get_service(GUPNP_DEVICE_INFO(device),"urn:schemas-upnp-org:service:AVTransport:1"); if (!service) return;
|
||||
std::string id=gupnp_device_info_get_udn(GUPNP_DEVICE_INFO(device));
|
||||
if (id==own_udn) { g_object_unref(service); return; }
|
||||
if (devices.count(id)) g_object_unref(devices[id]);
|
||||
g_object_set_data_full(G_OBJECT(service),"device",g_object_ref(device),g_object_unref); devices[id]=GUPNP_SERVICE_PROXY(service);
|
||||
}
|
||||
static void unavailable(GUPnPControlPoint*,GUPnPDeviceProxy* device,gpointer) {
|
||||
std::string id=gupnp_device_info_get_udn(GUPNP_DEVICE_INFO(device)); auto it=devices.find(id);
|
||||
if (it!=devices.end() && g_object_get_data(G_OBJECT(it->second),"device")==device) { g_object_unref(it->second); devices.erase(it); }
|
||||
}
|
||||
static void action(GUPnPService*,GUPnPServiceAction* a,gpointer) {
|
||||
const char* name=gupnp_service_action_get_name(a);
|
||||
try {
|
||||
if (strcmp(name,"GetProtocolInfo") && strncmp(name,"GetCurrentConnection",20)) {
|
||||
guint instance=0; gupnp_service_action_get(a,"InstanceID",G_TYPE_UINT,&instance,nullptr);
|
||||
if (instance!=0) { gupnp_service_action_return_error(a,718,"Invalid InstanceID"); return; }
|
||||
}
|
||||
if (!strcmp(name,"SetAVTransportURI")) {
|
||||
gchar *uri=nullptr,*metadata=nullptr; gupnp_service_action_get(a,"CurrentURI",G_TYPE_STRING,&uri,"CurrentURIMetaData",G_TYPE_STRING,&metadata,nullptr);
|
||||
std::string u=uri ? uri : "", meta=metadata ? metadata : ""; g_free(uri); g_free(metadata);
|
||||
if (u.size()>8192 || meta.size()>65536 || (u.rfind("http://",0)!=0 && u.rfind("https://",0)!=0)) throw std::runtime_error("Only HTTP media URLs are supported");
|
||||
if (sessions.size()>=16) throw std::runtime_error("Window limit reached");
|
||||
gchar* id=g_uuid_string_random(); current=id; g_free(id);
|
||||
sessions[current]=std::make_unique<Media>(current,u,meta); notify();
|
||||
output({{"event","request"},{"id",current},{"protocol","DLNA"},{"uri",u}});
|
||||
} else if (!strcmp(name,"GetTransportInfo")) gupnp_service_action_set(a,"CurrentTransportState",G_TYPE_STRING,state().c_str(),"CurrentTransportStatus",G_TYPE_STRING,"OK","CurrentSpeed",G_TYPE_STRING,"1",nullptr);
|
||||
else if (!strcmp(name,"GetMediaInfo")) { auto* m=active(); auto duration=time_string(position(m,true)); gupnp_service_action_set(a,"NrTracks",G_TYPE_UINT,m ? 1u : 0u,"MediaDuration",G_TYPE_STRING,duration.c_str(),"CurrentURI",G_TYPE_STRING,m ? m->uri.c_str() : "","CurrentURIMetaData",G_TYPE_STRING,m ? m->metadata.c_str() : "","NextURI",G_TYPE_STRING,"","NextURIMetaData",G_TYPE_STRING,"","PlayMedium",G_TYPE_STRING,"NETWORK","RecordMedium",G_TYPE_STRING,"NOT_IMPLEMENTED","WriteStatus",G_TYPE_STRING,"NOT_IMPLEMENTED",nullptr); }
|
||||
else if (!strcmp(name,"GetPositionInfo")) { auto* m=active(); auto duration=time_string(position(m,true)), pos=time_string(position(m,false)); gupnp_service_action_set(a,"Track",G_TYPE_UINT,m ? 1u : 0u,"TrackDuration",G_TYPE_STRING,duration.c_str(),"TrackMetaData",G_TYPE_STRING,m ? m->metadata.c_str() : "","TrackURI",G_TYPE_STRING,m ? m->uri.c_str() : "","RelTime",G_TYPE_STRING,pos.c_str(),"AbsTime",G_TYPE_STRING,pos.c_str(),"RelCount",G_TYPE_INT,0,"AbsCount",G_TYPE_INT,0,nullptr); }
|
||||
else if (!strcmp(name,"GetDeviceCapabilities")) gupnp_service_action_set(a,"PlayMedia",G_TYPE_STRING,"NETWORK","RecMedia",G_TYPE_STRING,"NOT_IMPLEMENTED","RecQualityModes",G_TYPE_STRING,"NOT_IMPLEMENTED",nullptr);
|
||||
else if (!strcmp(name,"GetTransportSettings")) gupnp_service_action_set(a,"PlayMode",G_TYPE_STRING,"NORMAL","RecQualityMode",G_TYPE_STRING,"NOT_IMPLEMENTED",nullptr);
|
||||
else if (!strcmp(name,"GetCurrentTransportActions")) gupnp_service_action_set(a,"Actions",G_TYPE_STRING,active() && active()->accepted ? "Play,Pause,Stop,Seek" : "Play,Stop",nullptr);
|
||||
else if (!strcmp(name,"GetProtocolInfo")) gupnp_service_action_set(a,"Source",G_TYPE_STRING,"","Sink",G_TYPE_STRING,protocols,nullptr);
|
||||
else if (!strcmp(name,"GetCurrentConnectionIDs")) gupnp_service_action_set(a,"ConnectionIDs",G_TYPE_STRING,"0",nullptr);
|
||||
else if (!strcmp(name,"GetCurrentConnectionInfo")) gupnp_service_action_set(a,"RcsID",G_TYPE_INT,0,"AVTransportID",G_TYPE_INT,0,"ProtocolInfo",G_TYPE_STRING,"http-get:*:*:*","PeerConnectionManager",G_TYPE_STRING,"","PeerConnectionID",G_TYPE_INT,-1,"Direction",G_TYPE_STRING,"Input","Status",G_TYPE_STRING,"OK",nullptr);
|
||||
else if (!strcmp(name,"GetVolume")) gupnp_service_action_set(a,"CurrentVolume",G_TYPE_UINT,active() ? unsigned(active()->volume*100) : 100u,nullptr);
|
||||
else if (!strcmp(name,"GetMute")) gupnp_service_action_set(a,"CurrentMute",G_TYPE_BOOLEAN,active() && active()->muted,nullptr);
|
||||
else {
|
||||
auto* m=active(); if (!m) { gupnp_service_action_return_error(a,701,"No media present"); return; }
|
||||
if (!strcmp(name,"Stop")) { auto id=current; erase_media(id); output({{"event","ended"},{"id",id}}); }
|
||||
else if (!strcmp(name,"Pause")) { m->paused=true; if (m->pipeline) gst_element_set_state(m->pipeline,GST_STATE_PAUSED); notify(); }
|
||||
else if (!strcmp(name,"Play")) { gchar* speed=nullptr; gupnp_service_action_get(a,"Speed",G_TYPE_STRING,&speed,nullptr); bool supported=speed && !strcmp(speed,"1"); g_free(speed); if (!supported) { gupnp_service_action_return_error(a,717,"Unsupported play speed"); return; } m->paused=false; if (m->accepted) play(*m); notify(); }
|
||||
else if (!strcmp(name,"Seek")) { gchar *unit=nullptr,*target=nullptr; gupnp_service_action_get(a,"Unit",G_TYPE_STRING,&unit,"Target",G_TYPE_STRING,&target,nullptr); std::string u=unit ? unit : "",t=target ? target : ""; g_free(unit); g_free(target); unsigned h=0,min=0; double sec=0; int count=0; if (u!="REL_TIME" || sscanf(t.c_str(),"%u:%u:%lf%n",&h,&min,&sec,&count)!=3 || count!=int(t.size()) || min>=60 || sec<0 || sec>=60) throw std::runtime_error("Unsupported seek target"); seek(*m,h*3600. + min*60. + sec); }
|
||||
else if (!strcmp(name,"SetVolume")) { guint value=100; gupnp_service_action_get(a,"DesiredVolume",G_TYPE_UINT,&value,nullptr); set_volume(*m,std::min(value,100u)/100.); }
|
||||
else if (!strcmp(name,"SetMute")) { gboolean value=false; gupnp_service_action_get(a,"DesiredMute",G_TYPE_BOOLEAN,&value,nullptr); m->muted=value; if (m->pipeline) g_object_set(m->pipeline,"mute",value,nullptr); notify(); }
|
||||
}
|
||||
gupnp_service_action_return_success(a);
|
||||
} catch (const std::exception& e) { gupnp_service_action_return_error(a,714,e.what()); }
|
||||
}
|
||||
static void query_variable(GUPnPService*,const char* variable,GValue* value,gpointer rendering) {
|
||||
g_value_init(value,G_TYPE_STRING);
|
||||
if (!strcmp(variable,"LastChange")) { auto text=last_change(GPOINTER_TO_INT(rendering)); g_value_set_string(value,text.c_str()); }
|
||||
else if (!strcmp(variable,"SinkProtocolInfo")) g_value_set_string(value,protocols);
|
||||
else if (!strcmp(variable,"CurrentConnectionIDs")) g_value_set_string(value,"0");
|
||||
else g_value_set_string(value,"");
|
||||
}
|
||||
static bool lan_context(GUPnPContext* context) {
|
||||
ifaddrs* first=nullptr; if (getifaddrs(&first)) return false;
|
||||
bool result=false; auto* name=gssdp_client_get_interface(GSSDP_CLIENT(context));
|
||||
for (auto* i=first;i;i=i->ifa_next) if (i->ifa_name && name && !strcmp(i->ifa_name,name) && (i->ifa_flags&IFF_UP) && (i->ifa_flags&IFF_RUNNING) && (i->ifa_flags&IFF_BROADCAST) && !(i->ifa_flags&(IFF_LOOPBACK|IFF_POINTOPOINT))) result=true;
|
||||
freeifaddrs(first); return result;
|
||||
}
|
||||
static void context_available(GUPnPContextManager* manager,GUPnPContext* context,gpointer) {
|
||||
if (!lan_context(context)) return;
|
||||
try {
|
||||
g_object_set(gupnp_context_get_session(context),"timeout",5u,nullptr);
|
||||
auto* cp=gupnp_control_point_new(context,"urn:schemas-upnp-org:device:MediaRenderer:1");
|
||||
g_signal_connect(cp,"device-proxy-available",G_CALLBACK(available),nullptr); g_signal_connect(cp,"device-proxy-unavailable",G_CALLBACK(unavailable),nullptr);
|
||||
gupnp_context_manager_manage_control_point(manager,cp); gssdp_resource_browser_set_active(GSSDP_RESOURCE_BROWSER(cp),true); g_object_unref(cp);
|
||||
if (receive_enabled) {
|
||||
GError* error=nullptr; auto* root=gupnp_root_device_new(context,"device.xml",xml_directory.c_str(),&error);
|
||||
if (!root) { std::string reason=error ? error->message : "DLNA description error"; g_clear_error(&error); throw std::runtime_error(reason); }
|
||||
own_udn=gupnp_device_info_get_udn(GUPNP_DEVICE_INFO(root));
|
||||
for (auto type:{"AVTransport","RenderingControl","ConnectionManager"}) {
|
||||
std::string urn="urn:schemas-upnp-org:service:"+std::string(type)+":1"; auto* service=gupnp_device_info_get_service(GUPNP_DEVICE_INFO(root),urn.c_str());
|
||||
if (!service) throw std::runtime_error("Missing DLNA service");
|
||||
for (auto name:{"SetAVTransportURI","Play","Pause","Stop","Seek","GetTransportInfo","GetMediaInfo","GetPositionInfo","GetDeviceCapabilities","GetTransportSettings","GetCurrentTransportActions","GetProtocolInfo","GetCurrentConnectionIDs","GetCurrentConnectionInfo","GetVolume","GetMute","SetVolume","SetMute"}) {
|
||||
std::string signal="action-invoked::"+std::string(name); g_signal_connect(service,signal.c_str(),G_CALLBACK(action),nullptr);
|
||||
}
|
||||
g_signal_connect(service,"query-variable",G_CALLBACK(query_variable),GINT_TO_POINTER(!strcmp(type,"RenderingControl")));
|
||||
if (!strcmp(type,"AVTransport")) transports.push_back(GUPNP_SERVICE(service));
|
||||
else if (!strcmp(type,"RenderingControl")) renderers.push_back(GUPNP_SERVICE(service));
|
||||
else g_object_unref(service);
|
||||
}
|
||||
gupnp_context_manager_manage_root_device(manager,root); gupnp_root_device_set_available(root,true); g_object_unref(root);
|
||||
}
|
||||
output({{"event","ready"},{"port",gupnp_context_get_port(context)},{"host",gssdp_client_get_host_ip(GSSDP_CLIENT(context))}});
|
||||
} catch (const std::exception& e) { output({{"event","fatal"},{"error",e.what()}}); }
|
||||
}
|
||||
int main(int argc,char** argv) {
|
||||
try {
|
||||
if (argc<4) throw std::runtime_error("receiver DIRECTORY XMLDIR RECEIVE");
|
||||
directory=argv[1]; xml_directory=argv[2]; receive_enabled=!strcmp(argv[3],"1"); gst_init(&argc,&argv);
|
||||
// Frame's V4L2/Vulkan decoders can return corrupted frames and block
|
||||
// playback controls. Prefer the bundled FFmpeg software decoders.
|
||||
for (const char* name : {"avdec_h264", "avdec_h265"}) {
|
||||
auto* decoder=gst_element_factory_find(name);
|
||||
if (!decoder) throw std::runtime_error(std::string("Required decoder unavailable: ")+name);
|
||||
gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(decoder),GST_RANK_PRIMARY+100);
|
||||
gst_object_unref(decoder);
|
||||
}
|
||||
for (const char* name : {"v4l2h264dec", "v4l2h265dec", "vulkanh264dec", "vulkanh265dec"}) {
|
||||
auto* decoder=gst_element_factory_find(name);
|
||||
if (decoder) { gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(decoder),GST_RANK_NONE); gst_object_unref(decoder); }
|
||||
}
|
||||
auto* manager=gupnp_context_manager_create_full(GSSDP_UDA_VERSION_1_0,G_SOCKET_FAMILY_IPV4,0);
|
||||
g_signal_connect(manager,"context-available",G_CALLBACK(context_available),nullptr);
|
||||
loop=g_main_loop_new(nullptr,false); auto* channel=g_io_channel_unix_new(STDIN_FILENO); g_io_add_watch(channel,(GIOCondition)(G_IO_IN|G_IO_HUP),input,nullptr);
|
||||
g_main_loop_run(loop); sessions.clear();
|
||||
for (auto& [_,p]:devices) g_object_unref(p);
|
||||
for (auto* p:transports) g_object_unref(p);
|
||||
for (auto* p:renderers) g_object_unref(p);
|
||||
g_io_channel_unref(channel); g_object_unref(manager); g_main_loop_unref(loop); return 0;
|
||||
} catch (const std::exception& e) { output({{"event","fatal"},{"error",e.what()}}); return 1; }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add only lifecycle events to UxPlay; protocol/decoder code remains upstream."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
p=Path(sys.argv[1])/'uxplay.cpp'
|
||||
s=p.read_text()
|
||||
if 'framely_client_ended' not in s:
|
||||
at=s.index('extern "C" void conn_init')
|
||||
s=s[:at]+'''#include "framely/frame_sink.h"
|
||||
static void framely_client_ended() {
|
||||
const char* directory=getenv("FRAMELY_CAST_DIR"),*id=getenv("FRAMELY_CAST_ID");
|
||||
if(directory&&id)FrameSink(directory,id,"AirPlay").event("ended");
|
||||
}
|
||||
|
||||
'''+s[at:]
|
||||
s=s.replace('if (open_connections == 0) {','if (open_connections == 0) {\n framely_client_ended();',1)
|
||||
s=s.replace('extern "C" void conn_reset (void *cls, int reason) {','extern "C" void conn_reset (void *cls, int reason) {\n framely_client_ended();',1)
|
||||
p.write_text(s)
|
||||
from shutil import copyfile
|
||||
dst=p.parent/'framely';dst.mkdir(exist_ok=True)
|
||||
copyfile(Path(__file__).parent/'native/frame_sink.h',dst/'frame_sink.h')
|
||||
|
||||
# Keep one client throughout a consented session; a second device must not
|
||||
# inherit the first device's permission while UxPlay still has open sockets.
|
||||
s=p.read_text()
|
||||
if 'framely_client_device' not in s:
|
||||
s=s.replace('#include "framely/frame_sink.h"','#include "framely/frame_sink.h"\n#include <mutex>\nstatic std::mutex framely_client_mutex;\nstatic std::string framely_client_device;')
|
||||
s=s.replace('static void framely_client_ended() {','static void framely_client_ended() {\n {std::lock_guard<std::mutex> lock(framely_client_mutex);framely_client_device.clear();}')
|
||||
marker=' // Pass device model to renderer for device frame display'
|
||||
assert marker in s
|
||||
s=s.replace(marker,''' if (*admit && deviceid) {
|
||||
std::lock_guard<std::mutex> lock(framely_client_mutex);
|
||||
if (framely_client_device.empty()) framely_client_device=deviceid;
|
||||
else if (framely_client_device!=deviceid) *admit=false;
|
||||
}
|
||||
'''+marker)
|
||||
p.write_text(s)
|
||||
|
||||
# A GStreamer sink can survive successive audio sessions. Announce each valid
|
||||
# media SETUP independently of its first decoded buffer.
|
||||
s=p.read_text()
|
||||
if 'framely_media_request' not in s:
|
||||
marker=' unsigned char type;\n LOGI("ct=%d'
|
||||
assert marker in s
|
||||
s=s.replace(marker,''' // framely_media_request: a new SETUP needs fresh consent after teardown.
|
||||
const char* directory=getenv("FRAMELY_CAST_DIR"),*id=getenv("FRAMELY_CAST_ID");
|
||||
if(directory&&id)FrameSink(directory,id,"AirPlay").event("request");
|
||||
unsigned char type;
|
||||
LOGI("ct=%d''',1)
|
||||
p.write_text(s)
|
||||
|
||||
# Mirroring may have no audio stream. Its size report arrives before decoding,
|
||||
# so it must also request consent independently of the decoder's first frame.
|
||||
s=p.read_text()
|
||||
if 'framely_mirror_request' not in s:
|
||||
marker='extern "C" void video_report_size(void *cls, float *width_source, float *height_source, float *width, float *height) {'
|
||||
assert marker in s
|
||||
s=s.replace(marker,marker+'''
|
||||
// framely_mirror_request: notify even while the video decoder is starting.
|
||||
const char* directory=getenv("FRAMELY_CAST_DIR"),*id=getenv("FRAMELY_CAST_ID");
|
||||
if(directory&&id&&*width_source>0&&*width_source<=4096&&*height_source>0&&*height_source<=4096)
|
||||
FrameSink(directory,id,"AirPlay").event("request",(unsigned)*width_source,(unsigned)*height_source);
|
||||
''',1)
|
||||
p.write_text(s)
|
||||
|
||||
# Classify media before a decoded buffer arrives, including URL playback.
|
||||
s=p.read_text()
|
||||
if 'framely_media_kind' not in s:
|
||||
marker='if(directory&&id)FrameSink(directory,id,"AirPlay").event("request");'
|
||||
assert marker in s
|
||||
s=s.replace(marker, 'if(directory&&id)FrameSink(directory,id,"AirPlay").event("request",0,0,*usingScreen?"video":"audio"); // framely_media_kind',1)
|
||||
marker='extern "C" void on_video_play(void *cls, const char* location, const float start_position) {'
|
||||
assert marker in s
|
||||
s=s.replace(marker, marker+'\n const char* directory=getenv("FRAMELY_CAST_DIR"),*id=getenv("FRAMELY_CAST_ID");\n if(directory&&id)FrameSink(directory,id,"AirPlay").event("request",0,0,"video");',1)
|
||||
# stdout is a file in production; retain useful errors before a restart.
|
||||
s=s.replace(' va_end(vargs);', ' va_end(vargs);\n fflush(stdout);',1)
|
||||
p.write_text(s)
|
||||
from shutil import copyfile
|
||||
copyfile(Path(__file__).parent/'native/frame_sink.h',p.parent/'framely/frame_sink.h')
|
||||
|
||||
# A mirror SETUP is already an explicit video request; do not wait for sound
|
||||
# or the first codec packet before asking for consent.
|
||||
header=p.parent/'lib/raop.h'
|
||||
s=header.read_text()
|
||||
if 'framely_video_setup' not in s:
|
||||
marker=' void (*video_report_size)'
|
||||
assert marker in s
|
||||
s=s.replace(marker, ' void (*framely_video_setup)(void *cls);\n'+marker,1)
|
||||
header.write_text(s)
|
||||
handlers=p.parent/'lib/raop_handlers.h'
|
||||
s=handlers.read_text()
|
||||
if 'callbacks.framely_video_setup' not in s:
|
||||
marker=' raop_rtp_mirror_start(conn->raop_rtp_mirror, &dport, raop->clientFPSdata);'
|
||||
assert marker in s
|
||||
s=s.replace(marker,marker+'\n if (raop->callbacks.framely_video_setup) raop->callbacks.framely_video_setup(raop->callbacks.cls);',1)
|
||||
handlers.write_text(s)
|
||||
s=p.read_text()
|
||||
if 'extern "C" void framely_video_setup' not in s:
|
||||
marker='extern "C" void video_report_size('
|
||||
at=s.index(marker)
|
||||
s=s[:at]+'''extern "C" void framely_video_setup(void *cls) {
|
||||
const char* directory=getenv("FRAMELY_CAST_DIR"),*id=getenv("FRAMELY_CAST_ID");
|
||||
if(directory&&id)FrameSink(directory,id,"AirPlay").event("request",0,0,"video");
|
||||
}
|
||||
|
||||
'''+s[at:]
|
||||
marker=' raop_cbs.video_report_size = video_report_size;'
|
||||
assert marker in s
|
||||
s=s.replace(marker,marker+'\n raop_cbs.framely_video_setup = framely_video_setup;',1)
|
||||
p.write_text(s)
|
||||
# HLS uses playbin's separate audio sink; apply the same consent gate there.
|
||||
renderer=p.parent/'renderers/video_renderer.c'
|
||||
s=renderer.read_text()
|
||||
if 'Framely HLS consent' not in s:
|
||||
marker=' g_object_set(G_OBJECT (renderer_type[i]->pipeline), "video-sink", playbin_videosink, NULL);'
|
||||
assert marker in s
|
||||
s=s.replace(marker,marker+'''
|
||||
/* Framely HLS consent: audio must use the accepted session too. */
|
||||
if (!strcmp(videosink, "framelyvideosink")) {
|
||||
GstElement *audio = gst_element_factory_make("framelyaudiosink", NULL);
|
||||
g_assert(audio);
|
||||
g_object_set(renderer_type[i]->pipeline, "audio-sink", audio, NULL);
|
||||
}
|
||||
''',1)
|
||||
renderer.write_text(s)
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<scpd xmlns="urn:schemas-upnp-org:service-1-0"><specVersion><major>1</major><minor>0</minor></specVersion><actionList><action><name>SetAVTransportURI</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>CurrentURI</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_CurrentURI</relatedStateVariable></argument><argument><name>CurrentURIMetaData</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_CurrentURIMetaData</relatedStateVariable></argument></argumentList></action><action><name>Play</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>Speed</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Speed</relatedStateVariable></argument></argumentList></action><action><name>Pause</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument></argumentList></action><action><name>Stop</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument></argumentList></action><action><name>GetTransportInfo</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>CurrentTransportState</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentTransportState</relatedStateVariable></argument><argument><name>CurrentTransportStatus</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentTransportStatus</relatedStateVariable></argument><argument><name>CurrentSpeed</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentSpeed</relatedStateVariable></argument></argumentList></action><action><name>GetMediaInfo</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>NrTracks</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_NrTracks</relatedStateVariable></argument><argument><name>MediaDuration</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_MediaDuration</relatedStateVariable></argument><argument><name>CurrentURI</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentURI</relatedStateVariable></argument><argument><name>CurrentURIMetaData</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentURIMetaData</relatedStateVariable></argument><argument><name>NextURI</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_NextURI</relatedStateVariable></argument><argument><name>NextURIMetaData</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_NextURIMetaData</relatedStateVariable></argument><argument><name>PlayMedium</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_PlayMedium</relatedStateVariable></argument><argument><name>RecordMedium</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_RecordMedium</relatedStateVariable></argument><argument><name>WriteStatus</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_WriteStatus</relatedStateVariable></argument></argumentList></action><action><name>Seek</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>Unit</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Unit</relatedStateVariable></argument><argument><name>Target</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Target</relatedStateVariable></argument></argumentList></action><action><name>GetDeviceCapabilities</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>PlayMedia</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_PlayMedia</relatedStateVariable></argument><argument><name>RecMedia</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_RecMedia</relatedStateVariable></argument><argument><name>RecQualityModes</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_RecQualityModes</relatedStateVariable></argument></argumentList></action><action><name>GetTransportSettings</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>PlayMode</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_PlayMode</relatedStateVariable></argument><argument><name>RecQualityMode</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_RecQualityMode</relatedStateVariable></argumeLine truncated
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<scpd xmlns="urn:schemas-upnp-org:service-1-0"><specVersion><major>1</major><minor>0</minor></specVersion><actionList><action><name>GetProtocolInfo</name><argumentList><argument><name>Source</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Source</relatedStateVariable></argument><argument><name>Sink</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Sink</relatedStateVariable></argument></argumentList></action><action><name>GetCurrentConnectionIDs</name><argumentList><argument><name>ConnectionIDs</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_ConnectionIDs</relatedStateVariable></argument></argumentList></action><action><name>GetCurrentConnectionInfo</name><argumentList><argument><name>ConnectionID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable></argument><argument><name>RcsID</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable></argument><argument><name>AVTransportID</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable></argument><argument><name>ProtocolInfo</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable></argument><argument><name>PeerConnectionManager</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_PeerConnectionManager</relatedStateVariable></argument><argument><name>PeerConnectionID</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_PeerConnectionID</relatedStateVariable></argument><argument><name>Direction</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable></argument><argument><name>Status</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Status</relatedStateVariable></argument></argumentList></action></actionList><serviceStateTable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Source</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Sink</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_ConnectionIDs</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_ConnectionID</name><dataType>i4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_RcsID</name><dataType>i4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_AVTransportID</name><dataType>i4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_ProtocolInfo</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_PeerConnectionManager</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_PeerConnectionID</name><dataType>i4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Direction</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Status</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="yes"><name>SourceProtocolInfo</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="yes"><name>SinkProtocolInfo</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="yes"><name>CurrentConnectionIDs</name><dataType>string</dataType></stateVariable></serviceStateTable></scpd>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<scpd xmlns="urn:schemas-upnp-org:service-1-0"><specVersion><major>1</major><minor>0</minor></specVersion><actionList><action><name>GetVolume</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>Channel</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable></argument><argument><name>CurrentVolume</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentVolume</relatedStateVariable></argument></argumentList></action><action><name>SetVolume</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>Channel</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable></argument><argument><name>DesiredVolume</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_DesiredVolume</relatedStateVariable></argument></argumentList></action><action><name>GetMute</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>Channel</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable></argument><argument><name>CurrentMute</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_CurrentMute</relatedStateVariable></argument></argumentList></action><action><name>SetMute</name><argumentList><argument><name>InstanceID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable></argument><argument><name>Channel</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Channel</relatedStateVariable></argument><argument><name>DesiredMute</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_DesiredMute</relatedStateVariable></argument></argumentList></action></actionList><serviceStateTable><stateVariable sendEvents="no"><name>A_ARG_TYPE_InstanceID</name><dataType>ui4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Channel</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_CurrentVolume</name><dataType>ui2</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_DesiredVolume</name><dataType>ui2</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_CurrentMute</name><dataType>boolean</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_DesiredMute</name><dataType>boolean</dataType></stateVariable><stateVariable sendEvents="yes"><name>LastChange</name><dataType>string</dataType></stateVariable></serviceStateTable></scpd>
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the standard UPnP AV service descriptions shipped with Framely."""
|
||||
from pathlib import Path
|
||||
from xml.etree.ElementTree import Element,SubElement,tostring
|
||||
root=Path(__file__).parent
|
||||
services={
|
||||
'AVTransport':[
|
||||
('SetAVTransportURI',[('InstanceID','in','ui4'),('CurrentURI','in','string'),('CurrentURIMetaData','in','string')]),
|
||||
('Play',[('InstanceID','in','ui4'),('Speed','in','string')]),('Pause',[('InstanceID','in','ui4')]),('Stop',[('InstanceID','in','ui4')]),
|
||||
('GetTransportInfo',[('InstanceID','in','ui4'),('CurrentTransportState','out','string'),('CurrentTransportStatus','out','string'),('CurrentSpeed','out','string')]),
|
||||
('GetMediaInfo',[('InstanceID','in','ui4')]+[(v,'out',t) for v,t in [('NrTracks','ui4'),('MediaDuration','string'),('CurrentURI','string'),('CurrentURIMetaData','string'),('NextURI','string'),('NextURIMetaData','string'),('PlayMedium','string'),('RecordMedium','string'),('WriteStatus','string')]]),
|
||||
('Seek',[('InstanceID','in','ui4'),('Unit','in','string'),('Target','in','string')]),
|
||||
('GetDeviceCapabilities',[('InstanceID','in','ui4'),('PlayMedia','out','string'),('RecMedia','out','string'),('RecQualityModes','out','string')]),
|
||||
('GetTransportSettings',[('InstanceID','in','ui4'),('PlayMode','out','string'),('RecQualityMode','out','string')]),
|
||||
('GetCurrentTransportActions',[('InstanceID','in','ui4'),('Actions','out','string')]),
|
||||
('GetPositionInfo',[('InstanceID','in','ui4')]+[(v,'out',t) for v,t in [('Track','ui4'),('TrackDuration','string'),('TrackMetaData','string'),('TrackURI','string'),('RelTime','string'),('AbsTime','string'),('RelCount','i4'),('AbsCount','i4')]])],
|
||||
'RenderingControl':[(k,[('InstanceID','in','ui4'),('Channel','in','string'),(v,d,t)]) for k,v,d,t in [('GetVolume','CurrentVolume','out','ui2'),('SetVolume','DesiredVolume','in','ui2'),('GetMute','CurrentMute','out','boolean'),('SetMute','DesiredMute','in','boolean')]],
|
||||
'ConnectionManager':[('GetProtocolInfo',[('Source','out','string'),('Sink','out','string')]),('GetCurrentConnectionIDs',[('ConnectionIDs','out','string')]),('GetCurrentConnectionInfo',[('ConnectionID','in','i4')]+[(v,'out',t) for v,t in [('RcsID','i4'),('AVTransportID','i4'),('ProtocolInfo','string'),('PeerConnectionManager','string'),('PeerConnectionID','i4'),('Direction','string'),('Status','string')]])],
|
||||
}
|
||||
for service,actions in services.items():
|
||||
doc=Element('scpd',xmlns='urn:schemas-upnp-org:service-1-0');version=SubElement(doc,'specVersion');SubElement(version,'major').text='1';SubElement(version,'minor').text='0';actionlist=SubElement(doc,'actionList');variables={}
|
||||
for name,args in actions:
|
||||
action=SubElement(actionlist,'action');SubElement(action,'name').text=name;arglist=SubElement(action,'argumentList')
|
||||
for v,d,t in args:
|
||||
arg=SubElement(arglist,'argument');SubElement(arg,'name').text=v;SubElement(arg,'direction').text=d;SubElement(arg,'relatedStateVariable').text='A_ARG_TYPE_'+v;variables[v]=t
|
||||
table=SubElement(doc,'serviceStateTable')
|
||||
for v,t in variables.items():
|
||||
var=SubElement(table,'stateVariable',sendEvents='no');SubElement(var,'name').text='A_ARG_TYPE_'+v;SubElement(var,'dataType').text=t
|
||||
for name in (['LastChange'] if service!='ConnectionManager' else ['SourceProtocolInfo','SinkProtocolInfo','CurrentConnectionIDs']):
|
||||
var=SubElement(table,'stateVariable',sendEvents='yes');SubElement(var,'name').text=name;SubElement(var,'dataType').text='string'
|
||||
(root/(service+'.xml')).write_bytes(tostring(doc,encoding='utf-8',xml_declaration=True))
|
||||
@@ -0,0 +1,23 @@
|
||||
# Always mount the shipped layer if available, but activate it only for the
|
||||
# explicitly selected package. No Android/system/Lepton files are replaced.
|
||||
if [[ -n "${FRAMELY_FBO_LIBRARY:-}" ]]; then
|
||||
declare -F setup_podman_mounts >/dev/null || { echo 'Unsupported Lepton graphics hooks' >&2; exit 64; }
|
||||
eval "$(declare -f setup_podman_mounts | sed '1s/setup_podman_mounts/framely_original_fbo_mounts/')"
|
||||
function setup_podman_mounts() {
|
||||
framely_original_fbo_mounts "$@" || return
|
||||
podman_mount_entry "$FRAMELY_FBO_LIBRARY" /data/local/debug/gles/libFramelyFBOCompat.so ro
|
||||
}
|
||||
fi
|
||||
if [[ -n "${FRAMELY_FBO_LIBRARY:-}" ]]; then
|
||||
[[ -z "${FRAMELY_FBO_PACKAGE:-}" || "$FRAMELY_FBO_PACKAGE" =~ ^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$ && -n "${FRAMELY_FBO_LIBRARY:-}" ]] || { echo 'Invalid framebuffer compatibility configuration' >&2; exit 64; }
|
||||
declare -F generate_vulkan_settings_script >/dev/null || { echo 'Unsupported Lepton graphics settings hook' >&2; exit 64; }
|
||||
eval "$(declare -f generate_vulkan_settings_script | sed '1s/generate_vulkan_settings_script/framely_original_fbo_settings/')"
|
||||
function generate_vulkan_settings_script() {
|
||||
# Lepton does not clear stale GLES settings when its GLES list is empty.
|
||||
# Remove our previous selection before its own settings are generated.
|
||||
printf '%s\n' 'if [ "$(settings get global gpu_debug_layers_gles)" = libFramelyFBOCompat.so ]; then settings delete global gpu_debug_layers_gles; settings delete global gpu_debug_layer_app; fi;'
|
||||
framely_original_fbo_settings "$@" || return
|
||||
[[ -n "${FRAMELY_FBO_PACKAGE:-}" ]] || return 0
|
||||
printf '%s\n' 'settings put global enable_gpu_debug_layers 1;' "settings put global gpu_debug_app $FRAMELY_FBO_PACKAGE;" 'settings put global gpu_debug_layers_gles libFramelyFBOCompat.so;' "settings put global gpu_debug_layer_app $FRAMELY_FBO_PACKAGE;"
|
||||
}
|
||||
fi
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
#include <sys/file.h>
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
// Raw decoded video is uploaded once into a GL texture, then scaled on the GPU.
|
||||
struct CastFrame {
|
||||
std::string path;GLuint texture=0,composite=0;int composite_width=0,composite_height=0;uint32_t width=0,height=0;timespec stamp{};std::vector<uint8_t> data;
|
||||
bool update(){if(path.empty())return false;int fd=open(path.c_str(),O_RDONLY|O_CLOEXEC|O_NOFOLLOW);if(fd<0)return false;
|
||||
struct stat st{};bool changed=fstat(fd,&st)==0&&(st.st_mtim.tv_sec!=stamp.tv_sec||st.st_mtim.tv_nsec!=stamp.tv_nsec);
|
||||
if(!changed||flock(fd,LOCK_SH|LOCK_NB)){close(fd);return false;}
|
||||
uint32_t header[4]{};bool valid=pread(fd,header,16,0)==16&&header[0]==0x46434153&&header[1]>0&&header[2]>0&&header[1]<=4096&&header[2]<=4096&&header[3]==header[1]*4&&st.st_size==16+int64_t(header[1])*header[2]*4;
|
||||
if(valid){data.resize(size_t(header[1])*header[2]*4);valid=pread(fd,data.data(),data.size(),16)==ssize_t(data.size());}
|
||||
flock(fd,LOCK_UN);close(fd);if(!valid)return false;
|
||||
stamp=st.st_mtim;width=header[1];height=header[2];if(!texture)glGenTextures(1,&texture);glBindTexture(GL_TEXTURE_2D,texture);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);glPixelStorei(GL_UNPACK_ROW_LENGTH,0);glPixelStorei(GL_UNPACK_ALIGNMENT,4);glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,width,height,0,GL_BGRA,GL_UNSIGNED_BYTE,data.data());return true;
|
||||
}
|
||||
GLuint draw(GLuint target,int target_width,int target_height){if(!texture||target_height<=0)return target;
|
||||
static auto gen=(PFNGLGENFRAMEBUFFERSPROC)glXGetProcAddressARB((const GLubyte*)"glGenFramebuffers");
|
||||
static auto bind=(PFNGLBINDFRAMEBUFFERPROC)glXGetProcAddressARB((const GLubyte*)"glBindFramebuffer");
|
||||
static auto attach=(PFNGLFRAMEBUFFERTEXTURE2DPROC)glXGetProcAddressARB((const GLubyte*)"glFramebufferTexture2D");
|
||||
static auto blit=(PFNGLBLITFRAMEBUFFERPROC)glXGetProcAddressARB((const GLubyte*)"glBlitFramebuffer");
|
||||
static auto del=(PFNGLDELETEFRAMEBUFFERSPROC)glXGetProcAddressARB((const GLubyte*)"glDeleteFramebuffers");
|
||||
if(!gen||!bind||!attach||!blit||!del)return target;
|
||||
if(!composite)glGenTextures(1,&composite);glBindTexture(GL_TEXTURE_2D,composite);if(composite_width!=target_width||composite_height!=target_height){glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,target_width,target_height,0,GL_BGRA,GL_UNSIGNED_BYTE,nullptr);composite_width=target_width;composite_height=target_height;}GLuint fbo[2];gen(2,fbo);bind(GL_READ_FRAMEBUFFER,fbo[0]);attach(GL_READ_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,texture,0);bind(GL_DRAW_FRAMEBUFFER,fbo[1]);attach(GL_DRAW_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,composite,0);// Decoder frames are top-down; CEF controls are already flipped for GL.
|
||||
blit(0,height,width,0,0,0,target_width,target_height,GL_COLOR_BUFFER_BIT,GL_LINEAR);
|
||||
// CEF paints transparent, premultiplied controls over the full video plane.
|
||||
// Keep its texture intact so partial CEF damage never contains stale video.
|
||||
bind(GL_FRAMEBUFFER,fbo[1]);glPushAttrib(GL_ALL_ATTRIB_BITS);glViewport(0,0,target_width,target_height);glDisable(GL_DEPTH_TEST);glDisable(GL_SCISSOR_TEST);glEnable(GL_TEXTURE_2D);glEnable(GL_BLEND);glBlendFunc(GL_ONE,GL_ONE_MINUS_SRC_ALPHA);glColor4f(1,1,1,1);glBindTexture(GL_TEXTURE_2D,target);
|
||||
glMatrixMode(GL_PROJECTION);glPushMatrix();glLoadIdentity();glOrtho(0,1,0,1,-1,1);glMatrixMode(GL_MODELVIEW);glPushMatrix();glLoadIdentity();glBegin(GL_QUADS);glTexCoord2f(0,0);glVertex2f(0,0);glTexCoord2f(1,0);glVertex2f(1,0);glTexCoord2f(1,1);glVertex2f(1,1);glTexCoord2f(0,1);glVertex2f(0,1);glEnd();glPopMatrix();glMatrixMode(GL_PROJECTION);glPopMatrix();glPopAttrib();bind(GL_FRAMEBUFFER,0);del(2,fbo);return composite;
|
||||
}
|
||||
~CastFrame(){if(texture)glDeleteTextures(1,&texture);if(composite)glDeleteTextures(1,&composite);}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
// Android GLES layer: opt-in workaround for missing framebuffer attachments.
|
||||
// No libc/GL dependency: entry points come from Android's layer loader.
|
||||
typedef void (*Attach)(unsigned, unsigned, unsigned, unsigned, int);
|
||||
typedef unsigned (*Check)(unsigned);
|
||||
static Attach attach;
|
||||
static Check check;
|
||||
static int same(const char *a, const char *b) {
|
||||
while (*a && *a == *b) { ++a; ++b; }
|
||||
return *a == *b;
|
||||
}
|
||||
static void validate_attachment(unsigned target, unsigned attachment,
|
||||
unsigned texture_target, unsigned texture, int level) {
|
||||
Attach next = __atomic_load_n(&attach, __ATOMIC_RELAXED);
|
||||
Check validate = __atomic_load_n(&check, __ATOMIC_RELAXED);
|
||||
next(target, attachment, texture_target, texture, level);
|
||||
// Force driver attachment validation before the next draw. Keep all GL
|
||||
// arguments, shader code and render commands unchanged.
|
||||
if (validate) validate(target);
|
||||
}
|
||||
__attribute__((visibility("default")))
|
||||
void AndroidGLESLayer_Initialize(void *id, void *(*lookup)(void *, const char *)) {
|
||||
__atomic_store_n(&attach, (Attach)lookup(id, "glFramebufferTexture2D"), __ATOMIC_RELAXED);
|
||||
__atomic_store_n(&check, (Check)lookup(id, "glCheckFramebufferStatus"), __ATOMIC_RELAXED);
|
||||
}
|
||||
__attribute__((visibility("default")))
|
||||
void *AndroidGLESLayer_GetProcAddress(const char *name, void *next) {
|
||||
if (next && __atomic_load_n(&check, __ATOMIC_RELAXED) && same(name, "glFramebufferTexture2D")) {
|
||||
__atomic_store_n(&attach, (Attach)next, __ATOMIC_RELAXED);
|
||||
return (void *)validate_attachment;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+10
-4
@@ -19,15 +19,19 @@ static void stop(int){stopped=1;}
|
||||
#include "gamepad_mapping.h"
|
||||
#include "gamepad_steam.h"
|
||||
#include "gamepad_feedback.h"
|
||||
#include "gamepad_direct.h"
|
||||
static const int axes[]={ABS_X,ABS_Y,ABS_RX,ABS_RY,ABS_Z,ABS_RZ,ABS_HAT0X,ABS_HAT0Y};
|
||||
static bool event(int fd,int type,int code,int value){input_event e{};e.type=type;e.code=code;e.value=value;return write(fd,&e,sizeof e)==sizeof e;}
|
||||
static bool send(int fd,const int* state){bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;}
|
||||
static GamepadTriggers triggers;
|
||||
static bool send(int fd,const int* state){bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<2;k++)ok=event(fd,EV_KEY,trigger_keys[k],triggers.update(k,state[14+k]))&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;}
|
||||
int main(int argc,char**argv){
|
||||
if((argc!=2&&argc!=4)||geteuid()==0){fprintf(stderr,"Run gamepad bridge as the Steam session user with an action manifest.\n");return 1;}
|
||||
// A session request runs on a short-lived worker thread. PR_SET_PDEATHSIG
|
||||
// follows that thread's lifetime, so it destroys the pad as soon as prepare
|
||||
// returns. The session-owned stdin pipe and parent process own this helper.
|
||||
const auto parent=getppid();
|
||||
signal(SIGTERM,stop);signal(SIGINT,stop);signal(SIGPIPE,SIG_IGN);
|
||||
if(const char* mode=getenv("FRAMELY_GAMEPAD_SOURCE");mode&&std::string(mode)=="steam-direct")return steam_direct(getenv("FRAMELY_STEAM_GAMEPAD_INFO"),getenv("FRAMELY_GAMEPAD_READY"));
|
||||
const bool steamMode=std::getenv("FRAMELY_GAMEPAD_SOURCE")&&std::string(std::getenv("FRAMELY_GAMEPAD_SOURCE"))=="steam";
|
||||
SteamGamepad steam;
|
||||
if(steamMode&&!steam.start(std::getenv("FRAMELY_STEAM_SDL_LIBRARY"),std::getenv("FRAMELY_STEAM_GAMEPAD_INFO"))){fprintf(stderr,"Steam Input SDL3 runtime unavailable; select Frame direct input in APK settings.\n");return 1;}
|
||||
@@ -58,6 +62,7 @@ int main(int argc,char**argv){
|
||||
int fd=open("/dev/uinput",O_RDWR|O_NONBLOCK|O_CLOEXEC);if(fd<0){perror("Gamepad /dev/uinput");shutdown();return 1;}
|
||||
bool ok=ioctl(fd,UI_SET_EVBIT,EV_KEY)==0&&ioctl(fd,UI_SET_EVBIT,EV_ABS)==0&&ioctl(fd,UI_SET_EVBIT,EV_FF)==0&&ioctl(fd,UI_SET_FFBIT,FF_RUMBLE)==0;
|
||||
for(auto key:keys)ok=ioctl(fd,UI_SET_KEYBIT,key)==0&&ok;
|
||||
for(auto key:trigger_keys)ok=ioctl(fd,UI_SET_KEYBIT,key)==0&&ok;
|
||||
for(int k=0;k<8;k++){ok=ioctl(fd,UI_SET_ABSBIT,axes[k])==0&&ok;uinput_abs_setup a{};a.code=axes[k];a.absinfo.minimum=k<4?-32768:(k<6?0:-1);a.absinfo.maximum=k<4?32767:(k<6?255:1);a.absinfo.flat=k<4?1024:0;ok=ioctl(fd,UI_ABS_SETUP,&a)==0&&ok;}
|
||||
uinput_setup device{};device.id={BUS_VIRTUAL,framely_pad_vendor,framely_pad_product,0x0001};device.ff_effects_max=GamepadFeedback::capacity;strcpy(device.name,"Framely Android gamepad");
|
||||
ok=ioctl(fd,UI_DEV_SETUP,&device)==0&&ioctl(fd,UI_DEV_CREATE)==0&&ok;
|
||||
@@ -69,8 +74,9 @@ int main(int argc,char**argv){
|
||||
if(witness<0){perror("Gamepad event access");ioctl(fd,UI_DEV_DESTROY);close(fd);shutdown();return 1;}
|
||||
printf("%s\n",node.c_str());fflush(stdout);
|
||||
// Parent owns routing. Start neutral; EOF, disconnect or disabled actions release input.
|
||||
bool enabled=false;std::string commands;int previous[18]{};GamepadFeedback feedback;std::array<uint16_t,2> lastRumble{};uint64_t rumbleAt=0;uint32_t source=0;bool frameConnected[2]{};
|
||||
while(!stopped&&getppid()==parent){pollfd p{STDIN_FILENO,POLLIN,0};if(poll(&p,1,0)>0){char buffer[128];auto count=read(0,buffer,sizeof buffer);if(count<=0)break;commands.append(buffer,count);size_t at;while((at=commands.find('\n'))!=std::string::npos){auto command=commands.substr(0,at);commands.erase(0,at+1);if(command=="enable")enabled=true;else if(command=="disable")enabled=false;else if(command=="rumble-on")rumbleEnabled=true;else if(command=="rumble-off")rumbleEnabled=false;}if(commands.size()>128)break;}
|
||||
if(const char* threshold=std::getenv("FRAMELY_GAMEPAD_TRIGGER_THRESHOLD"))triggers.configure(std::atoi(threshold));
|
||||
bool triggerChanged=false;bool enabled=false;std::string commands;int previous[18]{};GamepadFeedback feedback;std::array<uint16_t,2> lastRumble{};uint64_t rumbleAt=0;uint32_t source=0;bool frameConnected[2]{};
|
||||
while(!stopped&&getppid()==parent){pollfd p{STDIN_FILENO,POLLIN,0};if(poll(&p,1,0)>0){char buffer[128];auto count=read(0,buffer,sizeof buffer);if(count<=0)break;commands.append(buffer,count);size_t at;while((at=commands.find('\n'))!=std::string::npos){auto command=commands.substr(0,at);commands.erase(0,at+1);if(command=="enable")enabled=true;else if(command=="disable")enabled=false;else if(command=="rumble-on")rumbleEnabled=true;else if(command=="rumble-off")rumbleEnabled=false;else{int percent;char extra;if(std::sscanf(command.c_str(),"trigger-threshold %d %c",&percent,&extra)==1&&triggers.configure(percent))triggerChanged=true;}}if(commands.size()>128)break;}
|
||||
const auto now=milliseconds();
|
||||
// Android may upload effects even while unfocused. Always acknowledge the
|
||||
// kernel requests; only playback is gated by foreground ownership.
|
||||
@@ -101,7 +107,7 @@ int main(int argc,char**argv){
|
||||
lastRumble=vibration;rumbleAt=now;
|
||||
}
|
||||
if(!steamMode&&(vibration[0]||vibration[1]))for(int k=0;k<2;k++)if(haptics[k]!=vr::k_ulInvalidActionHandle&&vibration[k])input->TriggerHapticVibrationAction(haptics[k],0,.012f,100.f,float(vibration[k])/65535.f,vr::k_ulInvalidInputValueHandle);
|
||||
if(memcmp(previous,state,sizeof state)){if(!send(fd,state))break;memcpy(previous,state,sizeof state);}
|
||||
if(triggerChanged||memcmp(previous,state,sizeof state)){triggerChanged=false;if(!send(fd,state))break;memcpy(previous,state,sizeof state);}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(8));
|
||||
}
|
||||
steam.stop();feedback.stop();int neutral[18]{};send(fd,neutral);close(witness);ioctl(fd,UI_DEV_DESTROY);close(fd);shutdown();return 0;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
// Steam's already-configured evdev output, without SDL or a second uinput pad.
|
||||
#include "gamepad_steam.h"
|
||||
#include <filesystem>
|
||||
#include <sys/stat.h>
|
||||
#include <chrono>
|
||||
#include <poll.h>
|
||||
#include "gamepad_direct_policy.h"
|
||||
inline int direct_slot(const input_id& id,const char* name,const std::vector<SteamPadInfo>& rows){
|
||||
if(id.vendor!=0x28de||id.product!=0x11ff)return -1;
|
||||
const char* at=std::strstr(name,"pad ");int slot=-1;char extra;
|
||||
if(!at||std::sscanf(at+4,"%d%c",&slot,&extra)!=1||!allowed_steam_slot(rows,slot))return -1;
|
||||
return slot;
|
||||
}
|
||||
inline int steam_direct(const char* infoPath,const char* directory){
|
||||
if(!infoPath||!directory)return 1;
|
||||
std::ifstream info(infoPath);auto rows=read_steam_pad_info(info);
|
||||
std::string node;int slot=64;struct stat identity{};
|
||||
std::error_code ec;
|
||||
for(auto& entry:std::filesystem::directory_iterator("/dev/input",ec)){
|
||||
const auto name=entry.path().filename().string();if(name.rfind("event",0)!=0)continue;
|
||||
int fd=open(entry.path().c_str(),O_RDWR|O_NONBLOCK|O_CLOEXEC);if(fd<0)continue;
|
||||
input_id id{};char label[128]{};int next=-1;struct stat st{};
|
||||
if(ioctl(fd,EVIOCGID,&id)==0&&ioctl(fd,EVIOCGNAME(sizeof label),label)>0&&fstat(fd,&st)==0&&S_ISCHR(st.st_mode))next=direct_slot(id,label,rows);
|
||||
close(fd);if(next>=0&&next<slot){node=entry.path();slot=next;identity=st;}
|
||||
}
|
||||
if(node.empty()){fprintf(stderr,"No writable Steam Input output is available; wake the controller and retry, or select the bridge.\n");return 1;}
|
||||
const auto source=*std::find_if(rows.begin(),rows.end(),[&](auto p){return p.slot==slot;});
|
||||
const auto control=std::string(directory)+"/control";
|
||||
int ctl=open(control.c_str(),O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC,0644);if(ctl<0)return 1;
|
||||
if(fchmod(ctl,0644)!=0){close(ctl);return 1;}
|
||||
auto now=[](){return uint64_t(std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());};
|
||||
bool sourceValid=true;unsigned missing=0;bool enabled=false,rumble=!getenv("FRAMELY_GAMEPAD_RUMBLE")||std::string(getenv("FRAMELY_GAMEPAD_RUMBLE"))!="0";
|
||||
auto publish=[&](){FramelyDirectControl c{now()+2000000000ULL,uint32_t(enabled&&sourceValid),uint32_t(rumble)};return pwrite(ctl,&c,sizeof c,0)==sizeof c;};
|
||||
if(!publish()){close(ctl);return 1;}
|
||||
printf("%s\n",node.c_str());fflush(stdout);
|
||||
std::string commands;const auto parent=getppid();uint64_t scanned=0;
|
||||
while(!stopped&&getppid()==parent){
|
||||
pollfd p{STDIN_FILENO,POLLIN,0};int result=poll(&p,1,50);if(result<0&&errno!=EINTR)break;
|
||||
if(result>0&&(p.revents&(POLLIN|POLLHUP|POLLERR))){char buffer[128];auto n=read(0,buffer,sizeof buffer);if(n<=0)break;commands.append(buffer,n);size_t at;
|
||||
while((at=commands.find('\n'))!=std::string::npos){auto cmd=commands.substr(0,at);commands.erase(0,at+1);if(cmd=="enable")enabled=true;else if(cmd=="disable")enabled=false;else if(cmd=="rumble-on")rumble=true;else if(cmd=="rumble-off")rumble=false;}
|
||||
if(commands.size()>128)break;
|
||||
}
|
||||
if(now()-scanned>=500000000ULL){scanned=now();struct stat current{};std::ifstream file(infoPath);auto latest=read_steam_pad_info(file);
|
||||
if(stat(node.c_str(),¤t)!=0||current.st_ino!=identity.st_ino||current.st_rdev!=identity.st_rdev){
|
||||
fprintf(stderr,"Steam Input output changed/disconnected; restart this container to reconnect.\n");break;
|
||||
}
|
||||
sourceValid=std::any_of(latest.begin(),latest.end(),[&](auto p){return p.slot==source.slot&&p.identified&&p.vendor==source.vendor&&p.product==source.product;});
|
||||
if(sourceValid)missing=0;else if(++missing>=6){fprintf(stderr,"Steam Input controller ownership changed; restart this container to reconnect.\n");break;}
|
||||
}
|
||||
if(!publish())break;
|
||||
}
|
||||
enabled=false;rumble=false;publish();close(ctl);return 0;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
struct FramelyDirectControl { uint64_t expires_ns; uint32_t enabled; uint32_t rumble; };
|
||||
static inline int framely_direct_enabled(struct FramelyDirectControl c,uint64_t now){return c.enabled==1&&now<c.expires_ns;}
|
||||
static inline int framely_direct_rumble(struct FramelyDirectControl c,uint64_t now){return framely_direct_enabled(c,now)&&c.rumble==1;}
|
||||
+86
-2
@@ -1,15 +1,99 @@
|
||||
// Bionic preload: exclusively claim only Framely's mounted virtual event node.
|
||||
// Bionic preload: isolated virtual pad, or guarded Steam Input evdev passthrough.
|
||||
// Raw AArch64 syscalls avoid a glibc dependency inside Android.
|
||||
#define _GNU_SOURCE
|
||||
#include <stdarg.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/input.h>
|
||||
#include <time.h>
|
||||
#include <stddef.h>
|
||||
#include "gamepad_direct_policy.h"
|
||||
extern int *__errno(void);
|
||||
extern int pthread_create(unsigned long*,const void*,void*(*)(void*),void*);
|
||||
extern int pthread_detach(unsigned long);
|
||||
static long call(long nr,long a,long b,long c,long d){register long x0 __asm__("x0")=a;register long x1 __asm__("x1")=b;register long x2 __asm__("x2")=c;register long x3 __asm__("x3")=d;register long x8 __asm__("x8")=nr;__asm__ volatile("svc 0":"+r"(x0):"r"(x1),"r"(x2),"r"(x3),"r"(x8):"memory");return x0;}
|
||||
static long checked(long r){if(r<0){*__errno()=-r;return -1;}return r;}
|
||||
static int same(const char*a,const char*b){while(*a&&*a==*b){a++;b++;}return *a==*b;}
|
||||
static int opened(int dir,const char*p,int flags,int mode){long fd=call(56,dir,(long)p,flags,mode);if(fd<0){*__errno()=-fd;return -1;}if(same(p,"/dev/input/event250")){long r=call(29,fd,EVIOCGRAB,1,0);if(r==0){long ready=call(56,-100,(long)"/framely-gamepad-ready/ready",O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC,0644);if(ready>=0){call(64,ready,(long)"ready\n",6,0);call(57,ready,0,0,0);}}}return fd;}
|
||||
#ifndef FRAMELY_EVENT_PATH
|
||||
#define FRAMELY_EVENT_PATH "/dev/input/event250"
|
||||
#define FRAMELY_CONTROL_PATH "/framely-gamepad-control"
|
||||
#define FRAMELY_READY_PATH "/framely-gamepad-ready/ready"
|
||||
#endif
|
||||
#define MAX_FDS 4096
|
||||
static unsigned char direct_fds[MAX_FDS],was_enabled[MAX_FDS];
|
||||
static uint64_t playing[MAX_FDS];
|
||||
static int control=-1,lock=0,started=0;
|
||||
static void enter(void){while(__atomic_exchange_n(&lock,1,__ATOMIC_ACQUIRE))call(124,0,0,0,0);}
|
||||
static void leave(void){__atomic_store_n(&lock,0,__ATOMIC_RELEASE);}
|
||||
static struct FramelyDirectControl policy(uint64_t* now){
|
||||
struct timespec t;struct FramelyDirectControl c={0};*now=0;
|
||||
if(call(113,CLOCK_MONOTONIC,(long)&t,0,0)<0)return c;
|
||||
*now=(uint64_t)t.tv_sec*1000000000ULL+t.tv_nsec;
|
||||
if(control<0||call(67,control,(long)&c,sizeof c,0)!=sizeof c){c.expires_ns=0;c.enabled=0;c.rumble=0;}return c;
|
||||
}
|
||||
static void stop_effects(int fd){uint64_t mask=playing[fd];playing[fd]=0;for(int i=0;i<64;i++)if(mask&(1ULL<<i)){struct input_event e={0};e.type=EV_FF;e.code=i;call(64,fd,(long)&e,sizeof e,0);}}
|
||||
static void* watch(void* unused){(void)unused;for(;;){enter();uint64_t now;struct FramelyDirectControl c=policy(&now);if(!framely_direct_rumble(c,now))for(int fd=0;fd<MAX_FDS;fd++)if(direct_fds[fd]&&playing[fd])stop_effects(fd);leave();struct timespec delay={0,20000000};call(101,(long)&delay,0,0,0);}return 0;}
|
||||
static int opened(int dir,const char*p,int flags,int mode){
|
||||
long fd=call(56,dir,(long)p,flags,mode);if(fd<0)return checked(fd);
|
||||
if(same(p,FRAMELY_EVENT_PATH)){
|
||||
enter();if(control<0)control=call(56,-100,(long)FRAMELY_CONTROL_PATH,O_RDONLY|O_CLOEXEC,0);
|
||||
if(control<0&&control!=-2){int error=-control;leave();call(57,fd,0,0,0);*__errno()=error;return -1;}
|
||||
int direct=control>=0;
|
||||
if(direct){if(fd>=MAX_FDS){leave();call(57,fd,0,0,0);*__errno()=24;return -1;}__atomic_store_n(&direct_fds[fd],1,__ATOMIC_RELEASE);was_enabled[fd]=1;playing[fd]=0;
|
||||
if(!started){unsigned long thread;started=pthread_create(&thread,0,watch,0)==0;if(started)pthread_detach(thread);}
|
||||
// Without the feedback watchdog, fail closed rather than leave rumble active.
|
||||
if(!started){__atomic_store_n(&direct_fds[fd],0,__ATOMIC_RELEASE);leave();call(57,fd,0,0,0);*__errno()=5;return -1;}
|
||||
}
|
||||
leave();long r=direct?0:call(29,fd,EVIOCGRAB,1,0);
|
||||
if(r==0){long ready=call(56,-100,(long)FRAMELY_READY_PATH,O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC,0644);if(ready>=0){call(64,ready,(long)"ready\n",6,0);call(57,ready,0,0,0);}}
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
int open(const char*p,int flags,...){int mode=0;if((flags&O_CREAT)||(flags&O_TMPFILE)==O_TMPFILE){va_list ap;va_start(ap,flags);mode=va_arg(ap,int);va_end(ap);}return opened(-100,p,flags,mode);}
|
||||
int open64(const char*p,int flags,...){int mode=0;if((flags&O_CREAT)||(flags&O_TMPFILE)==O_TMPFILE){va_list ap;va_start(ap,flags);mode=va_arg(ap,int);va_end(ap);}return opened(-100,p,flags,mode);}
|
||||
int openat(int dir,const char*p,int flags,...){int mode=0;if((flags&O_CREAT)||(flags&O_TMPFILE)==O_TMPFILE){va_list ap;va_start(ap,flags);mode=va_arg(ap,int);va_end(ap);}return opened(dir,p,flags,mode);}
|
||||
int __open_2(const char*p,int flags){return opened(-100,p,flags,0);}
|
||||
int __openat_2(int dir,const char*p,int flags){return opened(dir,p,flags,0);}
|
||||
long read(int fd,void* buffer,size_t bytes){
|
||||
long n=call(63,fd,(long)buffer,bytes,0);if(n<=0)return checked(n);
|
||||
if(fd<0||fd>=MAX_FDS||!__atomic_load_n(&direct_fds[fd],__ATOMIC_ACQUIRE))return n;
|
||||
enter();if(direct_fds[fd]){uint64_t now;struct FramelyDirectControl c=policy(&now);int enabled=framely_direct_enabled(c,now);
|
||||
if(!enabled){
|
||||
// Drain the original events. Release all Android button/axis state once,
|
||||
// then report EAGAIN, so background input is never replayed on resumption.
|
||||
const int keys[]={304,305,307,308,310,311,312,313,314,315,317,318};const int axes[]={0,1,3,4,2,5,16,17};
|
||||
if(was_enabled[fd]&&bytes>=21*sizeof(struct input_event)){struct input_event* e=buffer;int count=0;for(int i=0;i<12;i++){e[count]=(struct input_event){0};e[count].type=EV_KEY;e[count++].code=keys[i];}for(int i=0;i<8;i++){e[count]=(struct input_event){0};e[count].type=EV_ABS;e[count++].code=axes[i];}e[count]=(struct input_event){0};e[count].type=EV_SYN;e[count++].code=SYN_REPORT;n=count*sizeof *e;was_enabled[fd]=0;}else n=-11;
|
||||
}else was_enabled[fd]=1;
|
||||
}leave();return checked(n);
|
||||
}
|
||||
long write(int fd,const void* buffer,size_t bytes){
|
||||
if(fd<0||fd>=MAX_FDS||!__atomic_load_n(&direct_fds[fd],__ATOMIC_ACQUIRE))return checked(call(64,fd,(long)buffer,bytes,0));
|
||||
enter();long n;
|
||||
if(direct_fds[fd]){
|
||||
uint64_t now;struct FramelyDirectControl c=policy(&now);int allowed=framely_direct_rumble(c,now);const struct input_event* events=buffer;
|
||||
if(bytes%sizeof *events){leave();*__errno()=22;return -1;}
|
||||
n=0;for(size_t i=0;i<bytes/sizeof *events;i++){
|
||||
struct input_event e=events[i];if(e.type==EV_FF&&e.value>0&&!allowed)e.value=0;
|
||||
long r=call(64,fd,(long)&e,sizeof e,0);if(r<0){if(n==0)n=r;break;}n+=r;
|
||||
if(e.type==EV_FF&&e.code<64){if(e.value>0)playing[fd]|=1ULL<<e.code;else playing[fd]&=~(1ULL<<e.code);}
|
||||
}
|
||||
}else n=call(64,fd,(long)buffer,bytes,0);
|
||||
leave();return checked(n);
|
||||
}
|
||||
int close(int fd){if(fd<0||fd>=MAX_FDS||!__atomic_load_n(&direct_fds[fd],__ATOMIC_ACQUIRE))return checked(call(57,fd,0,0,0));enter();if(fd>=0&&fd<MAX_FDS&&direct_fds[fd]){stop_effects(fd);__atomic_store_n(&direct_fds[fd],0,__ATOMIC_RELEASE);}long result=call(57,fd,0,0,0);leave();return checked(result);}
|
||||
int ioctl(int fd,unsigned long request,...){
|
||||
va_list ap;va_start(ap,request);void* argument=va_arg(ap,void*);va_end(ap);
|
||||
if(fd<0||fd>=MAX_FDS||!__atomic_load_n(&direct_fds[fd],__ATOMIC_ACQUIRE))return checked(call(29,fd,request,(long)argument,0));
|
||||
enter();long result=call(29,fd,request,(long)argument,0);
|
||||
if(result>=0&&direct_fds[fd]){
|
||||
uint64_t now;struct FramelyDirectControl c=policy(&now);
|
||||
if(!framely_direct_enabled(c,now)&&_IOC_TYPE(request)=='E'&&(_IOC_DIR(request)&_IOC_READ)){
|
||||
if(_IOC_NR(request)==0x18){unsigned char* bytes=argument;for(unsigned i=0;i<_IOC_SIZE(request);i++)bytes[i]=0;}
|
||||
else if(_IOC_NR(request)>=0x40&&_IOC_NR(request)<=0x7f&&_IOC_SIZE(request)==sizeof(struct input_absinfo))((struct input_absinfo*)argument)->value=0;
|
||||
}
|
||||
if(request==EVIOCRMFF&&(unsigned long)argument<64)playing[fd]&=~(1ULL<<(unsigned long)argument);
|
||||
}
|
||||
leave();return checked(result);
|
||||
}
|
||||
// Bionic's fortified entry points must use the same foreground/feedback gate.
|
||||
long __read_chk(int fd,void* buffer,size_t bytes,size_t capacity){if(bytes>capacity){*__errno()=14;return -1;}return read(fd,buffer,bytes);}
|
||||
long __write_chk(int fd,const void* buffer,size_t bytes,size_t capacity){if(bytes>capacity){*__errno()=14;return -1;}return write(fd,buffer,bytes);}
|
||||
@@ -4,3 +4,13 @@
|
||||
// Xbox/Android maps X to Linux 307 and Y to 308. The positional
|
||||
// aliases WEST/NORTH do not match these letter names.
|
||||
inline constexpr int keys[]={BTN_A,BTN_B,BTN_X,BTN_Y,BTN_TL,BTN_TR,BTN_SELECT,BTN_START,BTN_THUMBL,BTN_THUMBR};
|
||||
|
||||
inline constexpr int trigger_keys[]={BTN_TL2,BTN_TR2};
|
||||
// Preserve analog travel while exposing Android's digital L2/R2 events.
|
||||
// Default press at 80%; release five percentage points below it to avoid noise.
|
||||
struct GamepadTriggers {
|
||||
bool down[2]{};
|
||||
int press=204,release=191;
|
||||
bool configure(int percent){if(percent<1||percent>100)return false;press=(percent*255+99)/100;release=(percent>5?percent-5:0)*255/100;return true;}
|
||||
bool update(int side,int value){down[side]=down[side]?value>release:value>=press;return down[side];}
|
||||
};
|
||||
+211
-31
@@ -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>
|
||||
@@ -60,7 +64,7 @@ static GLXContext glcontext=nullptr;
|
||||
static GLXPbuffer glsurface=0;
|
||||
static std::atomic<unsigned> notification_badge_count{0};
|
||||
static bool launcher_open=false;
|
||||
static bool ui_haptics=true,ui_sounds=true;
|
||||
static bool ui_haptics=true,ui_sounds=false;
|
||||
static SteamUiSounds ui_audio;
|
||||
static LauncherFade launcher_fade;
|
||||
static std::chrono::steady_clock::time_point launcher_close_at{};
|
||||
@@ -103,11 +107,13 @@ 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;
|
||||
static bool keyboard_toolbar=false,keyboard_chars=false,keyboard_done=false;
|
||||
static MenuButtonInput menu_button;
|
||||
static std::map<unsigned,uint64_t> menu_previous_buttons;
|
||||
static cef_frame_t* keyboard_frame=nullptr;
|
||||
static std::string keyboard_view;
|
||||
static KeyboardMenuGuard keyboard_menu;
|
||||
@@ -122,15 +128,19 @@ template<class T> struct Handler {
|
||||
Handler(){api.base.size=sizeof(T);api.base.add_ref=[](cef_base_ref_counted_t* p){++reinterpret_cast<Handler*>(p)->refs;};api.base.release=[](cef_base_ref_counted_t* p){--reinterpret_cast<Handler*>(p)->refs;return 0;};api.base.has_one_ref=[](cef_base_ref_counted_t* p){return int(reinterpret_cast<Handler*>(p)->refs==1);};api.base.has_at_least_one_ref=[](cef_base_ref_counted_t* p){return int(reinterpret_cast<Handler*>(p)->refs>0);};}
|
||||
T* acquire(){api.base.add_ref(&api.base);return &api;}
|
||||
};
|
||||
#include "cast_frame.h"
|
||||
struct View {
|
||||
std::string key,plugin,title;int width=600,height=840,render_scale=1;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false;
|
||||
CastFrame cast_frame;
|
||||
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;
|
||||
bool active_seen=false,notification_content=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
|
||||
bool active_seen=false,notification_content=false,notification_requested=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
|
||||
vr::VROverlayHandle_t overlay=0,thumbnail=0;GLuint textures[2]{};int texture_slot=0;bool texture_ready[2]{};PaintDamage texture_damage[2];std::vector<uint8_t> pixels;
|
||||
};
|
||||
static std::map<std::string,std::unique_ptr<View>> views;
|
||||
static bool permission_trigger_held[2]{};
|
||||
static View* find_browser(cef_browser_t* b){int id=b->get_identifier(b);for(auto& [key,v]:views)if(v->browser_id==id)return v.get();return nullptr;}
|
||||
static Handler<cef_client_t> client;
|
||||
static Handler<cef_render_handler_t> render_handler;
|
||||
@@ -162,7 +172,16 @@ static std::vector<uint8_t> icon(bool hover,bool active){
|
||||
return p;
|
||||
}
|
||||
|
||||
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;{std::lock_guard<std::mutex> lock(visibility_mutex);payload=visible_views.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");return json::parse(response.substr(body+4));}
|
||||
static std::vector<std::string> closed_casts;
|
||||
// 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
|
||||
@@ -209,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);
|
||||
@@ -265,8 +349,25 @@ 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;cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu")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]{};
|
||||
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);
|
||||
if(!poses[0].bPoseIsValid)return;
|
||||
const auto& head=poses[0].mDeviceToAbsoluteTracking;
|
||||
float fx=head.m[0][2],fz=head.m[2][2],length=std::hypot(fx,fz);
|
||||
if(length<.01f)return;
|
||||
fx/=length;fz/=length;
|
||||
vr::HmdMatrix34_t upright{};
|
||||
upright.m[0][0]=fz;upright.m[1][1]=1;upright.m[2][0]=-fx;
|
||||
upright.m[0][2]=fx;upright.m[2][2]=fz;
|
||||
upright.m[0][3]=head.m[0][3]-.65f*fx;
|
||||
upright.m[1][3]=head.m[1][3];
|
||||
upright.m[2][3]=head.m[2][3]-.65f*fz;
|
||||
overlays->SetOverlayWidthInMeters(v.overlay,.85f);
|
||||
overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&upright);
|
||||
}
|
||||
static void anchor_notification(View& v){
|
||||
// Let the compositor follow the HMD's render pose; a captured standing-space
|
||||
// transform leaves the toast behind when the user turns or moves their head.
|
||||
@@ -280,16 +381,24 @@ static void anchor_notification(View& v){
|
||||
v.placement.curve(overlays,v.overlay,toast.width,toast.radius);
|
||||
}
|
||||
static View& create_view(const std::string& key,const std::string& title,const std::string& url,bool dock,int width,int height,const std::string& plugin="",float physical_width=0){
|
||||
if(views.count(key)){auto& v=*views.at(key);if(dock&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="notifications"&&key!="launcher")show(v,true);return v;}
|
||||
// Use 3x for launcher artwork and 2x for scrolling panels. CEF readback,
|
||||
// Plugin windows always use Steam window controls; dock only controls the tab.
|
||||
const bool dashboard=dock||!plugin.empty()||key.rfind("framely.cast.",0)==0;
|
||||
if(views.count(key)){auto& v=*views.at(key);if(v.dashboard&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="notifications"&&key!="launcher")show(v,true);return v;}
|
||||
// Use 1.5x for launcher artwork and 2x for scrolling panels. CEF readback,
|
||||
// texture upload and launcher mipmap generation scale with the pixel count.
|
||||
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dock;v->width=width;v->height=height;v->render_scale=key=="launcher"?3:(key=="framely.manager"||key=="menu"||key=="notifications")?2:1;
|
||||
auto error=dock?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
|
||||
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(v->overlay,0);
|
||||
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.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;
|
||||
mask.m_Primitive.m_Rectangle={0,0,float(width),float(height)};
|
||||
if(auto e=overlays->SetOverlayIntersectionMask(v->overlay,&mask,1))std::cerr<<"Permission input mask failed "<<e<<"\n";
|
||||
}
|
||||
|
||||
// Keep the runtime's full dashboard controls, including its keyboard and close buttons.
|
||||
if(dock){
|
||||
if(key=="launcher")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_NoDashboardTab,true);
|
||||
if(dashboard){
|
||||
overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_NoDashboardTab,!dock||key=="launcher");
|
||||
auto result=overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MinimalControlBar,key=="launcher");
|
||||
if(result)std::cerr<<"Dashboard controls unavailable "<<key<<" "<<result<<"\n";
|
||||
for(auto flag:{vr::VROverlayFlags_EnableControlBarKeyboard,vr::VROverlayFlags_EnableControlBarClose}){
|
||||
@@ -297,9 +406,9 @@ static View& create_view(const std::string& key,const std::string& title,const s
|
||||
if(result)std::cerr<<"Dashboard control unavailable "<<key<<" "<<result<<"\n";
|
||||
}
|
||||
}
|
||||
if(dock){auto pixels=icon(false,true);overlays->SetOverlayRaw(v->thumbnail,pixels.data(),128,128,4);overlays->SetOverlayWidthInMeters(v->overlay,physical_width>0?physical_width:(key=="framely.manager"?3.2f:key=="launcher"?3.6f:1.2f));}else if(key!="menu"&&key!="notifications"&&key!="launcher"){anchor_to_head(*v,physical_width>0?physical_width:1.0f);}
|
||||
cef_window_info_t info{};info.size=sizeof(info);info.windowless_rendering_enabled=1;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=browser_frame_rate();bs.background_color=0;std::cout<<"Browser frame rate "<<key<<" "<<bs.windowless_frame_rate<<"\n";cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&info,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);
|
||||
if(!browser){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);throw std::runtime_error("Browser creation failed");}v->browser=browser;v->browser_id=browser->get_identifier(browser);v->host=browser->get_host(browser);glGenTextures(2,v->textures);auto* ptr=v.get();views.emplace(key,std::move(v));ptr->host->notify_screen_info_changed(ptr->host);ptr->host->was_resized(ptr->host);ptr->host->was_hidden(ptr->host,key!="notifications");if(dock&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications"&&key!="launcher")show(*ptr,true);return *ptr;
|
||||
if(dashboard){auto pixels=icon(false,true);overlays->SetOverlayRaw(v->thumbnail,pixels.data(),128,128,4);overlays->SetOverlayWidthInMeters(v->overlay,physical_width>0?physical_width:(key=="framely.manager"?3.2f:key=="launcher"?3.6f:1.2f));}else if(key!="menu"&&key!="notifications"&&key!="launcher"){anchor_to_head(*v,physical_width>0?physical_width:1.0f);}
|
||||
cef_window_info_t info{};info.size=sizeof(info);info.windowless_rendering_enabled=1;info.runtime_style=CEF_RUNTIME_STYLE_ALLOY;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=browser_frame_rate();bs.background_color=0;std::cout<<"Browser frame rate "<<key<<" "<<bs.windowless_frame_rate<<"\n";cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&info,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);
|
||||
if(!browser){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);throw std::runtime_error("Browser creation failed");}v->browser=browser;v->browser_id=browser->get_identifier(browser);v->host=browser->get_host(browser);glGenTextures(2,v->textures);auto* ptr=v.get();views.emplace(key,std::move(v));ptr->host->notify_screen_info_changed(ptr->host);ptr->host->was_resized(ptr->host);ptr->host->was_hidden(ptr->host,key!="notifications");if(ptr->dashboard&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications"&&key!="launcher")show(*ptr,true);return *ptr;
|
||||
}
|
||||
static void plugin_thumbnail(View& view,const std::string& encoded){
|
||||
if(!view.thumbnail||encoded.empty()||encoded.size()>1400000)return;
|
||||
@@ -471,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
|
||||
@@ -484,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{};
|
||||
@@ -493,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,¶ms,&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,¶ms,&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);
|
||||
@@ -527,9 +638,40 @@ 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;
|
||||
const auto& m=pose.pose.mDeviceToAbsoluteTracking;
|
||||
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;
|
||||
for(int row=0;row<3;row++){ray.vSource.v[row]=m.m[row][3];ray.vDirection.v[row]=-m.m[row][2];}
|
||||
vr::VROverlayIntersectionResults_t point{};
|
||||
if(overlays->ComputeOverlayIntersection(view.overlay,&ray,&point)&&point.vUVs.v[0]>=0&&point.vUVs.v[0]<=1&&point.vUVs.v[1]>=0&&point.vUVs.v[1]<=1){
|
||||
cef_mouse_event_t mouse{};
|
||||
mouse.x=std::clamp(int(point.vUVs.v[0]*view.width),0,view.width-1);
|
||||
mouse.y=std::clamp(int((1-point.vUVs.v[1])*view.height),0,view.height-1);
|
||||
if(!overlays->IsHoverTargetOverlay(view.overlay)){
|
||||
view.host->send_mouse_move_event(view.host,&mouse,0);
|
||||
if(active&&down&&!permission_trigger_held[hand]){
|
||||
view.host->set_focus(view.host,1);
|
||||
mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;
|
||||
view.host->send_mouse_click_event(view.host,&mouse,MBT_LEFT,0,1);
|
||||
mouse.modifiers=0;
|
||||
view.host->send_mouse_click_event(view.host,&mouse,MBT_LEFT,1,1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
permission_trigger_held[hand]=active&&down;
|
||||
}
|
||||
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);}
|
||||
@@ -547,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",true);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=="window.open"){hide_launcher(false);std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely nLine 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;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")close_view(v);break;default:break;}}
|
||||
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];}
|
||||
@@ -588,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",true)}});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("indicatorVisible",true)?recording.value("id",std::string{}):std::string{},recording.value("elapsedSeconds",uint64_t(0)));if(auto editor=views.find("framely.framing");editor!=views.end())update_framing_placement(*editor->second);if(region.value("visible",false)&®ion.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;
|
||||
@@ -607,7 +768,7 @@ int main(int argc,char** argv){
|
||||
if(!main_overlay)main_overlay=active_main_window();
|
||||
if(main_overlay&&!overlays->GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose;
|
||||
if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);}
|
||||
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher")close_view(*v);}was_visible=visible;
|
||||
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher"&&key.rfind("framely.cast.",0)!=0&&key!="framely.apk.permission")close_view(*v);}was_visible=visible;
|
||||
if(launcher_close_at.time_since_epoch().count()&&now>=launcher_close_at){launcher_close_at={};if(!launcher_open)finish_launcher_close();}
|
||||
// The runtime owns dashboard placement, curvature, visibility and switching.
|
||||
// Never move the launcher into standing space or hide another app's overlay.
|
||||
@@ -655,10 +816,26 @@ int main(int argc,char** argv){
|
||||
activate_entry(entry_hold.tick(std::chrono::steady_clock::now(),anchor_ok&&entry_connected,entry_inside));
|
||||
for(auto&[key,v]:views)v->stick_scrolling=false;
|
||||
entry_actions(button,anchor_ok);
|
||||
// Some dashboard runtimes do not emit Modal_Cancel for clicks on their own
|
||||
// overlays. Observe the same controller click edge as a fallback, without
|
||||
// consuming the click destined for the other surface.
|
||||
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;
|
||||
vr::VRControllerState_t state{};
|
||||
if(!vr_system->GetControllerState(device,&state,sizeof(state))){menu_previous_buttons.erase(device);continue;}
|
||||
auto previous=menu_previous_buttons.find(device);
|
||||
uint64_t pressed=previous==menu_previous_buttons.end()?0:state.ulButtonPressed&~previous->second;
|
||||
menu_previous_buttons[device]=state.ulButtonPressed;
|
||||
const uint64_t click=vr::ButtonMaskFromId(vr::k_EButton_SteamVR_Trigger)|vr::ButtonMaskFromId(vr::k_EButton_A)|vr::ButtonMaskFromId(vr::k_EButton_DPad_Down);
|
||||
if(menu_open&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now())&&(pressed&click)&&views.count("menu")&&!overlays->IsHoverTargetOverlay(views.at("menu")->overlay)&&!overlays->IsHoverTargetOverlay(button))menu_open=false;
|
||||
}
|
||||
if(launcher_open){bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held;launcher_dismiss.observe(held);}
|
||||
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){if(!active&&key=="launcher"){if(v->launcher_pointer.active){cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;v->host->send_mouse_move_event(v->host,&outside,0);outside.modifiers=0;v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);}v->launcher_pointer.clear();auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->base.release(&frame->base);}v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;if(key!="launcher")for(auto&[other,t]:views)if(!t->dashboard&&other!="menu"&&other!="notifications"&&other!="launcher"&&!keyboard_open)close_view(*t);}}
|
||||
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){if(!active&&key=="launcher"){if(v->launcher_pointer.active){cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;v->host->send_mouse_move_event(v->host,&outside,0);outside.modifiers=0;v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);}v->launcher_pointer.clear();auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->base.release(&frame->base);}v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;if(key!="launcher")for(auto&[other,t]:views)if(!t->dashboard&&other!="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->dirty&&!v->closing){
|
||||
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){
|
||||
glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);
|
||||
// Upload to the other texture; never redefine storage submitted this frame.
|
||||
const int slot=1-v->texture_slot;const GLuint id=v->textures[slot];
|
||||
@@ -671,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);
|
||||
}
|
||||
@@ -681,8 +858,9 @@ int main(int argc,char** argv){
|
||||
glPixelStorei(GL_UNPACK_ROW_LENGTH,0);damage.clear();
|
||||
if(mipmapped)generate_mipmap(GL_TEXTURE_2D);
|
||||
}
|
||||
GLuint submitted=v->cast_frame.draw(id,pixel_width,pixel_height);
|
||||
// glFlush queues the upload; glFinish makes the full image ready for sharing.
|
||||
glFinish();vr::Texture_t texture{reinterpret_cast<void*>(uintptr_t(id)),vr::TextureType_OpenGL,vr::ColorSpace_Auto};
|
||||
glFinish();vr::Texture_t texture{reinterpret_cast<void*>(uintptr_t(submitted)),vr::TextureType_OpenGL,vr::ColorSpace_Auto};
|
||||
auto e=overlays->SetOverlayTexture(v->overlay,&texture);glFlush();
|
||||
if(e)std::cerr<<"Texture submission failed "<<key<<" "<<e<<"\n";
|
||||
else{v->texture_slot=slot;v->dirty=false;
|
||||
@@ -710,7 +888,7 @@ int main(int argc,char** argv){
|
||||
}
|
||||
if(v->notification_content&&!v->placement.valid)anchor_notification(*v);
|
||||
if(v->placement.valid)v->placement.curve(overlays,v->overlay,v->placement.width,notification_curve_radius);
|
||||
show(*v,v->notification_content&&v->placement.valid);
|
||||
show(*v,v->notification_requested&&v->notification_content&&v->placement.valid);
|
||||
}
|
||||
}
|
||||
{json snapshot=json::object();for(auto&[key,v]:views){if(key=="menu"||key=="launcher"||key=="framely.manager"||key.rfind("framely.window.",0)==0)snapshot[key]=capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay));}std::lock_guard<std::mutex> lock(visibility_mutex);visible_views=std::move(snapshot);}
|
||||
@@ -730,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;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ key 307 BUTTON_X
|
||||
key 308 BUTTON_Y
|
||||
key 310 BUTTON_L1
|
||||
key 311 BUTTON_R1
|
||||
key 312 BUTTON_L2
|
||||
key 313 BUTTON_R2
|
||||
key 314 BUTTON_SELECT
|
||||
key 315 BUTTON_START
|
||||
key 317 BUTTON_THUMBL
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Steam Input evdev output. Preserve its driver dead zones and axis ranges.
|
||||
key 304 BUTTON_A
|
||||
key 305 BUTTON_B
|
||||
key 307 BUTTON_X
|
||||
key 308 BUTTON_Y
|
||||
key 310 BUTTON_L1
|
||||
key 311 BUTTON_R1
|
||||
key 312 BUTTON_L2
|
||||
key 313 BUTTON_R2
|
||||
key 314 BUTTON_SELECT
|
||||
key 315 BUTTON_START
|
||||
key 317 BUTTON_THUMBL
|
||||
key 318 BUTTON_THUMBR
|
||||
axis 0x00 X
|
||||
axis 0x01 Y
|
||||
axis 0x03 Z
|
||||
axis 0x04 RZ
|
||||
axis 0x02 LTRIGGER
|
||||
axis 0x05 RTRIGGER
|
||||
axis 0x10 HAT_X
|
||||
axis 0x11 HAT_Y
|
||||
@@ -21,5 +21,5 @@ inline bool allow_plugin_navigation(const std::string& url, const std::string& o
|
||||
}
|
||||
|
||||
inline bool manager_navigation_page(const std::string& page) {
|
||||
return page=="installed"||page=="catalog"||page=="sources"||page=="notification-settings"||page=="settings"||page=="launcher-settings"||page=="updates"||page=="about"||page=="terminal"||page=="files"||page=="apk"||page=="apk-containers"||page=="apk-cleanup"||page=="apk-settings";
|
||||
return page=="casting"||page=="casting-picture"||page=="casting-receive"||page=="casting-devices"||page=="installed"||page=="catalog"||page=="sources"||page=="notification-settings"||page=="settings"||page=="launcher-settings"||page=="updates"||page=="about"||page=="terminal"||page=="files"||page=="apk"||page=="apk-containers"||page=="apk-cleanup"||page=="apk-settings";
|
||||
}
|
||||
@@ -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();cancel_input();}
|
||||
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;}}
|
||||
}
|
||||
};
|
||||
@@ -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();}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
// CEF view/input coordinates stay in DIP; only the paint buffer uses this scale.
|
||||
inline int render_screen_info(cef_screen_info_t* info,int width,int height,int scale){
|
||||
inline int render_screen_info(cef_screen_info_t* info,int width,int height,float scale){
|
||||
info->size=sizeof(*info);info->device_scale_factor=float(scale);info->depth=32;info->depth_per_component=8;info->is_monochrome=0;
|
||||
info->rect={0,0,width,height};info->available_rect=info->rect;return 1;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ class SteamUiSounds {
|
||||
bool (*resume)(void*)=nullptr;
|
||||
bool (*put)(void*,const void*,int)=nullptr;
|
||||
bool (*clear)(void*)=nullptr;
|
||||
bool (*gain)(void*,float)=nullptr;
|
||||
void (*destroy)(void*)=nullptr;
|
||||
void (*release)(void*)=nullptr;
|
||||
template<class T> bool bind(T& fn,const char* name){fn=reinterpret_cast<T>(dlsym(library,name));return fn!=nullptr;}
|
||||
@@ -36,13 +37,13 @@ class SteamUiSounds {
|
||||
if(root.empty())return false;
|
||||
for(const auto* name:{"steamrtarm64/libSDL3.so.0","steamrt64/libSDL3.so.0"}){library=dlopen((root+"/"+name).c_str(),RTLD_NOW|RTLD_LOCAL);if(library)break;}
|
||||
if(!library)return false;
|
||||
if(!(bind(init,"SDL_InitSubSystem")&&bind(quit,"SDL_QuitSubSystem")&&bind(load,"SDL_LoadWAV")&&bind(open,"SDL_OpenAudioDeviceStream")&&bind(resume,"SDL_ResumeAudioStreamDevice")&&bind(put,"SDL_PutAudioStreamData")&&bind(clear,"SDL_ClearAudioStream")&&bind(destroy,"SDL_DestroyAudioStream")&&bind(release,"SDL_free"))){shutdown();return false;}
|
||||
if(!(bind(init,"SDL_InitSubSystem")&&bind(quit,"SDL_QuitSubSystem")&&bind(load,"SDL_LoadWAV")&&bind(open,"SDL_OpenAudioDeviceStream")&&bind(resume,"SDL_ResumeAudioStreamDevice")&&bind(put,"SDL_PutAudioStreamData")&&bind(clear,"SDL_ClearAudioStream")&&bind(gain,"SDL_SetAudioStreamGain")&&bind(destroy,"SDL_DestroyAudioStream")&&bind(release,"SDL_free"))){shutdown();return false;}
|
||||
if(!init(0x10)){shutdown();return false;}initialized=true;
|
||||
const char* names[]={"deck_ui_navigation.wav","deck_ui_default_activation.wav"};
|
||||
const char* names[]={"deck_ui_typing.wav","deck_ui_typing.wav"};
|
||||
for(int i=0;i<2;++i){Spec spec{};auto& clip=clips[i];
|
||||
if(!load((root+"/steamui/sounds/"+names[i]).c_str(),&spec,&clip.data,&clip.size)||!clip.size||clip.size>1024*1024){shutdown();return false;}
|
||||
clip.stream=open(0xffffffffu,&spec,nullptr,nullptr);
|
||||
if(!clip.stream||!resume(clip.stream)){shutdown();return false;}
|
||||
if(!clip.stream||!gain(clip.stream,i==0?.30f:.45f)||!resume(clip.stream)){shutdown();return false;}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Generated
+292
@@ -12,6 +12,18 @@
|
||||
"examples/showcase"
|
||||
],
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
"@codemirror/lang-html": "6.4.12",
|
||||
"@codemirror/lang-javascript": "6.2.5",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-rust": "6.0.2",
|
||||
"@codemirror/language": "6.13.1",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@tabler/icons-react": "3.48.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
@@ -34,6 +46,172 @@
|
||||
"@framely/sdk": "0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
|
||||
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-python": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
|
||||
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.3.2",
|
||||
"@codemirror/language": "^6.8.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/python": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-rust": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
|
||||
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/rust": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
|
||||
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/streamparser": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
|
||||
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/streamparser": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
|
||||
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.14",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
|
||||
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
|
||||
@@ -488,6 +666,102 @@
|
||||
"resolved": "ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
|
||||
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
|
||||
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
|
||||
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
|
||||
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
|
||||
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/python": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz",
|
||||
"integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/rust": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.3.tgz",
|
||||
"integrity": "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@resvg/resvg-js": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
|
||||
@@ -777,6 +1051,12 @@
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -853,6 +1133,12 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -867,6 +1153,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"sdk": {
|
||||
"name": "@framely/sdk",
|
||||
"version": "0.4.0",
|
||||
|
||||
@@ -15,6 +15,18 @@
|
||||
"plugin:init": "node tools/plugin-dev.mjs init"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
"@codemirror/lang-html": "6.4.12",
|
||||
"@codemirror/lang-javascript": "6.2.5",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-rust": "6.0.2",
|
||||
"@codemirror/language": "6.13.1",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@tabler/icons-react": "3.48.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
|
||||
+34
-2
@@ -13,10 +13,37 @@ steam_uid=$(id -u "$steam_user")
|
||||
steam_home=$(getent passwd "$steam_user" | cut -d: -f6)
|
||||
[[ $steam_uid -ge 1000 && $steam_home == /* && $steam_home != *' '* ]] || { echo 'Invalid Steam user/home.' >&2; exit 1; }
|
||||
[[ -w /etc/systemd/system && -w /etc && -w /var/lib ]] || { echo 'System configuration is not writable. Framely did not change the read-only setting; installation stopped.' >&2; exit 1; }
|
||||
# A terminal opened by Framely belongs to its service cgroup. Stop requests
|
||||
# would kill this installer along with the UI, before it can switch/restart.
|
||||
# Journal output avoids retaining a pipe to the terminal being shut down.
|
||||
installer_cgroup=$(cat /proc/self/cgroup)
|
||||
if [[ $installer_cgroup =~ /framely(-session|-ui-recovery|-backend-[^/]+|-plugin-[^/]+|-hook-[^/]+)?\.service(/|$|[[:space:]]) ]]; then
|
||||
worker_args=("$steam_user")
|
||||
if $repair; then worker_args=(--repair "$steam_user"); fi
|
||||
install_unit="framely-install-$$-$(date +%s)"
|
||||
worker_environment=()
|
||||
if [[ -n ${FRAMELY_GITHUB_AUTO_FALLBACK+x} ]]; then
|
||||
[[ $FRAMELY_GITHUB_AUTO_FALLBACK == 0 || $FRAMELY_GITHUB_AUTO_FALLBACK == 1 ]] || { echo 'Invalid automatic GitHub fallback preference.' >&2; exit 1; }
|
||||
worker_environment+=("--setenv=FRAMELY_GITHUB_AUTO_FALLBACK=$FRAMELY_GITHUB_AUTO_FALLBACK")
|
||||
fi
|
||||
systemd-run --quiet --collect --no-block --unit="$install_unit" \
|
||||
"${worker_environment[@]}" \
|
||||
--property=Type=exec --property=StandardInput=null \
|
||||
--property=StandardOutput=journal --property=StandardError=journal \
|
||||
/usr/bin/bash "$base/install.sh" "${worker_args[@]}"
|
||||
echo "Installation continues independently. Log: journalctl -u $install_unit"
|
||||
exit 0
|
||||
fi
|
||||
cd "$base"
|
||||
sha256sum --quiet -c SHA256SUMS
|
||||
for binary in bin/framely; do
|
||||
dependencies=$(ldd "$binary" 2>&1)
|
||||
casting_binaries=()
|
||||
if [[ -d lib/media ]]; then
|
||||
command -v ffmpeg >/dev/null || { echo "Missing system FFmpeg for casting." >&2; exit 1; }
|
||||
casting_binaries=(bin/framely-panel-grab lib/media/framely-capture lib/media/framely-receiver lib/media/uxplay lib/media/libgstframely.so lib/media/libgstlibav.so lib/media/mediamtx)
|
||||
fi
|
||||
for binary in bin/framely "${casting_binaries[@]}"; do
|
||||
dependencies=$(ldd "$binary" 2>&1 || true)
|
||||
[[ -f $binary ]] || { echo "Missing binary: $binary" >&2; exit 1; }
|
||||
[[ $dependencies != *'not found'* ]] || { echo "$dependencies" >&2; exit 1; }
|
||||
done
|
||||
version=$(cat VERSION)
|
||||
@@ -41,6 +68,9 @@ for unit in framely.service framely-session.service; do
|
||||
previous_active[$unit]=$(systemctl is-active "$unit" || true)
|
||||
previous_enabled[$unit]=$(systemctl is-enabled "$unit" 2>/dev/null || true)
|
||||
done
|
||||
if systemctl --user --machine="$steam_user@" is-active --quiet framely-ui-recovery.service 2>/dev/null; then
|
||||
previous_active[framely-session.service]=active
|
||||
fi
|
||||
if [[ -d $root && ! -L $root ]]; then
|
||||
# Stop writers before moving state across filesystems. Restore running services
|
||||
# if migration or subsequent preflight fails; the compatibility path survives.
|
||||
@@ -145,6 +175,8 @@ rollback_on_failure() {
|
||||
exit "$code"
|
||||
}
|
||||
trap rollback_on_failure ERR
|
||||
# Retire the temporary user UI used to recover an interrupted installation.
|
||||
systemctl --user --machine="$steam_user@" stop framely-ui-recovery.service 2>/dev/null || true
|
||||
systemctl stop framely-session.service framely.service 2>/dev/null || true
|
||||
# Clear the uninstall guard retained by older releases only for a clean reinstall.
|
||||
# Existing installations, repairs and incomplete plugin cleanup retain safe mode.
|
||||
|
||||
+15
-3
@@ -1,16 +1,23 @@
|
||||
type Editable=HTMLInputElement|HTMLTextAreaElement;
|
||||
type Editable=HTMLInputElement|HTMLTextAreaElement|HTMLElement;
|
||||
export function installKeyboard(open:(params:{existing:string;password:boolean;multiline:boolean})=>Promise<unknown>){
|
||||
let field:Editable|null=null,last:Editable|null=null,openedAt=0;
|
||||
const request=(target:EventTarget|null)=>{
|
||||
if(!(target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement)||target.disabled||target.readOnly)return;
|
||||
const embedded=target instanceof HTMLElement&&target.matches('[data-framely-editor-input][contenteditable="true"]');
|
||||
if(!embedded&&(!(target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement)||target.disabled||target.readOnly))return;
|
||||
if(!(target instanceof HTMLElement))return;
|
||||
if(target instanceof HTMLInputElement&&!['text','password','url','search','email','number','tel'].includes(target.type))return;
|
||||
field=target;const now=performance.now();if(last===target&&now-openedAt<300)return;last=target;openedAt=now;
|
||||
void open({existing:target.value,password:target instanceof HTMLInputElement&&target.type==='password',multiline:target instanceof HTMLTextAreaElement}).catch(console.error);
|
||||
const params={existing:target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement?target.value:'',password:target instanceof HTMLInputElement&&target.type==='password',multiline:embedded||target instanceof HTMLTextAreaElement};
|
||||
if(embedded)target.dispatchEvent(new CustomEvent('framely.keyboard.open',{detail:params}));
|
||||
void open(params).catch(console.error);
|
||||
};
|
||||
document.addEventListener('focusin',e=>request(e.target));
|
||||
document.addEventListener('pointerdown',e=>request(e.target));
|
||||
(window as any).__framelyCommitKeyboard=(value:string)=>{
|
||||
if(!field?.isConnected)return;
|
||||
if(field.closest('[inert]')||((field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement)&&(field.disabled||field.readOnly)))return;
|
||||
if(!field.dispatchEvent(new CustomEvent('framely.keyboard.commit',{detail:value,cancelable:true})))return;
|
||||
if(!(field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement))return;
|
||||
const proto=field instanceof HTMLTextAreaElement?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;
|
||||
Object.getOwnPropertyDescriptor(proto,'value')!.set!.call(field,value);
|
||||
field.dispatchEvent(new Event('input',{bubbles:true}));field.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
@@ -18,6 +25,11 @@ export function installKeyboard(open:(params:{existing:string;password:boolean;m
|
||||
};
|
||||
(window as any).__framelyEditKeyboard=(edit:string)=>{
|
||||
if(!field?.isConnected)return;
|
||||
if(field.closest('[inert]')||((field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement)&&(field.disabled||field.readOnly)))return;
|
||||
// Embedded editors can consume the native keyboard without mutating their
|
||||
// hidden textarea. Ordinary inputs continue through the path below.
|
||||
if(!field.dispatchEvent(new CustomEvent('framely.keyboard.edit',{detail:edit,cancelable:true})))return;
|
||||
if(!(field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement))return;
|
||||
const value=field.value;
|
||||
let start=field.selectionStart??value.length,end=field.selectionEnd??start;
|
||||
const segments=Array.from(new (Intl as any).Segmenter(undefined,{granularity:'grapheme'}).segment(value)) as {index:number;segment:string}[];
|
||||
|
||||
+1001
-59
File diff suppressed because it is too large.
Load diff
+94
-8
@@ -16,6 +16,57 @@ fn directory(path: &Path) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// A failed first boot has no packages.xml. Only explicit Framely ownership
|
||||
// plus an exact, non-symlink managed path may replace that validation.
|
||||
pub(super) fn validate_location(home: &Path, db: &Database, c: &Container) -> Result<()> {
|
||||
if let Ok(path) = validate_baked(&c.baked) {
|
||||
ensure!(path == c.baked, "Container data location changed");
|
||||
return Ok(());
|
||||
}
|
||||
ensure!(
|
||||
c.managed
|
||||
&& !c.steam
|
||||
&& c.id == c.name
|
||||
&& safe(&c.id)
|
||||
&& !c.id.starts_with("steamlaunch-")
|
||||
&& db.owned_contexts.contains(&c.id),
|
||||
"Unreadable containers can only be cleaned when owned by Framely"
|
||||
);
|
||||
let base = contexts_root(home);
|
||||
ensure!(
|
||||
base.is_absolute() && fs::canonicalize(&base)? == base,
|
||||
"Managed container location changed"
|
||||
);
|
||||
let parent = base.join(&c.id);
|
||||
ensure!(
|
||||
c.baked == parent.join("baked"),
|
||||
"Container data location changed"
|
||||
);
|
||||
for path in [&base, &parent, &c.baked] {
|
||||
directory(path)?;
|
||||
if path.exists() {
|
||||
ensure!(
|
||||
fs::canonicalize(path)? == *path,
|
||||
"Container cleanup path contains a link"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn ensure_runtime_absent(c: &Container) -> Result<()> {
|
||||
ensure!(
|
||||
runtime(&c.name, &c.baked, true)?.is_none(),
|
||||
"Container runtime already exists; refusing to replace it"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn identity(path: &Path) -> Result<Option<(u64, u64)>> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(m) => Ok(Some((m.dev(), m.ino()))),
|
||||
Err(e) if absent(&e) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
fn remove_owned(path: &Path) -> Result<()> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(m) => {
|
||||
@@ -80,6 +131,24 @@ fn runtime(name: &str, baked: &Path, original: bool) -> Result<Option<String>> {
|
||||
}
|
||||
|
||||
pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path) -> Result<()> {
|
||||
delete_internal(home, db, c, log, false)
|
||||
}
|
||||
pub(super) fn delete_failed_install(
|
||||
home: &Path,
|
||||
db: &mut Database,
|
||||
c: &Container,
|
||||
log: &Path,
|
||||
) -> Result<()> {
|
||||
delete_internal(home, db, c, log, true)
|
||||
}
|
||||
fn delete_internal(
|
||||
home: &Path,
|
||||
db: &mut Database,
|
||||
c: &Container,
|
||||
log: &Path,
|
||||
preserve_logs: bool,
|
||||
) -> Result<()> {
|
||||
validate_location(home, db, c)?;
|
||||
let records: Vec<_> = db
|
||||
.records
|
||||
.values()
|
||||
@@ -113,7 +182,12 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
|
||||
} else {
|
||||
c.id.clone()
|
||||
};
|
||||
let standard = home.join(".local/share/lepton/contexts").join(&c.id);
|
||||
let standard = (if db.owned_contexts.contains(&c.id) {
|
||||
super::contexts_root(home)
|
||||
} else {
|
||||
home.join(".local/share/lepton/contexts")
|
||||
})
|
||||
.join(&c.id);
|
||||
let default_location = !c.id.starts_with("external-")
|
||||
&& !fs::symlink_metadata(&standard).is_ok_and(|m| m.file_type().is_symlink())
|
||||
&& fs::canonicalize(&standard).is_ok_and(|p| p == standard && p.join("baked") == c.baked);
|
||||
@@ -122,14 +196,15 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
|
||||
} else {
|
||||
c.baked.clone()
|
||||
};
|
||||
let target_identity = identity(&target)?;
|
||||
let mut names = BTreeSet::from([original.clone(), c.name.clone()]);
|
||||
names.extend(records.iter().filter_map(native::runtime_name));
|
||||
// Resolve every name before stopping anything. A recycled Steam App ID
|
||||
// pointing at different storage must never be removed.
|
||||
let mut runtimes = Vec::new();
|
||||
for name in names {
|
||||
if let Some(id) = runtime(&name, &c.baked, name == original)? {
|
||||
runtimes.push((name, id));
|
||||
for name in &names {
|
||||
if let Some(id) = runtime(name, &c.baked, *name == original)? {
|
||||
runtimes.push((name.clone(), id));
|
||||
}
|
||||
}
|
||||
for (name, _) in &runtimes {
|
||||
@@ -156,9 +231,14 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
|
||||
for rec in &records {
|
||||
steam_shortcuts::unregister(home, rec)?;
|
||||
}
|
||||
validate_location(home, db, c)?;
|
||||
ensure!(
|
||||
validate_baked(&c.baked)? == c.baked,
|
||||
"Container data location changed"
|
||||
identity(&target)? == target_identity,
|
||||
"Container directory changed during deletion"
|
||||
);
|
||||
ensure!(
|
||||
!names.iter().any(|name| wrapper_active(name)),
|
||||
"A container launcher is still running; wait for it to close"
|
||||
);
|
||||
for (name, identity) in &runtimes {
|
||||
if let Some(current) = runtime(name, &c.baked, *name == original)? {
|
||||
@@ -181,14 +261,20 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
|
||||
for entry in fs::read_dir(root(home).join("logs"))? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if entry.path() != log
|
||||
if !preserve_logs
|
||||
&& entry.path() != log
|
||||
&& name.ends_with(".log")
|
||||
&& keys.iter().any(|key| name.starts_with(&format!("{key}-")))
|
||||
{
|
||||
remove_owned(&entry.path())?;
|
||||
}
|
||||
}
|
||||
remove_tree(&target)?;
|
||||
validate_location(home, db, c)?;
|
||||
ensure!(
|
||||
identity(&target)? == target_identity,
|
||||
"Container directory changed during deletion"
|
||||
);
|
||||
remove_owned(&target)?;
|
||||
db.roots.retain(|p| p != &c.baked);
|
||||
db.root_contexts.remove(&c.baked);
|
||||
db.records.retain(|_, r| r.context != c.id);
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Per-application GLES workaround. The library is inert unless Android's
|
||||
//! debug-layer loader selects this exact package at process startup.
|
||||
use super::*;
|
||||
|
||||
pub(super) const HOOKS: &str = include_str!("../../native/android/fbo-compat.sh");
|
||||
const LAYER: &str = "libFramelyFBOCompat.so";
|
||||
|
||||
pub(super) fn library(required: bool) -> Result<Option<PathBuf>> {
|
||||
let exe = std::env::current_exe()?;
|
||||
let path = exe
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.context("Missing Framely runtime directory")?
|
||||
.join("lib/openvr")
|
||||
.join(LAYER);
|
||||
ensure!(
|
||||
!required || path.is_file(),
|
||||
"Framebuffer compatibility runtime is missing; install a complete Framely build"
|
||||
);
|
||||
Ok(path.is_file().then_some(path))
|
||||
}
|
||||
pub(super) fn configure_command(cmd: &mut Command, enabled: bool, package: &str) -> Result<()> {
|
||||
// Do not inherit a launch configuration from another application/process.
|
||||
cmd.env_remove("FRAMELY_FBO_LIBRARY")
|
||||
.env_remove("FRAMELY_FBO_PACKAGE");
|
||||
if let Some(path) = library(enabled)? {
|
||||
cmd.env("FRAMELY_FBO_LIBRARY", path);
|
||||
}
|
||||
if enabled {
|
||||
ensure!(apk_metadata::valid_package(package), "Invalid APK package");
|
||||
cmd.env("FRAMELY_FBO_PACKAGE", package);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn configure_environment(env: &mut Value, enabled: bool, package: &str) -> Result<()> {
|
||||
// Empty values also prevent inherited Steam environment settings from
|
||||
// enabling a layer for an application whose preference is off.
|
||||
env["FRAMELY_FBO_LIBRARY"] = json!(library(enabled)?);
|
||||
if env["FRAMELY_FBO_LIBRARY"].is_null() {
|
||||
env["FRAMELY_FBO_LIBRARY"] = json!("");
|
||||
}
|
||||
ensure!(
|
||||
!enabled || apk_metadata::valid_package(package),
|
||||
"Invalid APK package"
|
||||
);
|
||||
env["FRAMELY_FBO_PACKAGE"] = json!(if enabled { package } else { "" });
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn mounted(c: &Container, log: &Path) -> Result<bool> {
|
||||
Ok(podman(
|
||||
&[
|
||||
"exec",
|
||||
&format!("lepton-{}", c.name),
|
||||
"sh",
|
||||
"-c",
|
||||
"if [ -r /data/local/debug/gles/libFramelyFBOCompat.so ]; then echo ready; fi",
|
||||
],
|
||||
Some(log),
|
||||
)?
|
||||
.trim()
|
||||
== "ready")
|
||||
}
|
||||
pub(super) fn apply(c: &Container, a: &App, log: &Path) -> Result<()> {
|
||||
ensure!(
|
||||
apk_metadata::valid_package(&a.metadata.package),
|
||||
"Invalid APK package"
|
||||
);
|
||||
// Reusing a container is safe: restart only the selected app if its loader
|
||||
// configuration changed, and clear only our own layer when disabling it.
|
||||
let script = r#"
|
||||
package=$1
|
||||
enabled=$2
|
||||
layer=$(settings get global gpu_debug_layers_gles)
|
||||
target=$(settings get global gpu_debug_app)
|
||||
if [ "$enabled" = true ]; then
|
||||
[ -r /data/local/debug/gles/libFramelyFBOCompat.so ] || exit 64
|
||||
active=$(settings get global enable_gpu_debug_layers)
|
||||
if [ "$layer" != libFramelyFBOCompat.so ] || [ "$target" != "$package" ] || [ "$active" != 1 ]; then
|
||||
am force-stop --user 0 "$package" || exit
|
||||
fi
|
||||
settings put global enable_gpu_debug_layers 1 || exit
|
||||
settings put global gpu_debug_app "$package" || exit
|
||||
settings put global gpu_debug_layers_gles libFramelyFBOCompat.so || exit
|
||||
settings put global gpu_debug_layer_app "$package" || exit
|
||||
elif [ "$layer" = libFramelyFBOCompat.so ]; then
|
||||
if [ "$target" = "$package" ]; then am force-stop --user 0 "$package" || exit; fi
|
||||
settings delete global gpu_debug_layers_gles || exit
|
||||
settings delete global gpu_debug_layer_app || exit
|
||||
fi
|
||||
"#;
|
||||
podman(
|
||||
&[
|
||||
"exec",
|
||||
&format!("lepton-{}", c.name),
|
||||
"sh",
|
||||
"-c",
|
||||
script,
|
||||
"framely-fbo",
|
||||
&a.metadata.package,
|
||||
if a.framebuffer_compatibility {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
},
|
||||
],
|
||||
Some(log),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn compiled_layer_preserves_calls_and_handles_missing_symbols() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let base = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let library = dir.path().join("libFramelyFBOCompat.so");
|
||||
let tester = dir.path().join("test-layer");
|
||||
assert!(Command::new("gcc")
|
||||
.args([
|
||||
"-shared",
|
||||
"-fPIC",
|
||||
"-nostdlib",
|
||||
"-fno-stack-protector",
|
||||
"-fvisibility=hidden",
|
||||
"-Wl,-z,defs",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror"
|
||||
])
|
||||
.arg(base.join("native/fbo_compat.c"))
|
||||
.arg("-o")
|
||||
.arg(&library)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
assert!(Command::new("gcc")
|
||||
.arg(base.join("tests/fbo-layer.c"))
|
||||
.args(["-ldl", "-o"])
|
||||
.arg(&tester)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
assert!(Command::new(tester)
|
||||
.arg(library)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success());
|
||||
}
|
||||
#[test]
|
||||
fn boot_hooks_scope_layer_and_clear_stale_configuration() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let script = dir.path().join("test.sh");
|
||||
fs::write(
|
||||
&script,
|
||||
format!(
|
||||
r#"set -euo pipefail
|
||||
function setup_podman_mounts() {{ echo original-mounts; }}
|
||||
function podman_mount_entry() {{ printf '%s|%s|%s\n' "$1" "$2" "$3"; }}
|
||||
function generate_vulkan_settings_script() {{ echo original-vulkan-settings; }}
|
||||
{HOOKS}
|
||||
setup_podman_mounts
|
||||
generate_vulkan_settings_script
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
for enabled in [false, true] {
|
||||
let result = Command::new("bash")
|
||||
.arg(&script)
|
||||
.env("FRAMELY_FBO_LIBRARY", "/runtime/layer.so")
|
||||
.env(
|
||||
"FRAMELY_FBO_PACKAGE",
|
||||
if enabled { "com.example.game" } else { "" },
|
||||
)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
let output = String::from_utf8(result.stdout).unwrap();
|
||||
assert!(output.contains("original-mounts"));
|
||||
assert!(output
|
||||
.contains("/runtime/layer.so|/data/local/debug/gles/libFramelyFBOCompat.so|ro"));
|
||||
assert!(output.contains("settings delete global gpu_debug_layers_gles"));
|
||||
assert!(output.contains("original-vulkan-settings"));
|
||||
assert_eq!(
|
||||
output.contains("settings put global gpu_debug_app com.example.game"),
|
||||
enabled
|
||||
);
|
||||
assert_eq!(
|
||||
output.contains("settings put global gpu_debug_layers_gles libFramelyFBOCompat.so"),
|
||||
enabled
|
||||
);
|
||||
}
|
||||
let result = Command::new("bash")
|
||||
.arg(&script)
|
||||
.env("FRAMELY_FBO_LIBRARY", "/runtime/layer.so")
|
||||
.env("FRAMELY_FBO_PACKAGE", "bad;command")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!result.status.success());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Roll back only a directory atomically reserved by this installation.
|
||||
use super::*;
|
||||
|
||||
pub(super) struct NewContext {
|
||||
parent: PathBuf,
|
||||
identity: (u64, u64),
|
||||
}
|
||||
impl NewContext {
|
||||
pub(super) fn reserve(home: &Path, db: &Database, c: &Container) -> Result<Self> {
|
||||
cleanup::ensure_runtime_absent(c)?;
|
||||
let base = contexts_root(home);
|
||||
let ancestor = base
|
||||
.ancestors()
|
||||
.find(|p| p.exists())
|
||||
.context("Missing context storage ancestor")?;
|
||||
ensure!(
|
||||
fs::canonicalize(ancestor)? == ancestor,
|
||||
"Container storage contains a link"
|
||||
);
|
||||
fs::create_dir_all(&base)?;
|
||||
cleanup::validate_location(home, db, c)?;
|
||||
let parent = c
|
||||
.baked
|
||||
.parent()
|
||||
.context("Missing context parent")?
|
||||
.to_path_buf();
|
||||
// create_dir, not create_dir_all: an existing/replaced context is never
|
||||
// owned by this attempt, regardless of its size or missing database.
|
||||
fs::create_dir(&parent)
|
||||
.with_context(|| format!("Cannot reserve new container {}", parent.display()))?;
|
||||
let metadata = fs::symlink_metadata(&parent)?;
|
||||
Ok(Self {
|
||||
parent,
|
||||
identity: (metadata.dev(), metadata.ino()),
|
||||
})
|
||||
}
|
||||
pub(super) fn rollback(
|
||||
self,
|
||||
home: &Path,
|
||||
db: &mut Database,
|
||||
c: &Container,
|
||||
log: &Path,
|
||||
) -> Result<()> {
|
||||
let metadata = fs::symlink_metadata(&self.parent)?;
|
||||
ensure!(
|
||||
metadata.is_dir()
|
||||
&& !metadata.file_type().is_symlink()
|
||||
&& (metadata.dev(), metadata.ino()) == self.identity,
|
||||
"New container directory changed; rollback refused"
|
||||
);
|
||||
cleanup::delete_failed_install(home, db, c, log)
|
||||
}
|
||||
}
|
||||
@@ -214,14 +214,15 @@ fn candidates(home: &Path, db: &Database) -> Vec<Container> {
|
||||
.filter(|r| !r.removed)
|
||||
.map(|r| r.context.clone())
|
||||
.collect();
|
||||
let base = home.join(".local/share/lepton/contexts");
|
||||
let base = super::contexts_root(home);
|
||||
let default_base = home.join(".local/share/lepton/contexts");
|
||||
let mut out = Vec::new();
|
||||
for id in contexts {
|
||||
if !safe(&id) || id.starts_with("steamlaunch-") {
|
||||
continue;
|
||||
}
|
||||
let mut name = id.clone();
|
||||
let mut baked = base.join(&id).join("baked");
|
||||
let mut baked = (if db.owned_contexts.contains(&id) { &base } else { &default_base }).join(&id).join("baked");
|
||||
if id.starts_with("external-") {
|
||||
let Some(path) = db
|
||||
.roots
|
||||
|
||||
+38
-5
@@ -30,9 +30,17 @@ if [[ -n "${FRAMELY_NATIVE_APK_DIR:-}" ]]; then
|
||||
framely_native_mounts="$(declare -f setup_mounts)"
|
||||
framely_native_lower='APP_APK_DIR="$(dirname "$(get_apk_path)")"'
|
||||
framely_native_upper='$(get_baked_app_data_dir)/app_overlay'
|
||||
[[ "$framely_native_mounts" == *"$framely_native_lower"* && "$framely_native_mounts" == *"$framely_native_upper"* ]] || { echo 'Unsupported native APK mounts; keeping application data unchanged' >&2; exit 64; }
|
||||
[[ "$framely_native_mounts" == *"$framely_native_lower"* ]] || { echo 'Unsupported native APK mounts; keeping application data unchanged' >&2; exit 64; }
|
||||
if [[ "$framely_native_mounts" == *"$framely_native_upper"* ]]; then
|
||||
framely_native_mounts="${framely_native_mounts//"$framely_native_upper"/'${FRAMELY_NATIVE_OVERLAY:?}/upper'}"
|
||||
elif [[ "$framely_native_mounts" == *'APP_OVERLAY_UPPERDIR="$(app_datadir)"'* ]] && declare -F app_datadir >/dev/null && [[ "$(declare -f app_datadir)" == *'$(get_baked_app_data_dir $@)/app_overlay'* ]]; then
|
||||
# Lepton 3 routes the APK upper directory through app_datadir.
|
||||
function app_datadir() { print "${FRAMELY_NATIVE_OVERLAY:?}/upper"; }
|
||||
else
|
||||
echo 'Unsupported native APK mounts; keeping application data unchanged' >&2
|
||||
exit 64
|
||||
fi
|
||||
framely_native_mounts="${framely_native_mounts//"$framely_native_lower"/'APP_APK_DIR="${FRAMELY_NATIVE_APK_DIR:?}"'}"
|
||||
framely_native_mounts="${framely_native_mounts//"$framely_native_upper"/'${FRAMELY_NATIVE_OVERLAY:?}/upper'}"
|
||||
function app_workdir() { print "${FRAMELY_NATIVE_OVERLAY:?}/work"; }
|
||||
eval "$framely_native_mounts"
|
||||
unset framely_native_mounts framely_native_lower framely_native_upper
|
||||
@@ -176,6 +184,7 @@ pub(super) fn prepare(
|
||||
"APP_WANTS_FLATSCREEN":if flat {"true"}else{"false"},
|
||||
"LEPTON_NO_CLEANUP":"true", "TERM":"dumb"
|
||||
});
|
||||
graphics::configure_environment(&mut env, a.framebuffer_compatibility, &a.metadata.package)?;
|
||||
let installed_dir = c
|
||||
.baked
|
||||
.join("data_overlay")
|
||||
@@ -206,10 +215,19 @@ pub(super) fn prepare(
|
||||
)?;
|
||||
}
|
||||
if db.gamepad_enabled {
|
||||
let m =
|
||||
crate::gamepad::prepare(&root(home), &c.name, &db.gamepad_source, db.gamepad_rumble)?;
|
||||
let m = crate::gamepad::prepare(
|
||||
&root(home),
|
||||
&c.name,
|
||||
&db.gamepad_source,
|
||||
db.gamepad_rumble,
|
||||
db.gamepad_trigger_threshold,
|
||||
)?;
|
||||
crate::gamepad::select_target(&c.name, &a.metadata.package)?;
|
||||
for (k, v) in [
|
||||
(
|
||||
"FRAMELY_GAMEPAD_DIRECT",
|
||||
json!(if m.direct { "1" } else { "0" }),
|
||||
),
|
||||
("FRAMELY_GAMEPAD_EVENT", json!(m.event)),
|
||||
("FRAMELY_GAMEPAD_GRAB", json!(m.grab)),
|
||||
("FRAMELY_GAMEPAD_LAYOUT", json!(m.layout)),
|
||||
@@ -334,6 +352,10 @@ function setup_mounts() {{
|
||||
setup_mounts
|
||||
"#
|
||||
);
|
||||
for source in [source.clone(), source.replace(
|
||||
" local APP_OVERLAY_UPPERDIR=\"$(get_baked_app_data_dir)/app_overlay\"",
|
||||
" local APP_OVERLAY_UPPERDIR=\"$(app_datadir)\"",
|
||||
).replacen("function setup_mounts()", "function app_datadir() { print \"$(get_baked_app_data_dir $@)/app_overlay\"; }\nfunction setup_mounts()", 1)] {
|
||||
for package in ["one", "two"] {
|
||||
let out = Command::new("bash")
|
||||
.args(["-c", &source])
|
||||
@@ -352,6 +374,7 @@ setup_mounts
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), format!("/baked/data_overlay/app/{package}|/baked/native-{package}/upper|/baked/native-{package}/work"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn native_hooks_keep_storage_and_selected_activity_after_early_exit() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
@@ -372,6 +395,11 @@ function setup_podman_mounts() { setup_mounts; }
|
||||
function podman_mount_entry() { :; }
|
||||
function print() { printf '%s\n' "$*"; }
|
||||
function println() { print "$@"; }
|
||||
function prepare_baked_data_for_removal() { rm -rf "$TEST_DATA"; }
|
||||
function remove_prefix() {
|
||||
prepare_baked_data_for_removal
|
||||
rm -rf "$LEPTON_PREFIX"
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -380,7 +408,7 @@ set -euo pipefail
|
||||
COMMAND=waitforexitandrun
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
source "${SCRIPT_DIR}/liblepton/liblepton.sh"
|
||||
function teardown() { clear_baked_app_data 'early exit'; }
|
||||
function teardown() { clear_baked_app_data 'early exit'; remove_prefix; }
|
||||
case "${COMMAND}" in
|
||||
waitforexitandrun)
|
||||
is_app_baked
|
||||
@@ -403,6 +431,9 @@ esac
|
||||
fs::create_dir_all(save.parent().unwrap()).unwrap();
|
||||
fs::write(&save, b"saved game").unwrap();
|
||||
for flat in [true, false] {
|
||||
let prefix = t.path().join("lepton-prefix");
|
||||
fs::create_dir_all(&prefix).unwrap();
|
||||
fs::write(prefix.join("temporary"), b"runtime").unwrap();
|
||||
let out = Command::new("bash")
|
||||
.arg(&entry)
|
||||
.env("FRAMELY_LEPTON_DIR", lib.parent().unwrap())
|
||||
@@ -414,6 +445,7 @@ esac
|
||||
.env("FRAMELY_NATIVE_ACTIVITY", "test.Selected")
|
||||
.env("APP_WANTS_FLATSCREEN", if flat { "true" } else { "false" })
|
||||
.env("TEST_DATA", &data)
|
||||
.env("LEPTON_PREFIX", &prefix)
|
||||
.env("TEST_PROPS", t.path().join("props"))
|
||||
.output()
|
||||
.unwrap();
|
||||
@@ -431,6 +463,7 @@ esac
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains(expected));
|
||||
assert_eq!(fs::read(&save).unwrap(), b"saved game");
|
||||
assert_eq!(fs::read(&download).unwrap(), b"resume-me");
|
||||
assert!(!prefix.exists(), "Temporary prefix must still be cleaned up");
|
||||
}
|
||||
assert!(script("unknown future native entry").is_err());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Mirrors only a pending Android runtime-permission dialog into a VR window.
|
||||
//! The user's click is delivered to Android's own PermissionController, so
|
||||
//! Android retains its one-time/while-in-use/denial semantics and callbacks.
|
||||
use super::*;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use image::{DynamicImage, ImageFormat};
|
||||
use std::collections::BTreeSet;
|
||||
use std::io::{Cursor, Seek, SeekFrom};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Pending {
|
||||
pub id: String,
|
||||
pub app_name: String,
|
||||
pub package: String,
|
||||
runtime: String,
|
||||
instance: String,
|
||||
}
|
||||
|
||||
fn active_permission(runtime: &str) -> Result<bool> {
|
||||
let mut command = crate::process::tool("podman");
|
||||
command.args([
|
||||
"exec", &format!("lepton-{runtime}"), "sh", "-c",
|
||||
"dumpsys activity activities | grep -E 'topResumedActivity=|mResumedActivity:'; printf '\\nFRAMELY_PERMISSION_DONE\\n'",
|
||||
]);
|
||||
let text = output(command, Duration::from_secs(3), None)?;
|
||||
ensure!(text.ends_with("FRAMELY_PERMISSION_DONE\n"), "Incomplete permission probe");
|
||||
Ok(text.lines().any(|line| {
|
||||
(line.contains("topResumedActivity=") || line.contains("mResumedActivity:"))
|
||||
&& line.contains("com.android.permissioncontroller/")
|
||||
&& line.contains("GrantPermissionsActivity")
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn pending(home: &Path) -> Result<Option<Pending>> {
|
||||
let db = load(home)?;
|
||||
let mut command = crate::process::tool("podman");
|
||||
command.args(["ps", "--format", "{{.Names}}"]);
|
||||
let running = output(command, Duration::from_secs(3), None)?;
|
||||
let running: BTreeSet<_> = running.lines().collect();
|
||||
let contexts = containers(home, &db);
|
||||
for record in db.records.values().filter(|r| !r.removed && r.metadata.vr && !r.show_window.unwrap_or(false)) {
|
||||
let Some(base) = contexts.iter().find(|c| c.id == record.context) else { continue };
|
||||
let runtime = native::runtime_name(record).filter(|name| running.contains(format!("lepton-{name}").as_str()))
|
||||
.unwrap_or_else(|| base.name.clone());
|
||||
if !running.contains(format!("lepton-{runtime}").as_str()) { continue; }
|
||||
let mut probe = crate::process::tool("podman");
|
||||
probe.args(["exec", &format!("lepton-{runtime}"), "getprop", "lepton.active_app_id"]);
|
||||
let Ok(package) = output(probe, Duration::from_secs(3), None) else { continue };
|
||||
if package.trim() != record.metadata.package { continue; }
|
||||
if !active_permission(&runtime)? { continue; }
|
||||
let mut inspect = crate::process::tool("podman");
|
||||
inspect.args(["inspect", "--format", "{{.Id}}|{{.State.StartedAt}}|{{.State.Running}}", &format!("lepton-{runtime}")]);
|
||||
let Ok(instance) = output(inspect, Duration::from_secs(3), None) else { continue };
|
||||
let instance = instance.trim().to_owned();
|
||||
if !instance.ends_with("|true") || instance.split('|').count() != 3 { continue; }
|
||||
let id = hash(&format!("{}|{}|{}", runtime, instance, package.trim()));
|
||||
return Ok(Some(Pending {
|
||||
id,
|
||||
app_name: record.metadata.name.clone(),
|
||||
package: package.trim().to_owned(),
|
||||
runtime,
|
||||
instance,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn current(home: &Path, id: &str) -> Result<Pending> {
|
||||
let request = pending(home)?.context("Android permission request has ended")?;
|
||||
ensure!(request.id == id, "Android permission request has changed");
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn screenshot(runtime: &str) -> Result<Vec<u8>> {
|
||||
let mut file = tempfile::tempfile()?;
|
||||
let mut child = crate::process::tool("podman")
|
||||
.args(["exec", &format!("lepton-{runtime}"), "screencap", "-p"])
|
||||
.stdin(Stdio::null()).stdout(Stdio::from(file.try_clone()?)).stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
let start = Instant::now();
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? { break status; }
|
||||
if start.elapsed() >= Duration::from_secs(3) {
|
||||
let _ = child.kill(); let _ = child.wait();
|
||||
bail!("Android permission capture timed out");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
ensure!(status.success(), "Android permission capture failed");
|
||||
ensure!(file.metadata()?.len() <= 8 * 1024 * 1024, "Android permission capture too large");
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "Android permission capture is not PNG");
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn frame(home: &Path, id: &str) -> Result<Value> {
|
||||
let request = current(home, id)?;
|
||||
let image = image::load_from_memory_with_format(&screenshot(&request.runtime)?, ImageFormat::Png)?;
|
||||
let (width, height) = (image.width(), image.height());
|
||||
ensure!((320..=4096).contains(&width) && (240..=4096).contains(&height), "Unexpected Android display size");
|
||||
let crop_width = width * 11 / 20;
|
||||
let crop_height = height * 11 / 20;
|
||||
let left = (width - crop_width) / 2;
|
||||
let top = (height - crop_height) / 2;
|
||||
let cropped = image.crop_imm(left, top, crop_width, crop_height);
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
DynamicImage::ImageRgba8(cropped.into_rgba8()).write_to(&mut png, ImageFormat::Png)?;
|
||||
ensure!(current(home, id)?.instance == request.instance, "Android permission request has changed");
|
||||
Ok(json!({"id":id,"appName":request.app_name,"package":request.package,
|
||||
"image":format!("data:image/png;base64,{}",STANDARD.encode(png.into_inner())),
|
||||
"left":left,"top":top,"width":crop_width,"height":crop_height}))
|
||||
}
|
||||
|
||||
const INPUT: &str = r#"
|
||||
set -eu
|
||||
for entry in $(cat /proc/$(pidof system_server)/environ | tr '\0' '\n' | grep -E '^(ANDROID_(ROOT|DATA|ART_ROOT|I18N_ROOT|TZDATA_ROOT)|BOOTCLASSPATH)='); do
|
||||
export "$entry"
|
||||
done
|
||||
exec input touchscreen tap "$1" "$2"
|
||||
"#;
|
||||
|
||||
pub(crate) fn click(home: &Path, id: &str, x: u32, y: u32) -> Result<Value> {
|
||||
let request = current(home, id)?;
|
||||
ensure!(x < 4096 && y < 4096, "Invalid Android input position");
|
||||
let image = image::load_from_memory_with_format(&screenshot(&request.runtime)?, ImageFormat::Png)?;
|
||||
ensure!(x < image.width() && y < image.height(), "Android input position outside display");
|
||||
ensure!(current(home, id)?.instance == request.instance, "Android permission request has changed");
|
||||
let mut command = crate::process::tool("podman");
|
||||
command.args([
|
||||
"exec", &format!("lepton-{}", request.runtime), "sh", "-c", INPUT,
|
||||
"framely-permission", &x.to_string(), &y.to_string(),
|
||||
]);
|
||||
output(command, Duration::from_secs(3), None)?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
//! Optional Quest APK conversion. The runtime is versioned separately from Framely.
|
||||
use super::{apk_metadata, init, review, space, Cancellation, Metadata, MUTATION};
|
||||
use crate::model::ProxySettings;
|
||||
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},
|
||||
thread,
|
||||
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()
|
||||
.context("Missing Framely binary directory")?
|
||||
.parent()
|
||||
.context("Missing Framely release directory")?
|
||||
.to_path_buf())
|
||||
}
|
||||
|
||||
fn requirement_path() -> Result<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("FRAMELY_Q2F_DESCRIPTOR") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
Ok(release_root()?.join("Q2F_RUNTIME.json"))
|
||||
}
|
||||
|
||||
fn tool_path() -> Result<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("FRAMELY_Q2F_TOOL") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
Ok(release_root()?.join("tools/q2f-runtime.py"))
|
||||
}
|
||||
|
||||
pub(super) fn status(home: &Path) -> Result<Value> {
|
||||
let requirement: Value = serde_json::from_slice(&fs::read(requirement_path()?)?)?;
|
||||
let identity = requirement["id"]
|
||||
.as_str()
|
||||
.context("Invalid q2f runtime identity")?;
|
||||
ensure!(
|
||||
!identity.is_empty()
|
||||
&& identity
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b".+-".contains(&b))
|
||||
&& !identity.contains(".."),
|
||||
"Invalid q2f runtime identity"
|
||||
);
|
||||
let cached = super::root(home).join("q2f/runtimes").join(identity);
|
||||
Ok(json!({"id":identity,"bytes":requirement["size"],
|
||||
"cached":fs::read_to_string(cached.join("VERSION")).is_ok_and(|v|v.trim()==identity)}))
|
||||
}
|
||||
|
||||
fn run(mut command: Command, log: &Path, cancel: &Cancellation) -> Result<()> {
|
||||
let output = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(log)?;
|
||||
let errors = output.try_clone()?;
|
||||
let mut child = command
|
||||
.stdout(Stdio::from(output))
|
||||
.stderr(Stdio::from(errors))
|
||||
.spawn()?;
|
||||
loop {
|
||||
if let Err(error) = cancel.check() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(status) = child.try_wait()? {
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let detail = fs::read_to_string(log).unwrap_or_default();
|
||||
let tail = detail
|
||||
.chars()
|
||||
.rev()
|
||||
.take(3000)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
anyhow::bail!("Quest conversion failed ({}): {}", status, tail.trim());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace(storage: &Path, runtime: &Path) -> Result<PathBuf> {
|
||||
let workspace = storage.join("q2f/workspace");
|
||||
fs::create_dir_all(workspace.join("signatures"))?;
|
||||
fs::set_permissions(
|
||||
workspace.join("signatures"),
|
||||
fs::Permissions::from_mode(0o700),
|
||||
)?;
|
||||
let libraries = workspace.join("libraries");
|
||||
if libraries.is_symlink() || libraries.exists() {
|
||||
if !libraries.is_symlink() {
|
||||
anyhow::bail!("Unexpected q2f libraries path");
|
||||
}
|
||||
fs::remove_file(&libraries)?;
|
||||
}
|
||||
let identity = runtime
|
||||
.file_name()
|
||||
.context("Missing q2f runtime identity")?;
|
||||
// Keep this relative so APK storage migration preserves the link.
|
||||
symlink(
|
||||
Path::new("../runtimes").join(identity).join("libraries"),
|
||||
&libraries,
|
||||
)?;
|
||||
fs::copy(
|
||||
runtime.join("overport-installed.json"),
|
||||
workspace.join("installed.json"),
|
||||
)?;
|
||||
Ok(workspace)
|
||||
}
|
||||
|
||||
#[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() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let old = temp.path().join("old");
|
||||
let runtime = old.join("q2f/runtimes/test-id");
|
||||
fs::create_dir_all(runtime.join("libraries")).unwrap();
|
||||
fs::write(runtime.join("overport-installed.json"), "{}").unwrap();
|
||||
fs::write(runtime.join("libraries/probe"), "ok").unwrap();
|
||||
let work = workspace(&old, &runtime).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(work.join("libraries/probe")).unwrap(),
|
||||
"ok"
|
||||
);
|
||||
let new = temp.path().join("new");
|
||||
fs::rename(old, &new).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(new.join("q2f/workspace/libraries/probe")).unwrap(),
|
||||
"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(
|
||||
home: &Path,
|
||||
ticket: &str,
|
||||
cancel: &Cancellation,
|
||||
progress: impl Fn(Value) -> Result<()>,
|
||||
proxy: &ProxySettings,
|
||||
) -> Result<Value> {
|
||||
let _guard = MUTATION.lock().unwrap();
|
||||
cancel.check()?;
|
||||
let storage = init(home)?;
|
||||
let (source, before) = review(home, ticket)?;
|
||||
let source_apk = source.join("base.apk");
|
||||
let original_bytes = fs::metadata(&source_apk)?.len();
|
||||
// The observed OVR Port peak was roughly four source APK sizes, plus the
|
||||
// original review. Leave a fixed reserve for unrelated Android writes.
|
||||
space(
|
||||
&storage,
|
||||
original_bytes
|
||||
.saturating_mul(5)
|
||||
.saturating_add(1024 * 1024 * 1024),
|
||||
)?;
|
||||
let descriptor = requirement_path()?;
|
||||
ensure!(
|
||||
descriptor.is_file(),
|
||||
"This Framely build has no q2f runtime requirement"
|
||||
);
|
||||
let requirement: Value = serde_json::from_slice(&fs::read(&descriptor)?)?;
|
||||
let version = requirement["librariesVersion"]
|
||||
.as_str()
|
||||
.context("Invalid q2f runtime version")?;
|
||||
ensure!(
|
||||
version
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b)),
|
||||
"Invalid q2f runtime version"
|
||||
);
|
||||
progress(json!({"phase":"q2f-preparing","operation":"convert"}))?;
|
||||
let log = storage.join("logs").join(format!("q2f-{ticket}.log"));
|
||||
let mut prepare = Command::new("python3");
|
||||
prepare.arg(tool_path()?).arg(&descriptor).arg(&storage);
|
||||
prepare.env("FRAMELY_GITHUB_AUTO_FALLBACK", if proxy.github_auto_fallback {"1"} else {"0"});
|
||||
prepare.env("FRAMELY_GITHUB_PROXY", if proxy.github_enabled {&proxy.github} else {""});
|
||||
prepare.env("FRAMELY_HTTP_PROXY", if proxy.http_enabled {&proxy.http} else {""});
|
||||
run(prepare, &log, cancel)?;
|
||||
let runtime = PathBuf::from(
|
||||
fs::read_to_string(&log)?
|
||||
.lines()
|
||||
.last()
|
||||
.context("q2f runtime path missing")?,
|
||||
);
|
||||
ensure!(
|
||||
runtime.is_dir() && runtime.starts_with(storage.join("q2f/runtimes")),
|
||||
"Invalid q2f runtime path"
|
||||
);
|
||||
let work = workspace(&storage, &runtime)?;
|
||||
let output = source.join("converted");
|
||||
fs::create_dir_all(&output)?;
|
||||
progress(json!({"phase":"q2f-converting","operation":"convert","cancellable":true}))?;
|
||||
let mut java = Command::new(runtime.join("java/bin/java"));
|
||||
java.arg("-Xmx3g")
|
||||
.arg("-jar")
|
||||
.arg(runtime.join("overportcli.jar"))
|
||||
.arg("patch")
|
||||
.arg(format!("--input={}", source_apk.display()))
|
||||
.arg(format!("--output={}", output.display()))
|
||||
.arg("--output-name=base.apk")
|
||||
.arg(format!("--workspace={}", work.display()))
|
||||
.arg(format!("--version={version}"));
|
||||
let conversion = run(java, &log, cancel);
|
||||
let _ = fs::remove_dir_all(work.join("dec"));
|
||||
if let Err(error) = conversion {
|
||||
let _ = fs::remove_dir_all(&output);
|
||||
return Err(error);
|
||||
}
|
||||
let converted = output.join("base.apk");
|
||||
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);
|
||||
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?;
|
||||
Ok(
|
||||
json!({"ticket":next["ticket"],"metadata":next["metadata"],"bytes":next["bytes"],
|
||||
"q2fVersion":requirement["id"],"originalBytes":original_bytes}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Only missing artwork or content previously generated by Framely may change.
|
||||
async function(p, id, render, selectPalette) {
|
||||
const suffix={0:'p',1:'_hero',2:'_logo',3:''};
|
||||
const load=data=>new Promise((resolve,reject)=>{const i=new Image(),timer=setTimeout(()=>reject(Error('Artwork decode timeout')),4000);i.onload=()=>{clearTimeout(timer);resolve(i);};i.onerror=()=>{clearTimeout(timer);reject(Error('Invalid artwork'));};i.src=data;});
|
||||
const digest=async image=>{
|
||||
let c=image;
|
||||
if(!image.getContext){c=document.createElement('canvas');c.width=image.width;c.height=image.height;c.getContext('2d').drawImage(image,0,0);}
|
||||
const ctx=c.getContext('2d'),pixels=ctx.getImageData(0,0,c.width,c.height).data;
|
||||
const header=new TextEncoder().encode(`${c.width}x${c.height}:`),input=new Uint8Array(header.length+pixels.length);input.set(header);input.set(pixels,header.length);
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',input))).map(v=>v.toString(16).padStart(2,'0')).join('');
|
||||
};
|
||||
const inspect=async kind=>{
|
||||
const hashes=[];
|
||||
for(const ext of ['png','jpg','jpeg','webp']){
|
||||
const response=await fetch(`/customimages/${id}${suffix[kind]}.${ext}?framely=${Date.now()}`,{cache:'no-store'});
|
||||
if(response.status===404)continue;
|
||||
if(!response.ok)throw Error('Cannot inspect existing Steam artwork');
|
||||
const blob=await response.blob();
|
||||
if(blob.size>16*1024*1024)throw Error('Existing Steam artwork is too large');
|
||||
const data=await new Promise((resolve,reject)=>{const r=new FileReader();r.onload=()=>resolve(r.result);r.onerror=reject;r.readAsDataURL(blob);});
|
||||
const image=await load(data);
|
||||
if(image.width>8192||image.height>8192||image.width*image.height>16*1024*1024)throw Error('Existing artwork dimensions are too large');
|
||||
hashes.push(await digest(image));
|
||||
}
|
||||
return hashes;
|
||||
};
|
||||
const owned=p.ownership?.appId===id?p.ownership.hashes:{};
|
||||
const hashes={...owned}, generated=await render(p,selectPalette);
|
||||
try { for(const a of generated){
|
||||
if(!Object.hasOwn(suffix,a.kind))throw Error('Unsupported Steam artwork type');
|
||||
const current=await inspect(a.kind), previous=[];
|
||||
if(owned[a.kind])previous.push(owned[a.kind]);
|
||||
// v1 mistakenly sent the icon as type 4; Steam wrote it to the SAME
|
||||
// landscape path as type 3. Repair only that exact owned image.
|
||||
if(a.kind===3&&owned[4])previous.push(owned[4]);
|
||||
// One-time upgrade of the exact old plain-background/icon-only images.
|
||||
const legacy=p.legacy?.find(v=>v.kind===a.kind);
|
||||
if(legacy)previous.push(await digest(await load('data:image/png;base64,'+legacy.data)));
|
||||
if(a.kind===3){const oldIcon=p.legacy?.find(v=>v.kind===4);if(oldIcon)previous.push(await digest(await load('data:image/png;base64,'+oldIcon.data)));}
|
||||
if(current.length&¤t.some(h=>!previous.includes(h))){delete hashes[a.kind];if(a.kind===3)delete hashes[4];continue;}
|
||||
const hash=await digest(a.canvas);
|
||||
if(current.length===1&¤t[0]===hash){hashes[a.kind]=hash;if(a.kind===3)delete hashes[4];continue;}
|
||||
// Recheck just before writing in case a user changed the image meanwhile.
|
||||
const rechecked=await inspect(a.kind);
|
||||
if(JSON.stringify(current)!==JSON.stringify(rechecked)){delete hashes[a.kind];continue;}
|
||||
try{await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind);}
|
||||
catch(error){await new Promise(r=>setTimeout(r,200));const latest=await inspect(a.kind);if(latest.some(h=>!previous.includes(h)&&h!==hash))throw Error('Steam artwork changed during retry');await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind);}
|
||||
hashes[a.kind]=hash;
|
||||
if(a.kind===3)delete hashes[4];
|
||||
}} catch(error) {
|
||||
// Return completed hashes even on a partial failure. The core persists
|
||||
// this checkpoint before reporting failure, so retry does not mistake
|
||||
// images just written by Framely for user-created artwork.
|
||||
return {version:1,appId:id,hashes,failed:true};
|
||||
}
|
||||
return {version:1,appId:id,hashes};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Pure image algorithm. Package IDs and application names never enter selection.
|
||||
function(pixels, width, height) {
|
||||
if (pixels.length !== width * height * 4) throw Error('Invalid RGBA image');
|
||||
const distance = (a, b) => a.reduce((sum, v, i) => sum + (v - b[i]) ** 2, 0);
|
||||
const average = (points, key) => {
|
||||
const weight = points.reduce((sum, p) => sum + p.weight, 0);
|
||||
return [0, 1, 2].map(i => points.reduce((sum, p) => sum + p[key][i] * p.weight, 0) / weight);
|
||||
};
|
||||
const hex = rgb => '#' + rgb.map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
|
||||
const lab = rgb => {
|
||||
const [r, g, b] = rgb.map(v => { v /= 255; return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4; });
|
||||
const l = Math.cbrt(.4122214708*r + .5363325363*g + .0514459929*b);
|
||||
const m = Math.cbrt(.2119034982*r + .6806995451*g + .1073969566*b);
|
||||
const s = Math.cbrt(.0883024619*r + .2817188376*g + .6299787005*b);
|
||||
return [.2104542553*l + .793617785*m - .0040720468*s, 1.9779984951*l - 2.428592205*m + .4505937099*s, .0259040371*l + .7827717662*m - .808675766*s];
|
||||
};
|
||||
const hsl = rgb => {
|
||||
const [r,g,b] = rgb.map(v => v/255), hi = Math.max(r,g,b), lo = Math.min(r,g,b), d = hi-lo, l = (hi+lo)/2;
|
||||
const h = d === 0 ? 0 : hi === r ? (g-b)/d : hi === g ? (b-r)/d+2 : (r-g)/d+4;
|
||||
return [(h*60+360)%360, d === 0 ? 0 : d/(1-Math.abs(2*l-1)), l];
|
||||
};
|
||||
const fromHsl = (h,s,l) => {
|
||||
const a=s*Math.min(l,1-l), f=n=>{const k=(n+h/30)%12;return Math.round(255*(l-a*Math.max(-1,Math.min(k-3,9-k,1))));};
|
||||
return [f(0),f(8),f(4)];
|
||||
};
|
||||
const points = [];
|
||||
for (let y=0; y<height; y++) for (let x=0; x<width; x++) {
|
||||
const i=(y*width+x)*4; if(pixels[i+3]<32) continue;
|
||||
const rgb=Array.from(pixels.subarray(i,i+3));
|
||||
points.push({rgb,lab:lab(rgb),weight:pixels[i+3]/255,edge:x<width*.19||x>=width*.81||y<height*.19||y>=height*.81});
|
||||
}
|
||||
const neutral = {selected:'#808080',base:'#191a1d',accent:'#9a9da5',neutral:true};
|
||||
if(!points.length) return neutral;
|
||||
let centers=[average(points,'lab')], groups;
|
||||
while(centers.length<8) {
|
||||
const nearest=p=>Math.min(...centers.map(c=>distance(p.lab,c)));
|
||||
const far=points.reduce((best,p)=>nearest(p)>nearest(best)?p:best,points[0]);
|
||||
if(nearest(far)<.0004) break;
|
||||
centers.push(far.lab);
|
||||
}
|
||||
for(let step=0;step<24;step++) {
|
||||
groups=centers.map(()=>[]);
|
||||
for(const p of points) {const ds=centers.map(c=>distance(p.lab,c));groups[ds.indexOf(Math.min(...ds))].push(p);}
|
||||
const next=groups.map((g,i)=>g.length?average(g,'lab'):centers[i]);
|
||||
const delta=Math.max(...next.map((c,i)=>distance(c,centers[i])));
|
||||
centers=next;if(delta<.0000001)break;
|
||||
}
|
||||
const clusters=groups.filter(g=>g.length).map(points=>({points,lab:average(points,'lab')}));
|
||||
for(let changed=true;changed;) {
|
||||
changed=false;
|
||||
outer:for(let i=0;i<clusters.length;i++)for(let j=i+1;j<clusters.length;j++)if(distance(clusters[i].lab,clusters[j].lab)<.065**2) {
|
||||
clusters[i].points.push(...clusters[j].points);clusters[i].lab=average(clusters[i].points,'lab');clusters.splice(j,1);changed=true;break outer;
|
||||
}
|
||||
}
|
||||
const total=points.reduce((s,p)=>s+p.weight,0), edgeTotal=points.filter(p=>p.edge).reduce((s,p)=>s+p.weight,0);
|
||||
const candidates=clusters.map(c=>{
|
||||
const coverage=c.points.reduce((s,p)=>s+p.weight,0)/total;
|
||||
const edgeCoverage=edgeTotal?c.points.filter(p=>p.edge).reduce((s,p)=>s+p.weight,0)/edgeTotal:coverage;
|
||||
const rgb=average(c.points,'rgb'), chroma=Math.hypot(c.lab[1],c.lab[2]);
|
||||
const eligible=coverage>=.035&&edgeCoverage>=.025&&chroma>=.012&&c.lab[0]>.18&&c.lab[0]<.96;
|
||||
return {rgb,coverage,edgeCoverage,eligible,score:(.5*coverage+.5*edgeCoverage)*Math.min(1,chroma/.025)*(chroma>.16?.8:1)};
|
||||
}).sort((a,b)=>b.score-a.score);
|
||||
const best=candidates.find(c=>c.eligible);
|
||||
if(!best||best.score<.055)return {...neutral,selected:hex([...candidates].sort((a,b)=>(b.coverage+b.edgeCoverage)-(a.coverage+a.edgeCoverage))[0].rgb)};
|
||||
const [h,s]=hsl(best.rgb);
|
||||
// Multiply all RGB channels uniformly: 80% intensity, no hue remapping.
|
||||
return {selected:hex(best.rgb),base:hex(fromHsl(h,s*.22,.095)),accent:hex(best.rgb.map(v=>v*.8)),neutral:false};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Render inside Steam's existing CEF context; no external runtime/fonts needed.
|
||||
async function(p, selectPalette) {
|
||||
const load = data => new Promise((resolve,reject)=>{
|
||||
const image=new Image(), timer=setTimeout(()=>reject(Error('Artwork image timeout')),4000);
|
||||
image.onload=()=>{clearTimeout(timer);resolve(image);};
|
||||
image.onerror=()=>{clearTimeout(timer);reject(Error('Invalid artwork image'));};image.src=data;
|
||||
});
|
||||
const source=await load(p.image);
|
||||
const canvas=(w,h)=>{const c=document.createElement('canvas');c.width=w;c.height=h;return c;};
|
||||
const sample=canvas(64,64), sc=sample.getContext('2d');sc.drawImage(source,0,0,64,64);
|
||||
const palette=selectPalette(sc.getImageData(0,0,64,64).data,64,64);
|
||||
let n=2166136261;
|
||||
for(const c of p.package)n=Math.imul(n^c.charCodeAt(0),16777619);
|
||||
const random=()=>{n+=0x6D2B79F5;let t=n;t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return ((t^(t>>>14))>>>0)/4294967296;};
|
||||
const angle=(-39-random()*8)*Math.PI/180, offset=(random()-.5)*65;
|
||||
const shade=(hex,f)=>'#'+[1,3,5].map(i=>Math.round(parseInt(hex.slice(i,i+2),16)*f).toString(16).padStart(2,'0')).join('');
|
||||
const rounded=(c,x,y,w,h,r)=>{c.beginPath();c.roundRect(x,y,w,h,r);};
|
||||
const icon=(c,x,y,s,rotation=0,opacity=1)=>{
|
||||
c.save();c.globalAlpha=opacity;c.translate(x+s/2,y+s/2);c.rotate(rotation*Math.PI/180);c.translate(-s/2,-s/2);
|
||||
rounded(c,0,0,s,s,s*.23);c.clip();
|
||||
const scale=Math.min(s/source.width,s/source.height),w=source.width*scale,h=source.height*scale;
|
||||
c.drawImage(source,(s-w)/2,(s-h)/2,w,h);c.restore();
|
||||
};
|
||||
const fontSize=(size,max)=>{
|
||||
// Use actual font metrics, including CJK/emoji and long custom names.
|
||||
const c=sc;c.font=`700 ${size}px "Noto Sans CJK SC", "Noto Sans", sans-serif`;
|
||||
const width=c.measureText(p.name).width;return width>max?size*max/width:size;
|
||||
};
|
||||
const title=(c,x,y,size,max,center=false)=>{
|
||||
c.save();c.fillStyle='#fff';c.textAlign=center?'center':'left';c.font=`700 ${fontSize(size,max)}px "Noto Sans CJK SC", "Noto Sans", sans-serif`;
|
||||
c.fillText(p.name,x,y);c.restore();
|
||||
};
|
||||
const base=(c,w,h)=>{
|
||||
const g=c.createLinearGradient(0,0,w,h);g.addColorStop(0,palette.base);g.addColorStop(.43,shade(palette.base,.7));g.addColorStop(1,shade(palette.accent,.24));
|
||||
c.fillStyle=g;c.fillRect(0,0,w,h);
|
||||
c.save();c.translate(w*.82,h*.82);c.scale(w*.7,h*.85);
|
||||
const light=c.createRadialGradient(0,0,0,0,0,1);light.addColorStop(0,palette.accent+'0f');light.addColorStop(1,palette.accent+'00');c.fillStyle=light;c.fillRect(-1,-1,2,2);c.restore();
|
||||
};
|
||||
const pill=(c,x,y,w,h,muted=false,opacity=1)=>{
|
||||
c.save();c.translate(x+w/2,y+h/2);c.rotate(angle);c.translate(-w/2,-h/2);
|
||||
c.globalAlpha=muted?opacity:1;
|
||||
if(muted){const g=c.createLinearGradient(0,0,w,h);g.addColorStop(0,shade(palette.accent,.22));g.addColorStop(1,shade(palette.accent,.4));c.fillStyle=g;}else c.fillStyle=palette.accent;
|
||||
rounded(c,0,0,w,h,h/2);c.fill();c.restore();
|
||||
};
|
||||
const line=(c,x,y,w,h)=>{c.fillStyle=palette.accent;rounded(c,x,y,w,h,h/2);c.fill();};
|
||||
const result=[];
|
||||
// Only these four Steam custom-artwork enums are supported. A shortcut
|
||||
// icon goes through SetShortcutIcon, never SetCustomArtworkForApp(type=4).
|
||||
for(const [kind,w,h] of [[0,600,900],[1,3840,1240],[2,1280,360],[3,920,430]]){
|
||||
const image=canvas(w,h),c=image.getContext('2d');
|
||||
if(kind===0){base(c,w,h);pill(c,-205+offset,30,540,116);pill(c,206,-90+offset,425,98,true,.42);pill(c,-132,684,310,100);icon(c,315,676,490,-16,.5);icon(c,155,146,290);title(c,300,551,65,490,true);line(c,265,592,70,7);}
|
||||
if(kind===3){base(c,w,h);pill(c,-100+offset,-24,405,80);pill(c,700,-30+offset,355,100,true,.35);pill(c,692,350,350,104);pill(c,570,410,286,49,true,.55);icon(c,76,117,196);title(c,323,243,62,490);line(c,326,282,62,6);}
|
||||
// Frame currently displays the hero at ~4.17:1 using object-fit:cover.
|
||||
// Keep the main motifs within the central 75% height, rather than
|
||||
// designing solely against the uncropped 3840x1240 export.
|
||||
if(kind===1){base(c,w,h);pill(c,-520,-140,1410,240);pill(c,1650+offset*2,400,620,150,true,.43);pill(c,2540,680,720,190);pill(c,2140,780,520,100,true,.5);pill(c,3300,450,700,160,true,.32);}
|
||||
if(kind===2){icon(c,16,24,312);title(c,382,232,136,866);}
|
||||
result.push({kind,data:image.toDataURL('image/png').split(',')[1],canvas:image});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
async function(p) {
|
||||
const executable = d => d.strShortcutExe?.replace(/^"|"$/g, '').replace('/./', '/');
|
||||
const details = id => new Promise((resolve, reject) => {
|
||||
let sub, settled = false;
|
||||
const timer = setTimeout(() => { sub?.unregister(); reject(Error('Details timed out')); }, 3000);
|
||||
sub = SteamClient.Apps.RegisterForAppDetails(id, d => { settled = true; clearTimeout(timer); sub?.unregister(); resolve(d); });
|
||||
// Steam may invoke a cached callback synchronously.
|
||||
if (settled) sub?.unregister();
|
||||
});
|
||||
let apps, app;
|
||||
for (let n = 0; n < 10; n++) {
|
||||
apps = Array.from(appStore.m_mapApps.values());
|
||||
const matches = apps.filter(a => a.display_name === 'Devkit Game: ' + p.game);
|
||||
app = matches.length === 1 ? matches[0] : apps.find(a => a.appid === p.previous);
|
||||
if (app) break;
|
||||
if (n < 9) await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
if (app) {
|
||||
if (app.appid < 2147483648) throw Error('Missing owned shortcut');
|
||||
const d = await details(app.appid);
|
||||
if (executable(d) !== p.exe) throw Error('Shortcut target does not match owned APK');
|
||||
return { id: app.appid };
|
||||
}
|
||||
// A previous attempt may have renamed the entry before persisting its ID.
|
||||
// Recover by exact executable ownership, never by a user-visible app name.
|
||||
const shortcuts = apps.filter(a => a.appid >= 2147483648);
|
||||
if (shortcuts.length > 256) throw Error('Too many shortcuts to recover owned entry');
|
||||
const rows = await Promise.allSettled(shortcuts.map(async a => ({ id: a.appid, details: await details(a.appid) })));
|
||||
const owned = rows.filter(r => r.status === 'fulfilled' && executable(r.value.details) === p.exe);
|
||||
if (owned.length !== 1) throw Error('Missing or ambiguous owned shortcut');
|
||||
return { id: owned[0].value.id };
|
||||
}
|
||||
@@ -210,7 +210,7 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
|
||||
r.steam_binding = Some(Binding {
|
||||
game_id: game.clone(),
|
||||
native: true,
|
||||
revision: revision(a),
|
||||
revision: revision(a, r),
|
||||
});
|
||||
rpc(home, "create-shortcut", Some(&game))?;
|
||||
let v: Value = serde_json::from_str(&rpc(home, "list-shortcuts", None)?)?;
|
||||
@@ -230,15 +230,16 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
|
||||
r.steam_app_id = Some(super::steam_ui::configure(
|
||||
&game,
|
||||
r.steam_app_id,
|
||||
&a.metadata.name,
|
||||
r.steam_name.as_deref().unwrap_or(&a.metadata.name),
|
||||
icon.as_deref(),
|
||||
&launch,
|
||||
&native_apk(home, &a.id),
|
||||
&a.metadata.package,
|
||||
)?);
|
||||
r.steam_binding = Some(Binding {
|
||||
game_id: game,
|
||||
native: true,
|
||||
revision: revision(a),
|
||||
revision: revision(a, r),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -270,7 +271,9 @@ pub(super) fn unregister(home: &Path, r: &Record) -> Result<()> {
|
||||
"icon.png",
|
||||
"icon.webp",
|
||||
"icon.jpg",
|
||||
"steam-icon.png",
|
||||
"lepton-show-flatscreen",
|
||||
".framely-artwork.json",
|
||||
] {
|
||||
let file = p.parent().unwrap().join(name);
|
||||
if file.exists() {
|
||||
@@ -285,8 +288,38 @@ pub(super) fn unregister(home: &Path, r: &Record) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn revision(a: &App) -> String {
|
||||
hash(&json!({"metadata":a.metadata,"showWindow":a.show_window}).to_string())
|
||||
pub(super) fn parse_name(value: &Value) -> Result<Option<String>> {
|
||||
let name = value.as_str().context("Invalid Steam display name")?.trim();
|
||||
ensure!(
|
||||
name.chars().count() <= 128 && !name.chars().any(char::is_control),
|
||||
"Steam display name must be at most 128 characters without control characters"
|
||||
);
|
||||
Ok((!name.is_empty()).then(|| name.to_owned()))
|
||||
}
|
||||
fn revision(a: &App, r: &Record) -> String {
|
||||
let mut value = json!({"metadata":a.metadata,"showWindow":a.show_window,"artworkVersion":2});
|
||||
if let Some(name) = &r.steam_name {
|
||||
value["steamName"] = json!(name);
|
||||
}
|
||||
hash(&value.to_string())
|
||||
}
|
||||
pub(super) fn update_name(home: &Path, a: &App, r: &mut Record) -> Result<()> {
|
||||
let parameters = json!({"game":game_id(&a.id),"previous":r.steam_app_id,"exe":native_apk(home,&a.id),"name":r.steam_name.as_deref().unwrap_or(&a.metadata.name)});
|
||||
let result = steam_ui::evaluate(&format!(
|
||||
"(async()=>{{const p={parameters};const owned=await ({lookup})(p);SteamClient.Apps.SetShortcutName(owned.id,p.name);return true;}})()",
|
||||
lookup = include_str!("steam_shortcut_lookup.js")
|
||||
))?;
|
||||
ensure!(
|
||||
result == json!(true),
|
||||
"Steam name update was not acknowledged"
|
||||
);
|
||||
// The next synchronization redraws only our generated titles; custom user
|
||||
// artwork is preserved by its content hash. Do not mark stale artwork fresh.
|
||||
let current_revision = format!("artwork-pending:{}", revision(a, r));
|
||||
if let Some(binding) = &mut r.steam_binding {
|
||||
binding.revision = current_revision;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn icon_file(dir: &Path, metadata: &Metadata) -> Result<Option<PathBuf>> {
|
||||
use base64::Engine;
|
||||
@@ -369,7 +402,7 @@ pub(super) fn ensure_registered(home: &Path, id: &str, force: bool) -> Result<bo
|
||||
&& rec
|
||||
.steam_binding
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.native && b.revision == revision(&a))
|
||||
.is_some_and(|b| b.native && b.revision == revision(&a, rec))
|
||||
{
|
||||
save(home, &db)?;
|
||||
return Ok(true);
|
||||
@@ -421,7 +454,7 @@ pub(super) fn synchronize(home: &Path) -> Result<()> {
|
||||
&& linked(home, r)
|
||||
&& r.steam_binding
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.native && b.revision == revision(&a))
|
||||
.is_some_and(|b| b.native && b.revision == revision(&a, r))
|
||||
})
|
||||
{
|
||||
continue;
|
||||
|
||||
+164
-38
@@ -54,7 +54,8 @@ pub(crate) fn evaluate(expression: &str) -> Result<Value> {
|
||||
&SocketAddr::from(([127, 0, 0, 1], 8080)),
|
||||
Duration::from_secs(3),
|
||||
)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(6)))?;
|
||||
// Artwork renders and hashes a 3840x1240 hero inside Steam's CEF context.
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(3)))?;
|
||||
let (mut ws, _) = client(address.as_str(), stream)
|
||||
.map_err(|_| anyhow::anyhow!("Cannot connect to Steam UI"))?;
|
||||
@@ -70,7 +71,7 @@ pub(crate) fn evaluate(expression: &str) -> Result<Value> {
|
||||
.to_string()
|
||||
.into(),
|
||||
))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
ensure!(Instant::now() < deadline, "Steam UI request timed out");
|
||||
let message = ws
|
||||
@@ -98,53 +99,140 @@ pub(super) fn configure(
|
||||
icon: Option<&Path>,
|
||||
launch: &str,
|
||||
executable: &Path,
|
||||
package: &str,
|
||||
) -> Result<u32> {
|
||||
let art = artwork(icon)?;
|
||||
use base64::Engine;
|
||||
let legacy = legacy_artwork(icon)?;
|
||||
let image = icon
|
||||
.map(|path| -> Result<String> {
|
||||
ensure!(
|
||||
fs::metadata(path)?.len() <= 8 * 1024 * 1024,
|
||||
"APK icon is too large"
|
||||
);
|
||||
Ok(format!(
|
||||
"data:image/png;base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(fs::read(path)?)
|
||||
))
|
||||
})
|
||||
.transpose()?;
|
||||
let ownership = icon
|
||||
.map(|p| load_artwork_ownership(p.parent().context("Missing artwork directory")?))
|
||||
.transpose()?
|
||||
.unwrap_or(Value::Null);
|
||||
let shortcut_icon = icon.map(shortcut_icon_file).transpose()?;
|
||||
let parameters = json!({"game":game,"previous":previous,"name":name,
|
||||
"icon":icon,"launch":launch,"exe":executable,"art":art});
|
||||
"icon":shortcut_icon,"launch":launch,"exe":executable,"image":image,"package":package,
|
||||
"ownership":ownership,"legacy":legacy});
|
||||
let expression = format!(
|
||||
r#"(async()=>{{
|
||||
const p={parameters};
|
||||
let app;
|
||||
for(let n=0;n<10;n++){{
|
||||
const apps=Array.from(appStore.m_mapApps.values());
|
||||
const matches=apps.filter(a=>a.display_name==='Devkit Game: '+p.game);
|
||||
app=matches.length===1?matches[0]:apps.find(a=>a.appid===p.previous);
|
||||
if(app) break;
|
||||
await new Promise(r=>setTimeout(r,100));
|
||||
}}
|
||||
if(!app || app.appid<2147483648) throw Error('Missing owned shortcut');
|
||||
const id=app.appid;
|
||||
let sub;
|
||||
const details=await new Promise((resolve,reject)=>{{
|
||||
let timer=setTimeout(()=>{{sub?.unregister();reject(Error('Details timed out'))}},3000);
|
||||
sub=SteamClient.Apps.RegisterForAppDetails(id,d=>{{clearTimeout(timer);sub?.unregister();resolve(d)}});
|
||||
}});
|
||||
sub?.unregister();
|
||||
const exe=details.strShortcutExe?.replace(/^"|"$/g,'').replace('/./','/');
|
||||
if(exe!==p.exe) throw Error('Shortcut target does not match owned APK');
|
||||
const owned=await ({lookup})(p);
|
||||
const id=owned.id;
|
||||
SteamClient.Apps.SetShortcutLaunchOptions(id,p.launch);
|
||||
SteamClient.Apps.SetShortcutName(id,p.name);
|
||||
if(p.icon) SteamClient.Apps.SetShortcutIcon(id,p.icon);
|
||||
for(const a of p.art){{
|
||||
try{{await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind)}}
|
||||
catch(e){{
|
||||
await new Promise(r=>setTimeout(r,200));
|
||||
await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind);
|
||||
}}
|
||||
}}
|
||||
return id;
|
||||
}})()"#
|
||||
// Existing shortcuts may have a user-selected icon path.
|
||||
if(p.icon&&!p.previous) SteamClient.Apps.SetShortcutIcon(id,p.icon);
|
||||
const ownership=p.image?await ({apply})(p,id,({render}),({palette})):null;
|
||||
return {{id,ownership}};
|
||||
}})()"#,
|
||||
lookup = include_str!("steam_shortcut_lookup.js"),
|
||||
apply = include_str!("steam_artwork_apply.js"),
|
||||
render = include_str!("steam_artwork_render.js"),
|
||||
palette = include_str!("steam_artwork_palette.js"),
|
||||
);
|
||||
let result = evaluate(&expression)?;
|
||||
result
|
||||
// Numeric mock responses remain supported by the existing IPC fixtures.
|
||||
let id = result
|
||||
.get("id")
|
||||
.unwrap_or(&result)
|
||||
.as_u64()
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.filter(|v| *v >= 0x80000000)
|
||||
.context("Invalid Steam APK App ID")
|
||||
.context("Invalid Steam APK App ID")?;
|
||||
if let (Some(path), Some(ownership)) = (icon, result.get("ownership").filter(|v| !v.is_null()))
|
||||
{
|
||||
validate_artwork_ownership(ownership)?;
|
||||
ensure!(
|
||||
ownership["appId"] == id,
|
||||
"Artwork ownership does not match shortcut"
|
||||
);
|
||||
super::steam_shortcuts::write_owned(
|
||||
&path
|
||||
.parent()
|
||||
.context("Missing artwork directory")?
|
||||
.join(".framely-artwork.json"),
|
||||
&serde_json::to_vec(ownership)?,
|
||||
0o600,
|
||||
)?;
|
||||
ensure!(
|
||||
ownership["failed"] != true,
|
||||
"Steam artwork update was interrupted; completed images were recorded and will be retried"
|
||||
);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn artwork(icon: Option<&Path>) -> Result<Vec<Value>> {
|
||||
fn validate_artwork_ownership(value: &Value) -> Result<()> {
|
||||
ensure!(
|
||||
value["version"] == 1
|
||||
&& value["appId"]
|
||||
.as_u64()
|
||||
.is_some_and(|id| (0x80000000..=u32::MAX as u64).contains(&id)),
|
||||
"Invalid artwork ownership version or app ID"
|
||||
);
|
||||
let hashes = value["hashes"]
|
||||
.as_object()
|
||||
.context("Invalid artwork ownership hashes")?;
|
||||
ensure!(
|
||||
hashes.len() <= 5
|
||||
&& hashes.iter().all(|(kind, hash)| matches!(
|
||||
kind.as_str(),
|
||||
"0" | "1" | "2" | "3" | "4"
|
||||
) && hash
|
||||
.as_str()
|
||||
.is_some_and(|h| h.len() == 64 && h.bytes().all(|c| c.is_ascii_hexdigit()))),
|
||||
"Invalid artwork ownership hash"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shortcut_icon_file(source: &Path) -> Result<PathBuf> {
|
||||
let picture = image::open(source)?.thumbnail(256, 256).to_rgba8();
|
||||
let mut icon = image::RgbaImage::new(256, 256);
|
||||
image::imageops::overlay(
|
||||
&mut icon,
|
||||
&picture,
|
||||
((256 - picture.width()) / 2) as i64,
|
||||
((256 - picture.height()) / 2) as i64,
|
||||
);
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(icon).write_to(&mut bytes, image::ImageFormat::Png)?;
|
||||
let path = source
|
||||
.parent()
|
||||
.context("Missing icon directory")?
|
||||
.join("steam-icon.png");
|
||||
super::steam_shortcuts::write_owned(&path, &bytes.into_inner(), 0o600)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn load_artwork_ownership(dir: &Path) -> Result<Value> {
|
||||
let path = dir.join(".framely-artwork.json");
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Value::Null),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
ensure!(
|
||||
metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() <= 8192,
|
||||
"Invalid artwork ownership file"
|
||||
);
|
||||
let value: Value = serde_json::from_slice(&fs::read(path)?)?;
|
||||
validate_artwork_ownership(&value)?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
// Kept solely to recognize/migrate exact artwork made by earlier releases.
|
||||
fn legacy_artwork(icon: Option<&Path>) -> Result<Vec<Value>> {
|
||||
use base64::Engine;
|
||||
let Some(path) = icon else { return Ok(vec![]) };
|
||||
let image = image::open(path)?.to_rgba8();
|
||||
@@ -186,7 +274,7 @@ pub(super) fn clear_artwork(app_id: u32, executable: &Path) -> Result<()> {
|
||||
sub?.unregister();
|
||||
const exe=details.strShortcutExe?.replace(/^"|"$/g,'').replace('/./','/');
|
||||
if(exe!==p.exe) throw Error('Shortcut target does not match owned APK');
|
||||
for(const kind of [0,1,3,4]) await SteamClient.Apps.ClearCustomArtworkForApp(p.id,kind);
|
||||
for(const kind of [0,1,2,3]) await SteamClient.Apps.ClearCustomArtworkForApp(p.id,kind);
|
||||
await SteamClient.Apps.SetShortcutIcon(p.id,'');
|
||||
}})()"#
|
||||
))?;
|
||||
@@ -197,6 +285,22 @@ pub(super) fn clear_artwork(app_id: u32, executable: &Path) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn shortcut_icon_has_its_own_256px_file_and_preserves_source() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let source = t.path().join("icon.png");
|
||||
image::RgbaImage::from_pixel(128, 64, image::Rgba([230, 30, 20, 255]))
|
||||
.save(&source)
|
||||
.unwrap();
|
||||
let before = fs::read(&source).unwrap();
|
||||
let path = shortcut_icon_file(&source).unwrap();
|
||||
assert_eq!(path.file_name().unwrap(), "steam-icon.png");
|
||||
let picture = image::open(path).unwrap().to_rgba8();
|
||||
assert_eq!(picture.dimensions(), (256, 256));
|
||||
assert_eq!(picture.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(picture.get_pixel(128, 128).0, [230, 30, 20, 255]);
|
||||
assert_eq!(fs::read(source).unwrap(), before);
|
||||
}
|
||||
#[test]
|
||||
fn steam_artwork_has_matching_aspect_ratios_and_never_crops_icon() {
|
||||
use base64::Engine;
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
@@ -204,7 +308,7 @@ mod tests {
|
||||
image::RgbaImage::from_pixel(128, 64, image::Rgba([230, 30, 20, 255]))
|
||||
.save(&p)
|
||||
.unwrap();
|
||||
let art = artwork(Some(&p)).unwrap();
|
||||
let art = legacy_artwork(Some(&p)).unwrap();
|
||||
assert_eq!(art.len(), 4);
|
||||
for (a, expected) in art
|
||||
.iter()
|
||||
@@ -220,6 +324,28 @@ mod tests {
|
||||
[230, 30, 20, 255]
|
||||
);
|
||||
}
|
||||
assert!(artwork(None).unwrap().is_empty());
|
||||
assert!(legacy_artwork(None).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artwork_ownership_is_bounded_and_never_follows_symlinks() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
assert!(load_artwork_ownership(t.path()).unwrap().is_null());
|
||||
let valid = json!({"version":1,"appId":0x92345678u32,"hashes":{"0":"a".repeat(64)}});
|
||||
let file = t.path().join(".framely-artwork.json");
|
||||
fs::write(&file, serde_json::to_vec(&valid).unwrap()).unwrap();
|
||||
assert_eq!(load_artwork_ownership(t.path()).unwrap(), valid);
|
||||
for invalid in [
|
||||
json!({}),
|
||||
json!({"version":1,"appId":42,"hashes":{}}),
|
||||
json!({"version":1,"appId":0x92345678u32,"hashes":{"5":"a".repeat(64)}}),
|
||||
json!({"version":1,"appId":0x92345678u32,"hashes":{"0":"not-a-hash"}}),
|
||||
] {
|
||||
fs::write(&file, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(load_artwork_ownership(t.path()).is_err());
|
||||
}
|
||||
fs::remove_file(&file).unwrap();
|
||||
std::os::unix::fs::symlink(t.path().join("other"), &file).unwrap();
|
||||
assert!(load_artwork_ownership(t.path()).is_err());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 264 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user