mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 13:01:25 +02:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f9471c3e4 | ||
|
|
1b847f9305 | ||
|
|
d229ad2b09 | ||
|
|
69831e474e | ||
|
|
a078e8bfb1 | ||
|
|
926ee38b96 | ||
|
|
1e62eefb85 | ||
|
|
d5a039dad9 | ||
|
|
fd3666f931 | ||
|
|
76eba81eff | ||
|
|
3427786053 | ||
|
|
2e5496a2bd | ||
|
|
cdb24fd31d | ||
|
|
3039c9ef0e | ||
|
|
41137e1d26 | ||
|
|
d3628b0bcf | ||
|
|
a9d61df041 | ||
|
|
c647aa28a0 | ||
|
|
6cb052f732 | ||
|
|
33941cd3f4 | ||
|
|
1291925884 | ||
|
|
ae35a7c487 | ||
|
|
2a5e77a1c1 |
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
|
||||
@@ -54,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
|
||||
@@ -66,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"
|
||||
@@ -99,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
|
||||
@@ -108,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"
|
||||
@@ -20,3 +20,6 @@
|
||||
/media/bin/
|
||||
|
||||
/media/source/
|
||||
|
||||
# Local agent operating notes
|
||||
/AGENTS.md
|
||||
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.7.0-preview.2"
|
||||
version = "0.8.2-preview.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.7.0-preview.2"
|
||||
version = "0.8.2-preview.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -93,8 +93,8 @@ python3 -m unittest discover -s tests -p 'test_*.py'
|
||||
|
||||
See [release maintenance](docs/releases.md) for CEF builds, integration tests, and publishing commands. Plugins support `steamos` and `root`; hash verification is not a security review, so check the source and runtime user.
|
||||
|
||||
## License
|
||||
## License and acknowledgments
|
||||
|
||||
Original Framely code, including the core, installer, SDK, template, and repository examples, uses **AGPL-3.0-only**; see [LICENSE](LICENSE). Third-party dependencies and vendored files retain their respective licenses.
|
||||
Framely is licensed under **AGPL-3.0-only**; see [LICENSE](LICENSE). Third-party projects retain their own licenses.
|
||||
|
||||
AGPL allows commercial use. Distribution requires complete corresponding source under the license; modified software that interacts with users over a network must also offer source to those users. Release source corresponds to the repository's `v<VERSION>` or `installer-v<VERSION>` tags. Redistributors must provide modifications and build scripts matching their binaries.
|
||||
Thanks to Lepton for the Android runtime, [OVRPort](https://github.com/ovrport/app) for Quest APK conversion, and [FramePort's FrameBridge](https://github.com/spoopyghosty0/frameport) for OpenXR graphics and controller support. The optional q2f package includes the OVRPort and FrameBridge licenses and corresponding source.
|
||||
+3
-3
@@ -93,8 +93,8 @@ python3 -m unittest discover -s tests -p 'test_*.py'
|
||||
|
||||
CEF 构建、集成测试和发布命令见[发行维护](docs/zh-CN/releases.md)。插件支持 `steamos` 和 `root`;哈希校验不代表安全审核,应核对来源和运行用户。
|
||||
|
||||
## 许可证
|
||||
## 许可证与致谢
|
||||
|
||||
Framely 原创代码,包括本体、安装器、SDK、模板和仓库示例,采用 **AGPL-3.0-only**,全文见 [LICENSE](LICENSE)。第三方依赖和 vendored 文件保留各自许可证。
|
||||
Framely 采用 **AGPL-3.0-only**,详见 [LICENSE](LICENSE)。第三方项目保留各自的许可证。
|
||||
|
||||
AGPL 允许商业使用;分发需按许可提供完整对应源码,修改后通过网络与用户交互还须向这些用户提供源码。发行源码对应本仓库的 `v<版本>` 或 `installer-v<版本>` 标签;重新分发者须提供与自己二进制匹配的修改和构建脚本。
|
||||
感谢 Lepton 提供安卓运行环境、[OVRPort](https://github.com/ovrport/app) 提供 Quest APK 转换工具,以及 [FramePort 的 FrameBridge](https://github.com/spoopyghosty0/frameport) 提供 OpenXR 画面与手柄适配。可选 q2f 包附有 OVRPort 和 FrameBridge 的许可证及对应源码。
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
## 采集与网页观看
|
||||
|
||||
“网页观看”和快捷面板的串流卡片均有 **开始/停止录制** 按钮,状态同步。录制复用当前原始 H.264/H.265 + 可选 Opus 串流,不进行二次编码,文件保存到 Steam 用户的 `Videos/framely`(头显默认 `/home/steamos/Videos/framely`),使用唯一文件名和 MKV 容器,不覆盖已有视频。未串流时开始录制会先启动串流;停止录制不停止串流,停止串流会先结束录制并写入文件尾。调整取景前必须停止录制。
|
||||
|
||||
录制期间头显右上角显示红点、计时和停止图标。指向指示器并点击可停止当前录制;显示指示器不请求场景输入焦点或开启全局激光模式。录制进程异常退出时取消指示并在面板显示错误,已经写入的视频保留。正常关闭会话时尝试完成文件收尾;突然断电无法保证最后一段数据完整。
|
||||
|
||||
**屏幕采集**读取头显实际扫描输出,包括透视、Steam 菜单和空间窗口。核心 root 服务只启动固定的 `framely-panel-grab` 助手,通过受 Steam 用户身份检查的私人 Unix socket 导出 DRM framebuffer 的 DMA-BUF。其余采集、Vulkan 畸变校正、取景及 Iris H.264/H.265 编码均以 Steam 用户运行;不设置应用 capability,也不依赖 Framecorder 插件。
|
||||
|
||||
Framecorder 原插件的名称是“面板画面”和“SteamVR 备用画面”(CLI 为 `panel` / `headset`)。Framely 使用“屏幕采集”和“SteamVR”,避免把参考实现的应用名当作采集方式。
|
||||
@@ -16,6 +20,8 @@ Framecorder 原插件的名称是“面板画面”和“SteamVR 备用画面”
|
||||
|
||||
网页优先协商用户选择的 H.264 或 H.265。当前官方 CEF 运行包不能解码所选编码时,头显自带 FFmpeg 以 libvpx 提供 VP8 + Opus 兼容输出;所有需要 VP8 的观看者共享一个额外的软件编码进程。采集来源不变。支持所选编码的观看者直接接收原串流,DLNA 仍使用 H.264。VP8 增加 CPU 消耗;兼容观看结束后收回该进程。Framely 不安装、调用或依赖外部 Chrome。
|
||||
|
||||
在“在其他设备上观看”中开启 **免验证**,显示和复制的观看地址会附带随机凭证。持有该链接的人无需管理密码即可观看,但不能访问管理面板、文件或终端。链接在停止/重新开始串流及重启后仍有效;关闭开关会撤销链接并断开免验证观看,已登录观看不受影响。再次开启生成新链接。网络访问总开关仍需开启。
|
||||
|
||||
声音使用小片段和独立的有界输入队列,避免音频阻塞拖慢视频;编码使用无 B 帧和低延迟参数。SteamVR 的 H.265 是软件编码,会增加 CPU 负载;屏幕采集使用 Iris 硬件编码。
|
||||
|
||||
## 接收与发送
|
||||
|
||||
+4
-4
@@ -38,7 +38,7 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven
|
||||
|
||||
Device and installer share the repository with independent versions/tags/workflows:
|
||||
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.7.0-preview.2`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.7.1`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.10`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
|
||||
|
||||
Versions need not match or ship together. Update the relevant Cargo.lock with version changes. Installer package/macOS versions use the installer version.
|
||||
@@ -47,8 +47,8 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
|
||||
|
||||
```bash
|
||||
# After committing and pushing the intended source:
|
||||
git tag v0.7.0-preview.2
|
||||
git push origin v0.7.0-preview.2
|
||||
git tag v0.7.1
|
||||
git push origin v0.7.1
|
||||
# Independent installer release:
|
||||
git tag installer-v0.4.1-preview.10
|
||||
git push origin installer-v0.4.1-preview.10
|
||||
@@ -58,7 +58,7 @@ Prerelease suffixes create GitHub Prereleases without Latest. Installer releases
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,13 +42,13 @@ 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.7.0-preview.2`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.7.1`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.10`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
|
||||
|
||||
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
|
||||
@@ -59,8 +59,8 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
|
||||
|
||||
```bash
|
||||
# 提交并推送代码后,选择需要发布的产品
|
||||
git tag v0.7.0-preview.2
|
||||
git push origin v0.7.0-preview.2
|
||||
git tag v0.7.1
|
||||
git push origin v0.7.1
|
||||
|
||||
# 独立发布安装器,不触发 Framely 构建
|
||||
git tag installer-v0.4.1-preview.10
|
||||
@@ -71,7 +71,7 @@ git push origin installer-v0.4.1-preview.10
|
||||
|
||||
只有标签触发会发布。各工作流在自己的构建全部成功后创建草稿 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 快捷方式;不支持的包格式会明确拒绝。
|
||||
|
||||
### 平面窗口方向
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use framely_capture::openvr::OpenVr;
|
||||
use crate::panel_geometry::PanelGeometry;
|
||||
|
||||
/// Output pixels between grid points. The mapping is smooth, 8 is plenty.
|
||||
const STEP: u32 = 8;
|
||||
@@ -113,6 +114,7 @@ struct EyeModel {
|
||||
t: f64,
|
||||
b: f64,
|
||||
roll: f64,
|
||||
panel_geometry: Option<PanelGeometry>,
|
||||
/// Panel -> render target, sampled on a grid, per channel.
|
||||
forward: Vec<[[f64; 2]; 3]>,
|
||||
/// Render target spots the compositor never draws.
|
||||
@@ -125,6 +127,20 @@ impl EyeModel {
|
||||
fn new(vr: &OpenVr, eye: usize) -> Result<Self> {
|
||||
let proj = vr.projection(eye);
|
||||
let roll = vr.eye_roll(eye);
|
||||
// Parallel render cameras report identity even though the physical
|
||||
// Frame panels are clocked. Do not apply both transforms twice.
|
||||
let panel_geometry = if roll.abs() < 1e-5 {
|
||||
match PanelGeometry::load(eye) {
|
||||
Ok(geometry) => {
|
||||
log::info!("eye {eye}: experimental factory panel orientation correction enabled");
|
||||
Some(geometry)
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("eye {eye}: factory panel orientation unavailable, retaining OpenVR mapping: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else { None };
|
||||
log::info!(
|
||||
"eye {eye} projection: {proj:?}, render target {:?}, roll {:.2}°",
|
||||
vr.render_target_size(),
|
||||
@@ -139,13 +155,21 @@ impl EyeModel {
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let (u, v) = (i as f32 / (n - 1) as f32, j as f32 / (n - 1) as f32);
|
||||
if let Some(d) = vr.distortion(eye, u, v) {
|
||||
forward[j * n + i] = d.map(|c| [c[0] as f64, c[1] as f64]);
|
||||
let (du, dv) = if panel_geometry.is_some() { (1. - u, 1. - v) } else { (u, v) };
|
||||
if let Some(d) = vr.distortion(eye, du, dv) {
|
||||
forward[j * n + i] = d.map(|c| if panel_geometry.is_some() {
|
||||
[1. - c[0] as f64, 1. - c[1] as f64]
|
||||
} else { [c[0] as f64, c[1] as f64] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let triangles = vr.hidden_area(eye);
|
||||
let mut triangles = vr.hidden_area(eye);
|
||||
if panel_geometry.is_some() {
|
||||
for triangle in &mut triangles {
|
||||
for point in triangle { *point = [1. - point[0], 1. - point[1]]; }
|
||||
}
|
||||
}
|
||||
let hidden = rasterize(&triangles);
|
||||
if log::log_enabled!(log::Level::Debug) {
|
||||
let rows: Vec<String> = (0..32)
|
||||
@@ -168,6 +192,7 @@ impl EyeModel {
|
||||
t: proj.top as f64,
|
||||
b: proj.bottom as f64,
|
||||
roll,
|
||||
panel_geometry,
|
||||
forward,
|
||||
hidden,
|
||||
covered,
|
||||
@@ -177,8 +202,12 @@ impl EyeModel {
|
||||
/// Render target UV for a view direction given as tangents, with the
|
||||
/// eye's roll undone so the video stays level with the head.
|
||||
fn target(&self, tx: f64, ty: f64) -> [f64; 2] {
|
||||
let (s, c) = (-self.roll).sin_cos();
|
||||
let (rx, ry) = (tx * c - ty * s, tx * s + ty * c);
|
||||
let [rx, ry] = if let Some(geometry) = self.panel_geometry {
|
||||
geometry.tangents(tx, ty)
|
||||
} else {
|
||||
let (s, c) = (-self.roll).sin_cos();
|
||||
[tx * c - ty * s, tx * s + ty * c]
|
||||
};
|
||||
[(rx - self.l) / (self.r - self.l), (ry - self.t) / (self.b - self.t)]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ mod encoder;
|
||||
mod gpu;
|
||||
mod kms;
|
||||
mod lut;
|
||||
mod panel_geometry;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 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],
|
||||
}
|
||||
|
||||
impl PanelGeometry {
|
||||
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)
|
||||
}
|
||||
|
||||
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");
|
||||
Ok(Self { eye_to_head: rotation })
|
||||
}
|
||||
|
||||
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}]}).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]);
|
||||
}
|
||||
|
||||
#[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_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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
-15
@@ -31,6 +31,7 @@
|
||||
#include "brand_mask.h"
|
||||
#include "ui_visibility.h"
|
||||
#include "ui_feedback.h"
|
||||
#include "recording_indicator.h"
|
||||
#include "plugin_navigation.h"
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glx.h>
|
||||
@@ -108,6 +109,7 @@ 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;
|
||||
@@ -133,6 +135,7 @@ struct View {
|
||||
vr::VROverlayHandle_t overlay=0,thumbnail=0;GLuint textures[2]{};int texture_slot=0;bool texture_ready[2]{};PaintDamage texture_damage[2];std::vector<uint8_t> pixels;
|
||||
};
|
||||
static std::map<std::string,std::unique_ptr<View>> views;
|
||||
static 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;
|
||||
@@ -165,7 +168,11 @@ static std::vector<uint8_t> icon(bool hover,bool active){
|
||||
}
|
||||
|
||||
static std::vector<std::string> closed_casts;
|
||||
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();payload=json{{"views",visible_views},{"closedCasts",closed_casts}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
|
||||
// Protected by visibility_mutex (outgoing request) / queue_mutex (status).
|
||||
static std::string pending_recording_stop;
|
||||
static json recording_state=json::object();
|
||||
static 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;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();stop_id=pending_recording_stop;payload=json{{"views",visible_views},{"closedCasts",closed_casts},{"stopRecording",stop_id}}.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_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
|
||||
@@ -268,8 +275,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;if(v.key.rfind("framely.cast.",0)==0){std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu")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));}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.
|
||||
@@ -290,7 +314,13 @@ static View& create_view(const std::string& key,const std::string& title,const s
|
||||
// texture upload and launcher mipmap generation scale with the pixel count.
|
||||
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dashboard;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications")?2:1;
|
||||
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,false);overlays->SetOverlaySortOrder(v->overlay,0);
|
||||
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,key=="framely.apk.permission");overlays->SetOverlaySortOrder(v->overlay,key=="framely.apk.permission"?100:0);if(key=="framely.apk.permission")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true);
|
||||
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(dashboard){
|
||||
@@ -476,11 +506,12 @@ 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;
|
||||
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
|
||||
@@ -489,8 +520,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{};
|
||||
@@ -498,7 +529,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);
|
||||
@@ -532,9 +563,39 @@ 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){
|
||||
// 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);}
|
||||
@@ -552,9 +613,9 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
if(gaze_allowed&&gaze_active)launcher_outside_at={};
|
||||
if(launcher_open&&launcher_outside_at.time_since_epoch().count()&&now>=launcher_outside_at){launcher_outside_at={};if(!keyboard_open&&!launcher_dock_interaction())launcher_back();}
|
||||
}
|
||||
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="cast.window"){auto id=c.at("id").get<std::string>();auto key="framely.cast."+id;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}int width=c.value("width",640),height=c.value("height",240);if(width<160||width>1920||height<112||height>8192)continue;auto& v=create_view(key,"Framely casting",origin+"/cast/window/"+id,false,width,height,"",2.f);v.cast_frame.path=c.at("frame").get<std::string>();if(v.width!=width||v.height!=height){v.width=width;v.height=height;v.pixels.clear();v.texture_ready[0]=v.texture_ready[1]=false;v.host->was_resized(v.host);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v.overlay,&scale);}menu_open=false;launcher_open=false;}else if(kind=="cast.close"){auto key="framely.cast."+c.at("id").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="window.open"){hide_launcher(false);std::string plugLine 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=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="apk.permission.open"){auto key="framely.apk.permission";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto& v=create_view(key,"Android permission",origin+"/apk/permission",false,1000,560,"",.85f);anchor_permission(v);}else if(kind=="apk.permission.close"){if(views.count("framely.apk.permission"))close_view(*views.at("framely.apk.permission"));}else if(kind=="cast.window"){auto id=c.at("id").get<std::string>();auto key="framely.cast."+id;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}int width=c.value("width",640),height=c.value("height",240);if(width<160||width>1920||height<112||height>8192)continue;auto& v=create_view(key,"Framely casting",origin+"/cast/window/"+id,false,width,height,"",2.f);v.cast_frame.path=c.at("frame").get<std::string>();if(v.width!=width||v.height!=height){v.width=width;v.heighLine truncated
|
||||
}
|
||||
static void 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"&&v.key.rfind("framely.cast.",0)!=0)close_view(v);break;default:break;}}
|
||||
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;if(menu_open&&v.key!="menu"&&e.eventType==vr::VREvent_MouseButtonDown&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now()))menu_open=false;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key.rfind("framely.cast.",0)!=0&&v.key!="framely.apk.permission")close_view(v);break;default:break;}}
|
||||
|
||||
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];}
|
||||
@@ -593,10 +654,10 @@ int main(int argc,char** argv){
|
||||
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);DockIconTexture icon_texture;icon_texture.init();if(!icon_texture.submit(button,pixels)){icon_texture.destroy();overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayInputMethod(button,vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{dock_canvas_width,dock_canvas_height}};overlays->SetOverlayMouseScale(button,&scale);vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;mask.m_Primitive.m_Rectangle={dock_icon_left,0,128,128};if(overlays->SetOverlayIntersectionMask(button,&mask,1)){std::cerr<<"Dock input mask unavailable\n";overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayFlag(button,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(button,0);overlays->SetOverlayFlag(button,vr::VROverlayFlags_HideLaserIntersection,false);
|
||||
try{create_view("menu","Framely",initial,false,600,840);}catch(const std::exception& e){std::cerr<<e.what()<<"\n";running=0;}
|
||||
const char* steam_root=std::getenv("FRAMELY_STEAM_ROOT");if(!ui_audio.prepare(steam_root?steam_root:""))std::cerr<<"Steam UI sounds unavailable\n";
|
||||
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",false)}});last_feedback=feedback;}auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);recording_state=data.value("recording",json::object());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;{std::lock_guard<std::mutex> lock(queue_mutex);recording=recording_state;}recording_indicator.tick(overlays,recording.value("active",false)?recording.value("id",std::string{}):std::string{},recording.value("elapsedSeconds",uint64_t(0)));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;
|
||||
@@ -612,7 +673,7 @@ int main(int argc,char** argv){
|
||||
if(!main_overlay)main_overlay=active_main_window();
|
||||
if(main_overlay&&!overlays->GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose;
|
||||
if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);}
|
||||
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher"&&key.rfind("framely.cast.",0)!=0)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.
|
||||
@@ -660,8 +721,22 @@ 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"&&other.rfind("framely.cast.",0)!=0&&!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);}}
|
||||
while(overlays->PollNextOverlayEvent(v->overlay,&event,sizeof(event)))overlay_input(*v,event);
|
||||
if(!v->cast_frame.path.empty()){glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);if(v->cast_frame.update())v->dirty=true;}
|
||||
if(v->dirty&&!v->closing&&v->pixels.size()==size_t(v->width*v->render_scale)*size_t(v->height*v->render_scale)*4){
|
||||
@@ -737,5 +812,6 @@ 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();
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <cstdio>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
struct RecordingIndicatorClick {
|
||||
bool held=true,armed=false;
|
||||
bool update(bool down,bool inside,bool valid=true){
|
||||
if(!valid){held=true;armed=false;return false;}
|
||||
if(down&&!held)armed=inside;
|
||||
bool clicked=!down&&held&&armed&&inside;
|
||||
if(!down)armed=false;
|
||||
held=down;return clicked;
|
||||
}
|
||||
};
|
||||
|
||||
inline std::vector<unsigned char> recording_indicator_pixels(uint64_t seconds,bool hover){
|
||||
constexpr int w=320,h=80;std::vector<unsigned char> pixels(w*h*4);
|
||||
auto point=[&](int x,int y,int r,int g,int b,int a=255){if(x<0||x>=w||y<0||y>=h)return;auto i=(y*w+x)*4;pixels[i]=r;pixels[i+1]=g;pixels[i+2]=b;pixels[i+3]=a;};
|
||||
for(int y=0;y<h;y++)for(int x=0;x<w;x++){
|
||||
int dx=std::max(12-x,std::max(x-(w-13),0)),dy=std::max(12-y,std::max(y-(h-13),0));
|
||||
if(dx*dx+dy*dy<=144)point(x,y,hover?65:27,hover?45:27,hover?45:29,235);
|
||||
if((x-32)*(x-32)+(y-40)*(y-40)<=11*11)point(x,y,242,61,75);
|
||||
}
|
||||
static constexpr unsigned digits[]={0x7b6f,0x2492,0x73e7,0x73cf,0x5bc9,0x79cf,0x79ef,0x7249,0x7bef,0x7bcf};
|
||||
char text[32];seconds=std::min<uint64_t>(seconds,359999);std::snprintf(text,sizeof text,"%02llu:%02llu:%02llu",(unsigned long long)(seconds/3600),(unsigned long long)(seconds/60%60),(unsigned long long)(seconds%60));
|
||||
int x=70;for(char c:std::string(text)){
|
||||
unsigned glyph=c==':'?0x0410:digits[c-'0'];
|
||||
for(int row=0;row<5;row++)for(int col=0;col<3;col++)if(glyph&(1u<<(14-row*3-col)))for(int dy=0;dy<4;dy++)for(int dx=0;dx<4;dx++)point(x+col*4+dx,30+row*4+dy,245,245,245);
|
||||
x+=20;
|
||||
}
|
||||
for(int y=29;y<51;y++)for(int x=272;x<294;x++)point(x,y,245,245,245);
|
||||
return pixels;
|
||||
}
|
||||
|
||||
class RecordingIndicator {
|
||||
vr::IVROverlay* overlay=nullptr;vr::VROverlayHandle_t handle=0;
|
||||
std::string id;uint64_t rendered=~uint64_t(0);bool last_hover=false;std::array<RecordingIndicatorClick,2> clicks{};std::array<bool,2> hover{};
|
||||
public:
|
||||
void destroy(){if(handle&&overlay)overlay->DestroyOverlay(handle);handle=0;id.clear();hover={};}
|
||||
bool active()const{return handle&&!id.empty();}
|
||||
void cancel_input(){clicks={};hover={};}
|
||||
bool hovered(int hand)const{return hand>=0&&hand<2&&hover[hand];}
|
||||
std::string pointer(int hand,const vr::HmdMatrix34_t& pose,bool valid,bool down){
|
||||
if(!active()||hand<0||hand>=2)return {};
|
||||
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;
|
||||
for(int k=0;k<3;k++){ray.vSource.v[k]=pose.m[k][3];ray.vDirection.v[k]=-pose.m[k][2];}
|
||||
vr::VROverlayIntersectionResults_t hit{};
|
||||
hover[hand]=valid&&overlay->ComputeOverlayIntersection(handle,&ray,&hit)&&hit.vUVs.v[0]>=0&&hit.vUVs.v[0]<=1&&hit.vUVs.v[1]>=0&&hit.vUVs.v[1]<=1;
|
||||
return clicks[hand].update(down,hover[hand],valid)?id:std::string{};
|
||||
}
|
||||
void tick(vr::IVROverlay* overlays,const std::string& next,uint64_t seconds){
|
||||
overlay=overlays;if(next.empty()){destroy();return;}
|
||||
if(!handle){
|
||||
if(overlay->CreateOverlay("framely.recording","Framely recording — click to stop",&handle))return;
|
||||
overlay->SetOverlayWidthInMeters(handle,.32f);
|
||||
vr::HmdMatrix34_t transform={{{1,0,0,.50f},{0,1,0,.35f},{0,0,1,-1.f}}};
|
||||
overlay->SetOverlayTransformTrackedDeviceRelative(handle,vr::k_unTrackedDeviceIndex_Hmd,&transform);
|
||||
// Stay passive: never take input focus from a game or activate global lasers.
|
||||
overlay->SetOverlayInputMethod(handle,vr::VROverlayInputMethod_None);
|
||||
overlay->SetOverlayFlag(handle,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,false);
|
||||
overlay->SetOverlayFlag(handle,vr::VROverlayFlags_HideLaserIntersection,true);
|
||||
overlay->SetOverlayFlag(handle,vr::VROverlayFlags_VisibleInDashboard,true);
|
||||
rendered=~uint64_t(0);
|
||||
}
|
||||
if(id!=next){id=next;clicks={};hover={};}
|
||||
bool highlighted=hover[0]||hover[1];
|
||||
if(seconds!=rendered||highlighted!=last_hover){auto pixels=recording_indicator_pixels(seconds,highlighted);if(!overlay->SetOverlayRaw(handle,pixels.data(),320,80,4)){overlay->ShowOverlay(handle);rendered=seconds;last_hover=highlighted;}}
|
||||
}
|
||||
};
|
||||
@@ -18,6 +18,9 @@ export function installKeyboard(open:(params:{existing:string;password:boolean;m
|
||||
};
|
||||
(window as any).__framelyEditKeyboard=(edit:string)=>{
|
||||
if(!field?.isConnected)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;
|
||||
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}[];
|
||||
|
||||
+482
-16
@@ -24,6 +24,8 @@ mod cleanup;
|
||||
mod graphics;
|
||||
mod lifecycle;
|
||||
mod native;
|
||||
pub(crate) mod permissions;
|
||||
mod q2f;
|
||||
mod steam_bridge;
|
||||
mod steam_shortcuts;
|
||||
pub(crate) mod steam_ui;
|
||||
@@ -49,6 +51,8 @@ pub(crate) static TEST_SERIAL: Mutex<()> = Mutex::new(());
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Record {
|
||||
#[serde(default)]
|
||||
steam_name: Option<String>,
|
||||
#[serde(default)]
|
||||
steam_launch: bool,
|
||||
#[serde(default)]
|
||||
@@ -81,6 +85,8 @@ struct Record {
|
||||
orientation: Option<String>,
|
||||
#[serde(default)]
|
||||
framebuffer_compatibility: bool,
|
||||
#[serde(default)]
|
||||
q2f_version: Option<String>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Database {
|
||||
@@ -107,6 +113,45 @@ struct Database {
|
||||
#[serde(default)]
|
||||
records: BTreeMap<String, Record>,
|
||||
}
|
||||
#[derive(Default, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StorageLocations {
|
||||
#[serde(default)]
|
||||
apk: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
contexts: Option<PathBuf>,
|
||||
}
|
||||
fn storage_config_path(home: &Path) -> PathBuf {
|
||||
home.join(".local/share/framely/storage.json")
|
||||
}
|
||||
fn storage_journal_path(home: &Path) -> PathBuf {
|
||||
home.join(".local/share/framely/storage-migration.json")
|
||||
}
|
||||
fn storage_config(home: &Path) -> Result<StorageLocations> {
|
||||
let path = storage_config_path(home);
|
||||
if !path.exists() { return Ok(StorageLocations::default()); }
|
||||
let meta = fs::symlink_metadata(&path)?;
|
||||
ensure!(meta.is_file() && !meta.file_type().is_symlink() && meta.uid() == unsafe { libc::geteuid() }, "无效 APK 存储设置文件");
|
||||
let config: StorageLocations = serde_json::from_slice(&fs::read(path)?)?;
|
||||
for location in [&config.apk, &config.contexts].into_iter().flatten() {
|
||||
ensure!(location.is_absolute() && location.is_dir() && !fs::symlink_metadata(location)?.file_type().is_symlink(), "配置的存储目录不可用:{}", location.display());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
fn save_storage_config(home: &Path, config: &StorageLocations) -> Result<()> {
|
||||
let path = storage_config_path(home);
|
||||
fs::create_dir_all(path.parent().unwrap())?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
fs::write(&tmp, serde_json::to_vec(config)?)?;
|
||||
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?;
|
||||
fs::File::open(&tmp)?.sync_all()?;
|
||||
fs::rename(tmp, &path)?;
|
||||
fs::File::open(path.parent().unwrap())?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
fn contexts_root(home: &Path) -> PathBuf {
|
||||
storage_config(home).ok().and_then(|c| c.contexts).unwrap_or_else(|| home.join(".local/share/lepton/contexts"))
|
||||
}
|
||||
impl Default for Database {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -152,6 +197,7 @@ struct App {
|
||||
orientation: Option<String>,
|
||||
#[serde(default)]
|
||||
framebuffer_compatibility: bool,
|
||||
q2f_version: Option<String>,
|
||||
}
|
||||
fn gamepad_trigger_threshold_default() -> u8 {
|
||||
80
|
||||
@@ -180,9 +226,10 @@ fn safe(s: &str) -> bool {
|
||||
&& s != ".."
|
||||
}
|
||||
fn root(home: &Path) -> PathBuf {
|
||||
home.join(".local/share/framely/apk-manager")
|
||||
storage_config(home).ok().and_then(|c| c.apk).unwrap_or_else(|| home.join(".local/share/framely/apk-manager"))
|
||||
}
|
||||
fn init(home: &Path) -> Result<PathBuf> {
|
||||
storage_config(home)?;
|
||||
let r = root(home);
|
||||
for p in [
|
||||
home.join(".local"),
|
||||
@@ -208,6 +255,7 @@ fn init(home: &Path) -> Result<PathBuf> {
|
||||
Ok(r)
|
||||
}
|
||||
fn load(home: &Path) -> Result<Database> {
|
||||
storage_config(home)?;
|
||||
let p = root(home).join("state.json");
|
||||
if !p.exists() {
|
||||
return Ok(Database::default());
|
||||
@@ -363,13 +411,17 @@ fn validate_baked(p: &Path) -> Result<PathBuf> {
|
||||
}
|
||||
fn containers(home: &Path, db: &Database) -> Vec<Container> {
|
||||
let mut out = Vec::new();
|
||||
let base = home.join(".local/share/lepton/contexts");
|
||||
if let Ok(entries) = fs::read_dir(&base) {
|
||||
let managed_base = contexts_root(home);
|
||||
let legacy_base = home.join(".local/share/lepton/contexts");
|
||||
for base in [&managed_base, &legacy_base] {
|
||||
if let Ok(entries) = fs::read_dir(base) {
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if !safe(&name) {
|
||||
continue;
|
||||
}
|
||||
if base == &legacy_base && managed_base != legacy_base && db.owned_contexts.contains(&name) && managed_base.join(&name).exists() { continue; }
|
||||
if out.iter().any(|c: &Container| c.name == name) { continue; }
|
||||
let baked = e.path().join("baked");
|
||||
if let Ok(baked) = validate_baked(&baked) {
|
||||
out.push(Container {
|
||||
@@ -383,9 +435,10 @@ fn containers(home: &Path, db: &Database) -> Vec<Container> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in &db.owned_contexts {
|
||||
if safe(id) && !out.iter().any(|c| c.id == *id) {
|
||||
let baked = base.join(id).join("baked");
|
||||
let baked = managed_base.join(id).join("baked");
|
||||
if baked.parent().is_some_and(|p| p.is_dir())
|
||||
&& !fs::symlink_metadata(&baked).is_ok_and(|m| m.file_type().is_symlink())
|
||||
{
|
||||
@@ -592,7 +645,7 @@ fn automatic_install_context(
|
||||
return Ok(id);
|
||||
}
|
||||
if !records.is_empty()
|
||||
|| fs::symlink_metadata(home.join(".local/share/lepton/contexts").join(&id)).is_ok()
|
||||
|| fs::symlink_metadata(contexts_root(home).join(&id)).is_ok()
|
||||
{
|
||||
continue; // Preserve retained records and unreadable/dangling paths.
|
||||
}
|
||||
@@ -794,6 +847,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
|
||||
show_window: rec.and_then(|r| r.show_window),
|
||||
orientation: rec.and_then(|r| r.orientation.clone()),
|
||||
framebuffer_compatibility: rec.is_some_and(|r| r.framebuffer_compatibility),
|
||||
q2f_version: rec.and_then(|r| r.q2f_version.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -848,6 +902,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
|
||||
show_window: r.show_window,
|
||||
orientation: r.orientation.clone(),
|
||||
framebuffer_compatibility: r.framebuffer_compatibility,
|
||||
q2f_version: r.q2f_version.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -893,6 +948,7 @@ fn apps(home: &Path, db: &Database, cs: &[Container], measure: bool) -> (Vec<App
|
||||
show_window: None,
|
||||
orientation: None,
|
||||
framebuffer_compatibility: false,
|
||||
q2f_version: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -947,9 +1003,241 @@ pub fn list(home: &Path) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
Ok(
|
||||
json!({"apps":apps.iter().map(|a| { let mut v=serde_json::to_value(a).unwrap(); v["steamLaunch"]=json!(db.records.get(&a.id).is_none_or(steam_shortcuts::wanted)); v["steamRegistered"]=json!(db.records.get(&a.id).is_some_and(|r|r.steam_launch));v["steamRegistrationError"]=json!(db.records.get(&a.id).and_then(|r|r.steam_registration_error.as_ref())); v }).collect::<Vec<_>>(),"containers":cs.iter().map(|c| { let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(false); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok(),"retainSteamEntries":db.retain_steam_entries,"gamepadEnabled":db.gamepad_enabled,"gamepadSource":db.gamepad_source,"gamepadRumble":db.gamepad_rumble,"gamepadTriggerThreshold":db.gamepad_trigger_threshold,"autoStopContainer":db.auto_stop_container,"stopContainerOnClose":db.stop_container_on_close}),
|
||||
json!({"apps":apps.iter().map(|a| { let mut v=serde_json::to_value(a).unwrap(); v["steamName"]=json!(db.records.get(&a.id).and_then(|r|r.steam_name.as_ref())); v["steamLaunch"]=json!(db.records.get(&a.id).is_none_or(steam_shortcuts::wanted)); v["steamRegistered"]=json!(db.records.get(&a.id).is_some_and(|r|r.steam_launch));v["steamRegistrationError"]=json!(db.records.get(&a.id).and_then(|r|r.steam_registration_error.as_ref())); v }).collect::<Vec<_>>(),"containers":cs.iter().map(|c| { let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(false); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok(),"retainSteamEntries":db.retain_steam_entries,"gamepadEnabled":db.gamepad_enabled,"gamepadSource":db.gamepad_source,"gamepadRumble":db.gamepad_rumble,"gamepadTriggerThreshold":db.gamepad_trigger_threshold,"autoStopContainer":db.auto_stop_container,"stopContainerOnClose":db.stop_container_on_close}),
|
||||
)
|
||||
}
|
||||
pub fn storage_status(home: &Path) -> Result<Value> {
|
||||
let config = storage_config(home)?;
|
||||
let apk = config.apk.unwrap_or_else(|| home.join(".local/share/framely/apk-manager"));
|
||||
let contexts = config.contexts.unwrap_or_else(|| home.join(".local/share/lepton/contexts"));
|
||||
let db = load(home)?;
|
||||
let mut context_bytes = 0u64;
|
||||
let mut contexts_incomplete = false;
|
||||
let mut context_details = Vec::new();
|
||||
for id in &db.owned_contexts {
|
||||
if safe(id) {
|
||||
let (size, incomplete) = crate::storage_location::disk_bytes(&contexts.join(id))?;
|
||||
context_bytes = context_bytes.saturating_add(size);
|
||||
contexts_incomplete |= incomplete;
|
||||
context_details.push(json!({"id":id,"name":id,"bytes":size,"sizeIncomplete":incomplete}));
|
||||
}
|
||||
}
|
||||
let (apk_bytes, apk_incomplete) = crate::storage_location::disk_bytes(&apk)?;
|
||||
let mut apk_details = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(apk.join("apks")) {
|
||||
for entry in entries {
|
||||
let entry = match entry { Ok(entry) => entry, Err(_) => continue };
|
||||
let path = entry.path();
|
||||
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { continue; }
|
||||
let key = entry.file_name();
|
||||
let record = db.records.values().find(|record| hash(&record.id) == key.to_string_lossy());
|
||||
let (size, incomplete) = crate::storage_location::disk_bytes(&path)?;
|
||||
let name = record.map_or_else(|| key.to_string_lossy().into_owned(), |record| record.metadata.name.clone());
|
||||
let id = record.map_or_else(|| key.to_string_lossy().into_owned(), |record| record.id.clone());
|
||||
apk_details.push(json!({"id":id,"name":name,"bytes":size,"sizeIncomplete":incomplete}));
|
||||
}
|
||||
}
|
||||
apk_details.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
let apk_device = fs::metadata(&apk).ok().map(|m| m.dev());
|
||||
let context_device = fs::metadata(&contexts).ok().map(|m| m.dev());
|
||||
let volume = crate::storage_location::volume(home)?;
|
||||
let journal = fs::read(storage_journal_path(home)).ok().and_then(|b|serde_json::from_slice::<Value>(&b).ok());
|
||||
let committed = journal.as_ref().is_some_and(|v| v["target"] == json!(if v["kind"] == "apk" { &apk } else { &contexts }));
|
||||
if committed {
|
||||
let _ = fs::remove_file(storage_journal_path(home));
|
||||
}
|
||||
Ok(json!({"apk":{"path":apk,"bytes":apk_bytes,"sizeIncomplete":apk_incomplete,"device":apk_device,"details":apk_details},"contexts":{"path":contexts,"bytes":context_bytes,"sizeIncomplete":contexts_incomplete,"device":context_device,"details":context_details},"volume":volume,"interrupted":if committed {None} else {journal}}))
|
||||
}
|
||||
pub fn migrate_storage(home: &Path, kind: &str, target: &Path, cancel: Cancellation, progress: Arc<dyn Fn(Value) + Send + Sync>) -> Result<Value> {
|
||||
ensure!(matches!(kind, "apk" | "contexts"), "无效存储类型");
|
||||
let _guard = MUTATION.try_lock().map_err(|_| anyhow::anyhow!("已有 APK 操作正在运行"))?;
|
||||
cancel.check()?;
|
||||
let config = storage_config(home)?;
|
||||
let old_apk = init(home)?;
|
||||
let lock_file = fs::OpenOptions::new().write(true).create(true).mode(0o600).open(old_apk.join("operation.lock"))?;
|
||||
ensure!(unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0, "已有 APK 操作正在运行");
|
||||
let db = load(home)?;
|
||||
let source = if kind == "apk" { old_apk } else { contexts_root(home) };
|
||||
let journal_path = storage_journal_path(home);
|
||||
if let Ok(bytes) = fs::read(&journal_path) {
|
||||
let record: Value = serde_json::from_slice(&bytes)?;
|
||||
if record["target"] == json!(source) { fs::remove_file(&journal_path)?; }
|
||||
}
|
||||
let (target, resume) = if journal_path.exists() {
|
||||
let record: Value = serde_json::from_slice(&fs::read(&journal_path)?)?;
|
||||
ensure!(record["kind"] == kind && record["source"] == json!(source), "存储迁移来源已改变,请检查原目录");
|
||||
if record["target"] == json!(target) {
|
||||
let verified = if target.exists() { if kind == "apk" { crate::storage_location::verified_equal(&source, target)? } else { crate::storage_location::verified_selected(&source, target, &db.owned_contexts, false)? } } else { false };
|
||||
ensure!(!target.exists() || verified || (target.is_dir() && fs::read_dir(target)?.next().is_none()), "上次迁移副本已与原目录不同,请选择新的空目录");
|
||||
(target.to_path_buf(), verified)
|
||||
} else { (crate::storage_location::validate_target(&source, target, false)?, false) }
|
||||
} else {
|
||||
(crate::storage_location::validate_target(&source, target, false)?, false)
|
||||
};
|
||||
let log = root(home).join("logs/storage-migration.log");
|
||||
let owned: Vec<Container> = containers(home, &db).into_iter()
|
||||
.filter(|c| db.owned_contexts.contains(&c.id)).collect();
|
||||
let active: Vec<Container> = owned.iter().filter(|c| c.running).cloned().collect();
|
||||
let result = (|| -> Result<Value> {
|
||||
crate::storage_location::write_journal(&journal_path, &json!({"kind":kind,"source":source,"target":target}))?;
|
||||
progress(json!({"step":"stopping"}));
|
||||
for c in &owned { stop(c, &log)?; }
|
||||
cancel.check()?;
|
||||
if resume { progress(json!({"step":"verifying"})); }
|
||||
else if kind == "apk" {
|
||||
crate::storage_location::copy_verified(&source, &target, |step| progress(json!({"step":step})))?;
|
||||
} else {
|
||||
crate::storage_location::copy_selected(&source, &target, &db.owned_contexts, false, |step| progress(json!({"step":step})))?;
|
||||
}
|
||||
cancel.check()?;
|
||||
let mut next = config.clone();
|
||||
if kind == "apk" { next.apk = Some(target.clone()); } else { next.contexts = Some(target.clone()); }
|
||||
save_storage_config(home, &next)?;
|
||||
progress(json!({"step":"restarting"}));
|
||||
let new_log = root(home).join("logs/storage-migration.log");
|
||||
let mut started = Vec::new();
|
||||
for c in &active {
|
||||
let mut relocated = c.clone();
|
||||
if kind == "contexts" { relocated.baked = target.join(&c.id).join("baked"); }
|
||||
if let Err(error) = start_container(home, &relocated, None, false, &new_log) {
|
||||
for opened in &started { let _ = stop(opened, &new_log); }
|
||||
let _ = save_storage_config(home, &config);
|
||||
for prior in &active { let _ = start_container(home, prior, None, false, &log); }
|
||||
return Err(error).context("迁移后容器启动失败,已恢复原位置");
|
||||
}
|
||||
started.push(relocated);
|
||||
}
|
||||
fs::remove_file(&journal_path)?;
|
||||
progress(json!({"step":"cleaning"}));
|
||||
let mut warnings = Vec::new();
|
||||
if kind == "apk" {
|
||||
if let Err(error) = crate::storage_location::remove_tree(&source) { warnings.push(error.to_string()); }
|
||||
} else {
|
||||
for id in &db.owned_contexts {
|
||||
if safe(id) && source.join(id).exists() {
|
||||
if let Err(error) = crate::storage_location::remove_tree(&source.join(id)) { warnings.push(error.to_string()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"path":target,"cleanupWarnings":warnings}))
|
||||
})();
|
||||
if result.is_err() {
|
||||
for c in &active { let _ = start_container(home, c, None, false, &log); }
|
||||
}
|
||||
result
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod storage_migration_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn storage_status_lists_apk_and_managed_container_sizes() {
|
||||
let _serial = TEST_SERIAL.lock().unwrap();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let home = temp.path().join("home");
|
||||
fs::create_dir(&home).unwrap();
|
||||
let apk_root = init(&home).unwrap();
|
||||
fs::create_dir_all(apk_root.join("apks/example")).unwrap();
|
||||
fs::write(apk_root.join("apks/example/base.apk"), vec![1; 8192]).unwrap();
|
||||
let container = contexts_root(&home).join("managed");
|
||||
fs::create_dir_all(&container).unwrap();
|
||||
fs::write(container.join("data"), vec![2; 8192]).unwrap();
|
||||
let mut db = load(&home).unwrap();
|
||||
db.owned_contexts.push("managed".into());
|
||||
save(&home, &db).unwrap();
|
||||
let status = storage_status(&home).unwrap();
|
||||
assert_eq!(status["apk"]["details"][0]["id"], "example");
|
||||
assert!(status["apk"]["details"][0]["bytes"].as_u64().unwrap() > 0);
|
||||
assert_eq!(status["contexts"]["details"][0]["id"], "managed");
|
||||
assert!(status["contexts"]["details"][0]["bytes"].as_u64().unwrap() > 0);
|
||||
assert!(status["volume"]["total"].as_u64().unwrap() > 0);
|
||||
}
|
||||
#[test]
|
||||
fn apk_and_owned_contexts_move_independently() {
|
||||
let _serial = TEST_SERIAL.lock().unwrap();
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
let home = temp.path().join("home");
|
||||
fs::create_dir(&home).unwrap();
|
||||
let original_apk = init(&home).unwrap();
|
||||
fs::write(original_apk.join("apks/example.apk"), b"apk").unwrap();
|
||||
let apk_work = original_apk.join("recovery/old/baked/app_workdir/work");
|
||||
fs::create_dir_all(&apk_work).unwrap();
|
||||
fs::write(apk_work.join("overlay-state"), b"private").unwrap();
|
||||
fs::set_permissions(&apk_work, fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let original_contexts = contexts_root(&home);
|
||||
let baked = original_contexts.join("managed/baked/data_overlay/system");
|
||||
fs::create_dir_all(&baked).unwrap();
|
||||
fs::write(baked.join("packages.xml"), b"<packages/>").unwrap();
|
||||
let context_work = original_contexts.join("managed/baked/data_workdir/work");
|
||||
fs::create_dir_all(&context_work).unwrap();
|
||||
fs::write(context_work.join("overlay-state"), b"private").unwrap();
|
||||
fs::set_permissions(&context_work, fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let external = original_contexts.join("external/baked/data_overlay/system");
|
||||
fs::create_dir_all(&external).unwrap();
|
||||
fs::write(external.join("packages.xml"), b"<packages/>").unwrap();
|
||||
let mut db = load(&home).unwrap();
|
||||
db.owned_contexts.push("managed".into());
|
||||
save(&home, &db).unwrap();
|
||||
let apk_target = temp.path().join("apk-new");
|
||||
migrate_storage(&home,"apk",&apk_target,Cancellation::default(),Arc::new(|_|{})).unwrap();
|
||||
assert_eq!(root(&home), apk_target);
|
||||
assert_eq!(fs::read(root(&home).join("apks/example.apk")).unwrap(), b"apk");
|
||||
assert!(!original_apk.exists());
|
||||
assert_eq!(fs::metadata(apk_target.join("recovery/old/baked/app_workdir/work")).unwrap().permissions().mode() & 0o777, 0);
|
||||
assert!(original_contexts.join("managed/baked").is_dir());
|
||||
let context_target = temp.path().join("android-new");
|
||||
migrate_storage(&home,"contexts",&context_target,Cancellation::default(),Arc::new(|_|{})).unwrap();
|
||||
assert_eq!(contexts_root(&home), context_target);
|
||||
assert!(context_target.join("managed/baked/data_overlay/system/packages.xml").is_file());
|
||||
assert!(!context_target.join("external").exists());
|
||||
assert!(original_contexts.join("external/baked").is_dir());
|
||||
assert!(!original_contexts.join("managed").exists());
|
||||
let found = containers(&home,&load(&home).unwrap());
|
||||
assert!(found.iter().any(|c|c.id=="managed" && c.baked.starts_with(&context_target)));
|
||||
assert!(found.iter().any(|c|c.id=="external" && c.baked.starts_with(&original_contexts)));
|
||||
fs::set_permissions(apk_target.join("recovery/old/baked/app_workdir/work"), fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::set_permissions(context_target.join("managed/baked/data_workdir/work"), fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn interrupted_apk_copy_resumes_without_touching_original() {
|
||||
let _serial = TEST_SERIAL.lock().unwrap();
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
let home = temp.path().join("home");
|
||||
fs::create_dir(&home).unwrap();
|
||||
let source = init(&home).unwrap();
|
||||
fs::write(source.join("apks/keep.apk"), b"saved apk").unwrap();
|
||||
fs::write(source.join("operation.lock"), b"").unwrap();
|
||||
let target = temp.path().join("apk-resume");
|
||||
crate::storage_location::copy_verified(&source,&target,|_|{}).unwrap();
|
||||
crate::storage_location::write_journal(&storage_journal_path(&home),&json!({"kind":"apk","source":source,"target":target})).unwrap();
|
||||
assert_eq!(storage_status(&home).unwrap()["interrupted"]["target"],json!(target));
|
||||
migrate_storage(&home,"apk",&target,Cancellation::default(),Arc::new(|_|{})).unwrap();
|
||||
assert_eq!(root(&home),target);
|
||||
assert_eq!(fs::read(target.join("apks/keep.apk")).unwrap(),b"saved apk");
|
||||
assert!(!storage_journal_path(&home).exists());
|
||||
}
|
||||
#[test]
|
||||
fn storage_status_survives_unreadable_overlay_workdirs() {
|
||||
if unsafe { libc::geteuid() } == 0 { return; }
|
||||
let _serial = TEST_SERIAL.lock().unwrap();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let home = temp.path().join("home");
|
||||
fs::create_dir(&home).unwrap();
|
||||
let apk_root = init(&home).unwrap();
|
||||
let apk_work = apk_root.join("recovery/old/baked/app_workdir/work");
|
||||
fs::create_dir_all(&apk_work).unwrap();
|
||||
let context_work = contexts_root(&home).join("managed/baked/data_workdir/work");
|
||||
fs::create_dir_all(&context_work).unwrap();
|
||||
let mut db = load(&home).unwrap();
|
||||
db.owned_contexts.push("managed".into());
|
||||
save(&home, &db).unwrap();
|
||||
for path in [&apk_work, &context_work] { fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap(); }
|
||||
let status = storage_status(&home);
|
||||
for path in [&apk_work, &context_work] { fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); }
|
||||
let status = status.unwrap();
|
||||
assert_eq!(status["apk"]["sizeIncomplete"], true);
|
||||
assert_eq!(status["contexts"]["sizeIncomplete"], true);
|
||||
}
|
||||
}
|
||||
pub fn launcher(home: &Path) -> Vec<Value> {
|
||||
let Ok(db) = load(home) else { return vec![] };
|
||||
let cs = containers(home, &db);
|
||||
@@ -1194,6 +1482,10 @@ fn direct_launch_script(source: &str) -> Result<String> {
|
||||
"Unsupported Lepton entry script; cannot safely configure the APK display"
|
||||
);
|
||||
let hooks = r#"
|
||||
# Lepton 3 calls this helper from remove_prefix, independently of
|
||||
# LEPTON_NO_CLEANUP. Preserve installed data for both the package-manager
|
||||
# container and native Steam launches; temporary prefix removal still runs.
|
||||
function prepare_baked_data_for_removal() { :; }
|
||||
# Preserve downloaded Android resources in development contexts. Lepton's
|
||||
# single-app launcher deletes media/0 before creating its external-storage link.
|
||||
function framely_prepare_media() {
|
||||
@@ -1228,6 +1520,24 @@ function framely_prepare_media() {
|
||||
mkdir -p -- "$media"
|
||||
fi
|
||||
}
|
||||
function framely_link_host_directory() {
|
||||
local target="$1" source="$2"
|
||||
if [[ -L "$target" ]]; then
|
||||
[[ "$(readlink "$target")" == "$source" ]] || { echo "Unexpected Android storage link: $target" >&2; return 64; }
|
||||
rm -f -- "$target" || return
|
||||
elif [[ -d "$target" ]]; then
|
||||
# An app may replace the link with a real directory. Keep its files
|
||||
# visible inside Android instead of removing or hiding them.
|
||||
if [[ -n "$(find "$target" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
|
||||
return 0
|
||||
fi
|
||||
rmdir -- "$target" || return
|
||||
elif [[ -e "$target" ]]; then
|
||||
echo "Unexpected Android storage entry: $target" >&2
|
||||
return 64
|
||||
fi
|
||||
ln -s -- "$source" "$target"
|
||||
}
|
||||
for framely_mount_hook in setup_mounts setup_podman_mounts; do
|
||||
declare -F "$framely_mount_hook" >/dev/null || continue
|
||||
framely_mount_body="$(declare -f "$framely_mount_hook")"
|
||||
@@ -1240,6 +1550,21 @@ for framely_mount_hook in setup_mounts setup_podman_mounts; do
|
||||
fi
|
||||
if [[ "$framely_mount_body" == *"$framely_media_remove"* ]]; then
|
||||
framely_mount_body="${framely_mount_body//"$framely_media_remove"/framely_prepare_media || return}"
|
||||
if [[ "$framely_mount_hook" == setup_mounts ]]; then
|
||||
for framely_host_pair in 'Documents:Documents' 'Download:Downloads' 'Movies:Videos'; do
|
||||
framely_host_target="${framely_host_pair%%:*}"
|
||||
framely_host_source="${framely_host_pair#*:}"
|
||||
framely_host_remove="rm -rf \"\${TARGET_PATH}/$framely_host_target\""
|
||||
framely_host_link="ln -s \"\${HOME}/$framely_host_source\" \"\${TARGET_PATH}/$framely_host_target\""
|
||||
if [[ "$framely_mount_body" == *"$framely_host_remove"* || "$framely_mount_body" == *"$framely_host_link"* ]]; then
|
||||
[[ "$framely_mount_body" == *"$framely_host_remove"* && "$framely_mount_body" == *"$framely_host_link"* ]] || { echo "Unsupported Lepton host storage links" >&2; exit 64; }
|
||||
framely_host_replacement="framely_link_host_directory \"\${TARGET_PATH}/$framely_host_target\" \"\${HOME}/$framely_host_source\" || return"
|
||||
framely_mount_body="${framely_mount_body//"$framely_host_remove"/$framely_host_replacement}"
|
||||
framely_mount_body="${framely_mount_body//"$framely_host_link"/:}"
|
||||
fi
|
||||
done
|
||||
unset framely_host_pair framely_host_target framely_host_source framely_host_remove framely_host_link framely_host_replacement
|
||||
fi
|
||||
if [[ "${FRAMELY_NATIVE_LAUNCH:-false}" == true ]]; then
|
||||
framely_native_link='ln -s "${STEAM_COMPAT_DATA_PATH}/external"'
|
||||
framely_preserved_link='ln -s "${FRAMELY_EXTERNAL_MEDIA_DIR:?}"'
|
||||
@@ -1763,6 +2088,7 @@ fn record_app(db: &mut Database, a: &App) {
|
||||
show_window: a.show_window,
|
||||
orientation: a.orientation.clone(),
|
||||
framebuffer_compatibility: a.framebuffer_compatibility,
|
||||
q2f_version: a.q2f_version.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
@@ -1846,17 +2172,21 @@ fn snapshot_inventory(
|
||||
Ok(())
|
||||
}
|
||||
fn recover_restores(home: &Path, db: &mut Database) -> Result<()> {
|
||||
let base = home.join(".local/share/lepton/contexts");
|
||||
let base = contexts_root(home);
|
||||
let legacy = home.join(".local/share/lepton/contexts");
|
||||
let mut locations = BTreeMap::new();
|
||||
for id in &db.owned_contexts {
|
||||
ensure!(safe(id), "Invalid managed context");
|
||||
locations.insert(id.clone(), base.join(id));
|
||||
}
|
||||
if let Ok(entries) = fs::read_dir(&base) {
|
||||
for e in entries.flatten() {
|
||||
let id = e.file_name().to_string_lossy().into_owned();
|
||||
if safe(&id) && !id.starts_with("steamlaunch-") && e.file_type()?.is_dir() {
|
||||
locations.insert(id, e.path());
|
||||
for directory in [&base, &legacy] {
|
||||
if let Ok(entries) = fs::read_dir(directory) {
|
||||
for e in entries.flatten() {
|
||||
let id = e.file_name().to_string_lossy().into_owned();
|
||||
if safe(&id) && !id.starts_with("steamlaunch-") && e.file_type()?.is_dir()
|
||||
&& (directory == &base || !db.owned_contexts.contains(&id)) {
|
||||
locations.insert(id, e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2453,6 +2783,17 @@ pub fn reviews_drop(home: &Path, ticket: &str) -> Result<Value> {
|
||||
}
|
||||
Ok(json!(true))
|
||||
}
|
||||
pub fn q2f_convert(
|
||||
home: &Path,
|
||||
ticket: &str,
|
||||
cancel: &Cancellation,
|
||||
progress: impl Fn(Value) -> Result<()>,
|
||||
) -> Result<Value> {
|
||||
q2f::convert(home, ticket, cancel, progress)
|
||||
}
|
||||
pub fn q2f_status(home: &Path) -> Result<Value> {
|
||||
q2f::status(home)
|
||||
}
|
||||
pub fn operate(
|
||||
home: &Path,
|
||||
kind: &str,
|
||||
@@ -2468,7 +2809,7 @@ pub fn operate(
|
||||
}
|
||||
}
|
||||
let result = operate_internal(home, kind, p, cancel, progress, None);
|
||||
if matches!(kind, "install" | "steam.retention.settings") && result.is_ok() {
|
||||
if matches!(kind, "install" | "steam.retention.settings" | "settings") && result.is_ok() {
|
||||
steam_shortcuts::wake_registration();
|
||||
}
|
||||
result
|
||||
@@ -2690,6 +3031,15 @@ fn operate_internal(
|
||||
.transpose()?;
|
||||
let ticket = p["ticket"].as_str().context("Missing reviewed APK")?;
|
||||
let (review, metadata) = review(home, ticket)?;
|
||||
let q2f_version = match fs::read_to_string(review.join("q2f-version")) {
|
||||
Ok(value) => {
|
||||
let id = value.trim();
|
||||
ensure!(safe(id) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b".+-".contains(&b)), "Invalid q2f review version");
|
||||
Some(id.to_owned())
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let requested = p["context"].as_str().unwrap_or("");
|
||||
let cs = containers(home, &db);
|
||||
let context = if requested.is_empty() {
|
||||
@@ -2702,8 +3052,7 @@ fn operate_internal(
|
||||
let c = existing.unwrap_or_else(|| Container {
|
||||
id: context.clone(),
|
||||
name: context.clone(),
|
||||
baked: home
|
||||
.join(".local/share/lepton/contexts")
|
||||
baked: contexts_root(home)
|
||||
.join(&context)
|
||||
.join("baked"),
|
||||
running: false,
|
||||
@@ -2794,7 +3143,7 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
|
||||
// Installation boots a headless container solely for the package manager.
|
||||
// Finish that runtime before Steam takes ownership of the first app launch.
|
||||
stop(&c,&log)?;
|
||||
let rec=db.records.get_mut(&app_id).unwrap();rec.metadata=metadata.clone();rec.removed=false;rec.pending=None;rec.show_window=settings.show_window;save(home,&db)?;fs::remove_dir_all(review)?;
|
||||
let rec=db.records.get_mut(&app_id).unwrap();rec.metadata=metadata.clone();rec.removed=false;rec.pending=None;rec.show_window=settings.show_window;rec.q2f_version=q2f_version;save(home,&db)?;fs::remove_dir_all(review)?;
|
||||
// Mirror the operation log under the stable application id for newly installed apps.
|
||||
if id!=app_id {let _=fs::copy(&log,r.join("logs").join(format!("{}-{}.log",hash(&app_id),now())));}
|
||||
Ok(json!({"app":app_id,"backup":backup_id,"launchable":!metadata.activities.is_empty()}))})
|
||||
@@ -2845,6 +3194,11 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
|
||||
);
|
||||
record_app(&mut db, &a);
|
||||
let rec = db.records.get_mut(id).unwrap();
|
||||
let steam_name = p.get("steamName").map(steam_shortcuts::parse_name).transpose()?;
|
||||
if let Some(name) = &steam_name {
|
||||
ensure!(!a.steam, "This app is already managed by Steam");
|
||||
rec.steam_name = name.clone();
|
||||
}
|
||||
if let Some(v) = p.get("activity") {
|
||||
let activity = v.as_str().filter(|s| !s.is_empty());
|
||||
ensure!(
|
||||
@@ -2875,6 +3229,14 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
|
||||
rec.orientation = v.as_str().filter(|s| *s != "auto").map(str::to_owned);
|
||||
}
|
||||
cancel.commit(|| save(home, &db))?;
|
||||
if steam_name.is_some() {
|
||||
let rec = db.records.get_mut(id).unwrap();
|
||||
if rec.steam_launch && steam_shortcuts::linked(home, rec) {
|
||||
let result = steam_shortcuts::update_name(home, &a, rec);
|
||||
rec.steam_registration_error = result.err().map(|e| format!("{e:#}"));
|
||||
save(home, &db)?;
|
||||
}
|
||||
}
|
||||
Ok(json!(true))
|
||||
}
|
||||
"launch" | "close" | "uninstall" | "purge" | "clear" => {
|
||||
@@ -3865,6 +4227,73 @@ setup_podman_mounts
|
||||
assert_eq!(fs::read(&checkpoint).unwrap(), b"offset=19");
|
||||
}
|
||||
#[test]
|
||||
fn direct_boot_keeps_real_android_host_folders() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let library = dir.path().join("lepton/liblepton");
|
||||
fs::create_dir_all(&library).unwrap();
|
||||
fs::write(library.join("liblepton.sh"), r#"
|
||||
function data_mount_path() { echo "$TEST_DATA"; }
|
||||
function setup_mounts() {
|
||||
rm -rf "$(data_mount_path)/media/0"
|
||||
mkdir -p "$(data_mount_path)/media"
|
||||
local TARGET_PATH="$TEST_EXTERNAL"
|
||||
mkdir -p "$TARGET_PATH"
|
||||
if [[ -d "${HOME}/Documents" ]]; then
|
||||
rm -rf "${TARGET_PATH}/Documents"
|
||||
ln -s "${HOME}/Documents" "${TARGET_PATH}/Documents"
|
||||
fi
|
||||
if [[ -d "${HOME}/Downloads" ]]; then
|
||||
rm -rf "${TARGET_PATH}/Download"
|
||||
ln -s "${HOME}/Downloads" "${TARGET_PATH}/Download"
|
||||
fi
|
||||
if [[ -d "${HOME}/Videos" ]]; then
|
||||
rm -rf "${TARGET_PATH}/Movies"
|
||||
ln -s "${HOME}/Videos" "${TARGET_PATH}/Movies"
|
||||
fi
|
||||
}
|
||||
function setup_podman_mounts() { setup_mounts; }
|
||||
"#).unwrap();
|
||||
let source = r#"#!/bin/bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
source "${SCRIPT_DIR}/liblepton/liblepton.sh"
|
||||
setup_podman_mounts
|
||||
"#;
|
||||
let entry = dir.path().join("entry.sh");
|
||||
fs::write(&entry, direct_launch_script(source).unwrap()).unwrap();
|
||||
let home = dir.path().join("home");
|
||||
let external = dir.path().join("external");
|
||||
let data = dir.path().join("data");
|
||||
for folder in ["Documents", "Downloads", "Videos"] {
|
||||
fs::create_dir_all(home.join(folder)).unwrap();
|
||||
}
|
||||
let run = || Command::new("bash")
|
||||
.arg(&entry)
|
||||
.env("FRAMELY_LEPTON_DIR", library.parent().unwrap())
|
||||
.env("HOME", &home)
|
||||
.env("TEST_DATA", &data)
|
||||
.env("TEST_EXTERNAL", &external)
|
||||
.output().unwrap();
|
||||
let first = run();
|
||||
assert!(first.status.success(), "{}", String::from_utf8_lossy(&first.stderr));
|
||||
for (target, source) in [("Documents", "Documents"), ("Download", "Downloads"), ("Movies", "Videos")] {
|
||||
assert_eq!(fs::read_link(external.join(target)).unwrap(), home.join(source));
|
||||
}
|
||||
fs::remove_file(external.join("Documents")).unwrap();
|
||||
fs::create_dir(external.join("Documents")).unwrap();
|
||||
fs::write(external.join("Documents/keep.txt"), b"android file").unwrap();
|
||||
let second = run();
|
||||
assert!(second.status.success(), "{}", String::from_utf8_lossy(&second.stderr));
|
||||
assert_eq!(fs::read(external.join("Documents/keep.txt")).unwrap(), b"android file");
|
||||
assert!(external.join("Documents").is_dir());
|
||||
fs::remove_file(external.join("Download")).unwrap();
|
||||
std::os::unix::fs::symlink(dir.path().join("unexpected"), external.join("Download")).unwrap();
|
||||
let third = run();
|
||||
assert!(!third.status.success());
|
||||
assert!(external.join("Download").is_symlink());
|
||||
assert_eq!(fs::read(external.join("Documents/keep.txt")).unwrap(), b"android file");
|
||||
}
|
||||
#[test]
|
||||
fn direct_boot_binds_resources_and_reuses_shader_cache() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let library = dir.path().join("lepton/liblepton");
|
||||
@@ -4318,6 +4747,38 @@ setup_props
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn custom_steam_names_persist_reset_and_preserve_app_identity() {
|
||||
let f = Fixture::new();
|
||||
let id = "test/com.example.app";
|
||||
let before = list(&f.home).unwrap();
|
||||
let original = before["apps"][0]["metadata"]["name"].clone();
|
||||
f.operation("settings", json!({"app":id,"steamName":" 我的游戏 🎮 "})).unwrap();
|
||||
assert_eq!(load(&f.home).unwrap().records[id].steam_name.as_deref(), Some("我的游戏 🎮"));
|
||||
let after = list(&f.home).unwrap();
|
||||
assert_eq!(after["apps"][0]["steamName"], "我的游戏 🎮");
|
||||
assert_eq!(after["apps"][0]["metadata"]["name"], original);
|
||||
for value in [json!(42), json!("bad\nname"), json!("x".repeat(129))] {
|
||||
assert!(f.operation("settings", json!({"app":id,"steamName":value})).is_err());
|
||||
assert_eq!(load(&f.home).unwrap().records[id].steam_name.as_deref(), Some("我的游戏 🎮"));
|
||||
}
|
||||
let mut db = load(&f.home).unwrap();
|
||||
let (a, _) = app(&f.home, &db, id).unwrap();
|
||||
let record = db.records.get_mut(id).unwrap();
|
||||
record.steam_binding = Some(steam_shortcuts::Binding { game_id: steam_shortcuts::game_id(id), native: true, revision: "stale".into() });
|
||||
steam_ui::TEST_RESULT.with(|v| *v.borrow_mut() = Some(json!(true)));
|
||||
steam_shortcuts::update_name(&f.home, &a, record).unwrap();
|
||||
assert_ne!(record.steam_binding.as_ref().unwrap().revision, "stale");
|
||||
let current_revision = record.steam_binding.as_ref().unwrap().revision.clone();
|
||||
record.steam_name = Some("New name".into());
|
||||
steam_ui::TEST_RESULT.with(|v| *v.borrow_mut() = Some(json!(false)));
|
||||
assert!(steam_shortcuts::update_name(&f.home, &a, record).is_err());
|
||||
assert_eq!(record.steam_binding.as_ref().unwrap().revision, current_revision);
|
||||
f.operation("settings", json!({"app":id,"steamName":" "})).unwrap();
|
||||
assert!(load(&f.home).unwrap().records[id].steam_name.is_none());
|
||||
assert!(list(&f.home).unwrap()["apps"][0]["steamName"].is_null());
|
||||
assert_eq!(fs::read_to_string(f.home.join(".local/share/lepton/contexts/test/baked/data_overlay/data/com.example.app/files/save")).unwrap(), "saved progress");
|
||||
}
|
||||
#[test]
|
||||
fn transient_steam_entry_is_launch_only_protects_newer_launch_and_preserves_data() {
|
||||
let f = Fixture::new();
|
||||
list(&f.home).unwrap();
|
||||
@@ -4780,12 +5241,17 @@ setup_props
|
||||
assert!(load(&f.home).unwrap().records.is_empty());
|
||||
for (version, show) in [(43, false), (44, true)] {
|
||||
let ticket = f.review(version);
|
||||
if version == 43 {
|
||||
fs::write(root(&f.home).join("reviews").join(&ticket).join("q2f-version"), "test-q2f-1").unwrap();
|
||||
}
|
||||
f.operation(
|
||||
"install",
|
||||
json!({"ticket":ticket,"context":"test","app":id,"approve":true,"showWindow":show}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(load(&f.home).unwrap().records[id].show_window, Some(show));
|
||||
assert_eq!(load(&f.home).unwrap().records[id].q2f_version.as_deref(),
|
||||
if version == 43 { Some("test-q2f-1") } else { None });
|
||||
assert!(
|
||||
!running("test"),
|
||||
"installation must finish its headless runtime"
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-3
@@ -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
|
||||
@@ -344,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])
|
||||
@@ -362,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();
|
||||
@@ -382,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();
|
||||
@@ -390,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
|
||||
@@ -413,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())
|
||||
@@ -424,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();
|
||||
@@ -441,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))
|
||||
}
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
//! Optional Quest APK conversion. The runtime is versioned separately from Framely.
|
||||
use super::{apk_metadata, init, review, space, Cancellation, Metadata, MUTATION};
|
||||
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<()>,
|
||||
) -> 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);
|
||||
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}),
|
||||
)
|
||||
}
|
||||
@@ -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,7 +230,7 @@ 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),
|
||||
@@ -238,7 +238,7 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
|
||||
r.steam_binding = Some(Binding {
|
||||
game_id: game,
|
||||
native: true,
|
||||
revision: revision(a),
|
||||
revision: revision(a, r),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -285,8 +285,30 @@ 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});
|
||||
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");
|
||||
let current_revision = 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 +391,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 +443,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;
|
||||
|
||||
@@ -2,5 +2,7 @@
|
||||
//! run as the Steam session user, apart from the framebuffer export helper.
|
||||
pub mod settings;
|
||||
pub mod runtime;
|
||||
mod recording;
|
||||
|
||||
pub mod receiver;
|
||||
pub mod watch_access;
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Mux the existing stream without another capture or video encoder.
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
fs,
|
||||
io::Write,
|
||||
os::unix::{fs::OpenOptionsExt, process::CommandExt},
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub struct Recording {
|
||||
child: Child,
|
||||
path: PathBuf,
|
||||
started: Instant,
|
||||
progress: PathBuf,
|
||||
log: PathBuf,
|
||||
pub id: String,
|
||||
}
|
||||
fn has_media(progress: &str) -> bool {
|
||||
// Stream-copy progress has no `frame=` field on the headset's FFmpeg.
|
||||
let positive = |name: &str| {
|
||||
progress
|
||||
.lines()
|
||||
.rev()
|
||||
.find_map(|line| line.strip_prefix(name))
|
||||
.is_some_and(|value| value.trim().parse::<i64>().is_ok_and(|n| n > 0))
|
||||
};
|
||||
positive("out_time_us=") && positive("total_size=")
|
||||
}
|
||||
impl Recording {
|
||||
pub fn start(rtsp_port: u16, directory: &Path, output: &Path) -> Result<Self> {
|
||||
Self::start_command(Command::new("ffmpeg"), rtsp_port, directory, output)
|
||||
}
|
||||
fn start_command(
|
||||
mut cmd: Command,
|
||||
rtsp_port: u16,
|
||||
directory: &Path,
|
||||
output: &Path,
|
||||
) -> Result<Self> {
|
||||
fs::create_dir_all(output).context("无法创建录制目录")?;
|
||||
let id = hex::encode(rand::random::<[u8; 16]>());
|
||||
let path = output.join(format!(
|
||||
"Framely-{}-{}.mkv",
|
||||
crate::service::now_ms(),
|
||||
&id[..8]
|
||||
));
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&path)?;
|
||||
let progress = directory.join(format!("recording-{id}.progress"));
|
||||
let log = directory.join(format!("recording-{id}.log"));
|
||||
super::runtime::bind_to_session(&mut cmd);
|
||||
let spawned = cmd
|
||||
.process_group(0)
|
||||
.args([
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
"-y",
|
||||
"-fflags",
|
||||
"+genpts",
|
||||
"-analyzeduration",
|
||||
"1000000",
|
||||
"-probesize",
|
||||
"1048576",
|
||||
"-rtsp_transport",
|
||||
"tcp",
|
||||
"-i",
|
||||
&format!("rtsp://127.0.0.1:{rtsp_port}/headset"),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"0:a?",
|
||||
"-c",
|
||||
"copy",
|
||||
"-avoid_negative_ts",
|
||||
"make_zero",
|
||||
"-stats_period",
|
||||
"0.2",
|
||||
"-progress",
|
||||
])
|
||||
.arg(&progress)
|
||||
.args(["-flush_packets", "1", "-f", "matroska"])
|
||||
.arg(&path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&log)?,
|
||||
)
|
||||
.spawn()
|
||||
.context("启动录制程序失败");
|
||||
let child = match spawned {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&path);
|
||||
let _ = fs::remove_file(&log);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let mut recording = Self {
|
||||
child,
|
||||
path,
|
||||
started: Instant::now(),
|
||||
progress,
|
||||
log,
|
||||
id,
|
||||
};
|
||||
let ready = (|| -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(12);
|
||||
loop {
|
||||
ensure!(
|
||||
recording.child.try_wait()?.is_none(),
|
||||
"录制程序启动失败:{}",
|
||||
recording.detail()
|
||||
);
|
||||
if fs::read_to_string(&recording.progress)
|
||||
.ok()
|
||||
.is_some_and(|s| has_media(&s))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
ensure!(
|
||||
Instant::now() < deadline,
|
||||
"录制启动超时:{}",
|
||||
recording.detail()
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
})();
|
||||
if let Err(error) = ready {
|
||||
let _ = recording.finish();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(recording)
|
||||
}
|
||||
pub fn status(&self) -> Value {
|
||||
json!({"active":true,"id":self.id,"path":self.path,"elapsedSeconds":self.started.elapsed().as_secs()})
|
||||
}
|
||||
pub fn exited(&mut self) -> Result<bool> {
|
||||
Ok(self.child.try_wait()?.is_some())
|
||||
}
|
||||
pub fn detail(&self) -> String {
|
||||
fs::read_to_string(&self.log)
|
||||
.map(|s| {
|
||||
s.chars()
|
||||
.rev()
|
||||
.take(2048)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn finish(&mut self) -> Result<PathBuf> {
|
||||
if self.child.try_wait()?.is_none() {
|
||||
if let Some(mut stdin) = self.child.stdin.take() {
|
||||
let _ = stdin.write_all(b"q\n");
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
while self.child.try_wait()?.is_none() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
if self.child.try_wait()?.is_none() {
|
||||
unsafe {
|
||||
libc::kill(-(self.child.id() as i32), libc::SIGINT);
|
||||
}
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while self.child.try_wait()?.is_none() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
if self.child.try_wait()?.is_none() {
|
||||
unsafe {
|
||||
libc::kill(-(self.child.id() as i32), libc::SIGKILL);
|
||||
}
|
||||
let _ = self.child.wait();
|
||||
anyhow::bail!("录制结束超时,已保留可恢复文件:{}", self.path.display());
|
||||
}
|
||||
}
|
||||
let status = self.child.wait()?;
|
||||
ensure!(
|
||||
status.success(),
|
||||
"录制异常结束,文件已保留:{};{}",
|
||||
self.path.display(),
|
||||
self.detail()
|
||||
);
|
||||
Ok(self.path.clone())
|
||||
}
|
||||
}
|
||||
impl Drop for Recording {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.finish();
|
||||
let _ = fs::remove_file(&self.progress);
|
||||
let _ = fs::remove_file(&self.log);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn test_recording(directory: &Path, output: &Path, fail: bool) -> Recording {
|
||||
let script = directory.join("fake-ffmpeg.sh");
|
||||
fs::write(
|
||||
&script,
|
||||
r#"previous=''
|
||||
for argument in "$@"; do
|
||||
if [ "$previous" = '-progress' ]; then progress="$argument"; fi
|
||||
previous="$argument"
|
||||
done
|
||||
printf 'header\n' > "$previous"
|
||||
printf 'out_time_us=1000\ntotal_size=100\n' > "$progress"
|
||||
if [ "$FAIL_LATER" = 1 ]; then sleep 0.4; exit 1; fi
|
||||
read command
|
||||
[ "$command" = q ] || exit 2
|
||||
printf 'trailer\n' >> "$previous"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut command = Command::new("sh");
|
||||
command
|
||||
.arg(script)
|
||||
.env("FAIL_LATER", if fail { "1" } else { "0" });
|
||||
Recording::start_command(command, 1, directory, output).unwrap()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
#[test]
|
||||
fn cast_recording_copy_progress_does_not_require_frame_count() {
|
||||
assert!(has_media(
|
||||
"total_size=38485\nout_time_us=1191811\nprogress=continue\n"
|
||||
));
|
||||
assert!(!has_media("total_size=38485\nout_time_us=N/A\n"));
|
||||
assert!(!has_media("frame=1\ntotal_size=0\nout_time_us=0\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cast_recording_finalizes_and_uses_unique_private_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("videos");
|
||||
let mut first = test_recording(dir.path(), &output, false);
|
||||
let path = first.finish().unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "header\ntrailer\n");
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
let mut second = test_recording(dir.path(), &output, false);
|
||||
assert_ne!(first.id, second.id);
|
||||
assert_ne!(path, second.finish().unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn cast_recording_drop_finalizes_and_cleans_progress() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("videos");
|
||||
let first = test_recording(dir.path(), &output, false);
|
||||
let path = first.path.clone();
|
||||
let progress = first.progress.clone();
|
||||
drop(first);
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), "header\ntrailer\n");
|
||||
assert!(!progress.exists());
|
||||
}
|
||||
#[test]
|
||||
fn cast_recording_spawn_failure_cleans_empty_output() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("videos");
|
||||
assert!(Recording::start_command(
|
||||
Command::new("/framely-no-such-ffmpeg"),
|
||||
1,
|
||||
dir.path(),
|
||||
&output
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(fs::read_dir(output).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires FFmpeg, ffprobe and MediaMTX; uses isolated test streams"]
|
||||
fn cast_recording_real_mux_preserves_video_audio_and_finalizes() {
|
||||
struct Worker(Child);
|
||||
impl Drop for Worker {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
let port = || {
|
||||
std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("videos");
|
||||
let rtsp = port();
|
||||
let api = port();
|
||||
let config = dir.path().join("mediamtx.yml");
|
||||
fs::write(&config,format!("logLevel: warn\nrtspAddress: 127.0.0.1:{rtsp}\nrtspTransports: [tcp]\napi: yes\napiAddress: 127.0.0.1:{api}\nrtmp: no\nhls: no\nwebrtc: no\nsrt: no\nmoq: no\npaths:\n headset:\n source: publisher\n")).unwrap();
|
||||
let binary = std::env::var("FRAMELY_MEDIA_MTX").unwrap();
|
||||
let _server = Worker(
|
||||
Command::new(binary)
|
||||
.arg(config)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let end = Instant::now() + Duration::from_secs(5);
|
||||
while std::net::TcpStream::connect(("127.0.0.1", rtsp)).is_err() {
|
||||
assert!(Instant::now() < end);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
for (codec, audio) in [("h264", true), ("hevc", true), ("h264", false)] {
|
||||
let mut command = Command::new("ffmpeg");
|
||||
command.args([
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-re",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=160x96:rate=15",
|
||||
]);
|
||||
if audio {
|
||||
command.args([
|
||||
"-re",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:sample_rate=48000",
|
||||
]);
|
||||
}
|
||||
command.args([
|
||||
"-c:v",
|
||||
if codec == "hevc" {
|
||||
"libx265"
|
||||
} else {
|
||||
"libx264"
|
||||
},
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-tune",
|
||||
"zerolatency",
|
||||
"-threads",
|
||||
"1",
|
||||
"-g",
|
||||
"15",
|
||||
]);
|
||||
if codec == "hevc" {
|
||||
command.args([
|
||||
"-x265-params",
|
||||
"log-level=error:pools=1:frame-threads=1:bframes=0",
|
||||
]);
|
||||
}
|
||||
if audio {
|
||||
command.args(["-c:a", "libopus"]);
|
||||
} else {
|
||||
command.arg("-an");
|
||||
}
|
||||
let address = format!("rtsp://127.0.0.1:{rtsp}/headset");
|
||||
let _publisher = Worker(
|
||||
command
|
||||
.args(["-f", "rtsp", "-rtsp_transport", "tcp", &address])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let end = Instant::now() + Duration::from_secs(8);
|
||||
loop {
|
||||
if ureq::get(&format!("http://127.0.0.1:{api}/v3/paths/get/headset"))
|
||||
.timeout(Duration::from_millis(500))
|
||||
.call()
|
||||
.ok()
|
||||
.and_then(|r| r.into_json::<Value>().ok())
|
||||
.is_some_and(|v| v["ready"] == true)
|
||||
{
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < end, "publisher not ready");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let mut recording = Recording::start(rtsp, dir.path(), &output).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
let path = recording.finish().unwrap();
|
||||
let probe = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
"-of",
|
||||
"json",
|
||||
])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
probe.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&probe.stderr)
|
||||
);
|
||||
let data: Value = serde_json::from_slice(&probe.stdout).unwrap();
|
||||
let streams = data["streams"].as_array().unwrap();
|
||||
assert!(
|
||||
streams
|
||||
.iter()
|
||||
.any(|s| s["codec_name"] == codec && s["width"] == 160 && s["height"] == 96),
|
||||
"{data}"
|
||||
);
|
||||
assert_eq!(
|
||||
streams.iter().any(|s| s["codec_name"] == "opus"),
|
||||
audio,
|
||||
"{data}"
|
||||
);
|
||||
assert!(
|
||||
data["format"]["duration"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse::<f64>()
|
||||
.unwrap()
|
||||
> 0.5,
|
||||
"{data}"
|
||||
);
|
||||
let decoded = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-xerror", "-i"])
|
||||
.arg(&path)
|
||||
.args(["-f", "null", "-"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
decoded.status.success(),
|
||||
"saved recording is not decodable: {}",
|
||||
String::from_utf8_lossy(&decoded.stderr)
|
||||
);
|
||||
if audio {
|
||||
let pcm = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-i"])
|
||||
.arg(&path)
|
||||
.args(["-map", "0:a:0", "-t", "0.2", "-f", "s16le", "pipe:1"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(pcm.status.success());
|
||||
assert!(
|
||||
pcm.stdout
|
||||
.chunks_exact(2)
|
||||
.any(|s| i16::from_le_bytes([s[0], s[1]]).unsigned_abs() > 1000),
|
||||
"recorded audio is silent"
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"PASS: {codec}, audio={audio}, finalized {} bytes",
|
||||
fs::metadata(path).unwrap().len()
|
||||
);
|
||||
drop(recording);
|
||||
drop(_publisher);
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
-22
@@ -26,6 +26,9 @@ pub struct Casting {
|
||||
pub stream_key: String,
|
||||
error: Option<String>,
|
||||
preview_restore: Option<Option<Settings>>,
|
||||
recording: Option<super::recording::Recording>,
|
||||
recording_error: Option<String>,
|
||||
recording_path: Option<PathBuf>,
|
||||
}
|
||||
fn port() -> Result<u16> {
|
||||
Ok(std::net::TcpListener::bind("127.0.0.1:0")?
|
||||
@@ -50,9 +53,21 @@ impl Casting {
|
||||
stream_key: String::new(),
|
||||
error: None,
|
||||
preview_restore: None,
|
||||
recording: None,
|
||||
recording_error: None,
|
||||
recording_path: None,
|
||||
}
|
||||
}
|
||||
pub fn status(&mut self) -> Value {
|
||||
self.poll_recording();
|
||||
self.poll_workers();
|
||||
let view = fs::File::open(self.directory.join("stream.log")).ok().and_then(|file| { let mut log = String::new(); file.take(65536).read_to_string(&mut log).ok()?; Some(log) }).and_then(|log| log.lines().filter_map(|line|serde_json::from_str::<Value>(line).ok()).find(|v|v["event"]=="view"));
|
||||
json!({"running":self.started.is_some(),"error":self.error,"preview":self.preview_restore.is_some(),"view":view,
|
||||
"watchPath":if self.started.is_some(){Some("/cast/watch")}else{None},
|
||||
"elapsedSeconds":self.started.map(|t|t.elapsed().as_secs()),
|
||||
"codec":self.active_settings.as_ref().map(|s|s.codec.name()),"dominantEye":dominant_eye().ok(),"recording":self.recording_status()})
|
||||
}
|
||||
fn poll_workers(&mut self) {
|
||||
let exited = self
|
||||
.workers
|
||||
.iter_mut()
|
||||
@@ -61,13 +76,43 @@ impl Casting {
|
||||
self.error = Some(format!("串流进程已退出:{code}"));
|
||||
self.stop_workers();
|
||||
}
|
||||
let view = fs::File::open(self.directory.join("stream.log")).ok().and_then(|file| { let mut log = String::new(); file.take(65536).read_to_string(&mut log).ok()?; Some(log) }).and_then(|log| log.lines().filter_map(|line|serde_json::from_str::<Value>(line).ok()).find(|v|v["event"]=="view"));
|
||||
json!({"running":self.started.is_some(),"error":self.error,"preview":self.preview_restore.is_some(),"view":view,
|
||||
"watchPath":if self.started.is_some(){Some("/cast/watch")}else{None},
|
||||
"elapsedSeconds":self.started.map(|t|t.elapsed().as_secs()),
|
||||
"codec":self.active_settings.as_ref().map(|s|s.codec.name()),"dominantEye":dominant_eye().ok()})
|
||||
}
|
||||
pub fn recording_status(&self) -> Value {
|
||||
let mut value = self.recording.as_ref().map(|r|r.status()).unwrap_or(json!({"active":false,"path":self.recording_path}));
|
||||
value["error"] = json!(self.recording_error);
|
||||
value
|
||||
}
|
||||
fn poll_recording(&mut self) {
|
||||
if self.recording.as_mut().is_some_and(|r|r.exited().unwrap_or(true)) {
|
||||
self.recording_error = Some(format!("录制进程意外退出:{}",self.recording.as_ref().unwrap().detail()));
|
||||
let _ = self.stop_recording();
|
||||
}
|
||||
}
|
||||
pub fn start_recording(&mut self, output: &Path) -> Result<Value> {
|
||||
self.status();
|
||||
ensure!(self.started.is_some(), "请先开始串流");
|
||||
ensure!(self.preview_restore.is_none(), "取景预览期间不能录制");
|
||||
ensure!(self.recording.is_none(), "录制已在运行");
|
||||
self.recording_error = None;
|
||||
match super::recording::Recording::start(self.rtsp_port, &self.directory, output) {
|
||||
Ok(recording) => {self.recording = Some(recording); Ok(self.recording_status())},
|
||||
Err(error) => {self.recording_error = Some(format!("{error:#}")); Err(error)}
|
||||
}
|
||||
}
|
||||
pub fn stop_recording(&mut self) -> Result<Value> {
|
||||
if let Some(mut recording) = self.recording.take() {
|
||||
match recording.finish() {
|
||||
Ok(path) => self.recording_path = Some(path),
|
||||
Err(error) => {self.recording_error = Some(format!("{error:#}")); return Err(error);}
|
||||
}
|
||||
}
|
||||
Ok(self.recording_status())
|
||||
}
|
||||
pub fn stop_recording_id(&mut self, id: &str) {
|
||||
if self.recording.as_ref().is_some_and(|r|r.id == id) {let _ = self.stop_recording();}
|
||||
}
|
||||
fn stop_workers(&mut self) {
|
||||
let _ = self.stop_recording();
|
||||
for child in &mut self.workers {
|
||||
if child.try_wait().ok().flatten().is_some() {
|
||||
continue;
|
||||
@@ -100,6 +145,12 @@ impl Casting {
|
||||
/// All VP8 viewers share one on-demand compatibility encoder.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_rtsp_port(&self) -> u16 {self.rtsp_port}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_whep_server(&mut self, port: u16) {
|
||||
self.started = Some(Instant::now());
|
||||
self.active_settings = Some(Settings::default());
|
||||
self.whep_port = port;
|
||||
}
|
||||
pub fn codec(&self) -> Codec { self.active_settings.as_ref().map(|s|s.codec).unwrap_or_default() }
|
||||
pub fn ensure_vp8(&mut self) -> Result<()> {
|
||||
ensure!(self.status()["running"] == true, "请先开始头显串流");
|
||||
@@ -189,6 +240,8 @@ impl Casting {
|
||||
}
|
||||
}
|
||||
pub fn maintain(&mut self) {
|
||||
self.poll_recording();
|
||||
self.poll_workers();
|
||||
if self.compatibility_poll.elapsed() < Duration::from_secs(2) {
|
||||
return;
|
||||
}
|
||||
@@ -278,6 +331,7 @@ impl Casting {
|
||||
json!(true)
|
||||
}
|
||||
pub fn start_preview(&mut self, settings: &Settings, socket: &Path) -> Result<Value> {
|
||||
ensure!(self.recording.is_none(), "请先停止录制再调整取景");
|
||||
ensure!(self.preview_restore.is_none(), "取景预览已在运行");
|
||||
let restore = self.active_settings.clone();
|
||||
self.stop_workers();
|
||||
@@ -409,11 +463,11 @@ impl Casting {
|
||||
if let Some(fov) = s.horizontal_fov {
|
||||
command.args(["--fov", &fov.to_string()]);
|
||||
}
|
||||
// Bind signed values to their options: older capture workers'
|
||||
// clap parser treats a separate negative number as a new flag.
|
||||
command.args([
|
||||
"--center-x",
|
||||
&s.center_x.to_string(),
|
||||
"--center-y",
|
||||
&s.center_y.to_string(),
|
||||
format!("--center-x={}", s.center_x),
|
||||
format!("--center-y={}", s.center_y),
|
||||
]);
|
||||
}
|
||||
bind_to_session(&mut command);
|
||||
@@ -452,6 +506,11 @@ impl Casting {
|
||||
"0",
|
||||
"-framerate",
|
||||
&s.fps.to_string(),
|
||||
// Timestamp raw packets before FFmpeg schedules the inputs.
|
||||
// Use the capture frame count rather than arrival wallclock:
|
||||
// pipe reads can batch frames and distort scheduler pacing.
|
||||
"-bsf:v",
|
||||
&video_packet_timestamps(s.fps),
|
||||
"-f",
|
||||
if s.codec == Codec::H265 {"hevc"} else {"h264"},
|
||||
"-i",
|
||||
@@ -460,7 +519,6 @@ impl Casting {
|
||||
} else {
|
||||
ffmpeg.args(["-thread_queue_size", "4", "-f", "v4l2", "-i", "/dev/video99"]);
|
||||
}
|
||||
let audio = s.system_audio || s.microphone;
|
||||
if s.system_audio {
|
||||
add_pulse_input(&mut ffmpeg, "@DEFAULT_MONITOR@");
|
||||
}
|
||||
@@ -468,22 +526,15 @@ impl Casting {
|
||||
add_pulse_input(&mut ffmpeg, "default");
|
||||
}
|
||||
ffmpeg.args(["-map", "0:v:0"]);
|
||||
if s.system_audio && s.microphone {
|
||||
ffmpeg.args([
|
||||
"-filter_complex",
|
||||
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0[a]",
|
||||
"-map",
|
||||
"[a]",
|
||||
]);
|
||||
} else if audio {
|
||||
ffmpeg.args(["-map", "1:a:0"]);
|
||||
}
|
||||
let audio = map_audio(&mut ffmpeg, s.system_audio, s.microphone);
|
||||
if s.source == Source::Screen {
|
||||
ffmpeg.args(["-c:v", "copy"]);
|
||||
if s.codec==Codec::H265 {
|
||||
if s.codec == Codec::H265 {
|
||||
// Iris HEVC headers omit reliable frame timing. Write the requested
|
||||
// timing without decoding/re-encoding, avoiding false 50 fps playback.
|
||||
ffmpeg.args(["-bsf:v",&format!("hevc_metadata=tick_rate={}:num_ticks_poc_diff_one=1",s.fps)]);
|
||||
ffmpeg.args(["-bsf:v", &format!(
|
||||
"hevc_metadata=tick_rate={}:num_ticks_poc_diff_one=1", s.fps
|
||||
)]);
|
||||
}
|
||||
} else {
|
||||
ffmpeg.args(["-vf", &format!("fps={},scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2,format=yuv420p",s.fps,s.width,s.height,s.width,s.height),
|
||||
@@ -598,6 +649,29 @@ fn add_pulse_input(command: &mut Command, device: &str) {
|
||||
command.args(["-thread_queue_size", "4", "-f", "pulse", "-sample_rate", "48000", "-channels", "2", "-fragment_size", "3840", "-i", device]);
|
||||
}
|
||||
|
||||
// Pulse capture timestamps use the audio server's clock, while raw panel video
|
||||
// starts at zero. Generate audio PTS from the sample count in that same origin.
|
||||
// libopus low-delay mode subtracts its 120-sample pre-roll from packet PTS, so
|
||||
// offset the input by 120 samples and keep the first RTSP packet non-negative.
|
||||
fn map_audio(command: &mut Command, system_audio: bool, microphone: bool) -> bool {
|
||||
let audio = system_audio || microphone;
|
||||
if system_audio && microphone {
|
||||
command.args([
|
||||
"-filter_complex",
|
||||
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0,asetpts=(N+120)/SR/TB[a]",
|
||||
"-map",
|
||||
"[a]",
|
||||
]);
|
||||
} else if audio {
|
||||
command.args(["-map", "1:a:0", "-af", "asetpts=(N+120)/SR/TB"]);
|
||||
}
|
||||
audio
|
||||
}
|
||||
|
||||
fn video_packet_timestamps(fps: u32) -> String {
|
||||
format!("setts=time_base=1/{fps}:pts=N:dts=N:duration=1")
|
||||
}
|
||||
|
||||
pub fn bind_to_session(command: &mut Command) {
|
||||
// Linux PDEATHSIG follows the spawning thread, not the session process.
|
||||
// HTTP request threads end immediately after returning a response. Worker
|
||||
@@ -609,6 +683,78 @@ pub fn bind_to_session(command: &mut Command) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn audio_mapping_generates_monotonic_timestamps() {
|
||||
let arguments = |system_audio, microphone| {
|
||||
let mut command = Command::new("ffmpeg");
|
||||
let enabled = map_audio(&mut command, system_audio, microphone);
|
||||
let arguments = command
|
||||
.get_args()
|
||||
.map(|argument| argument.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>();
|
||||
(enabled, arguments)
|
||||
};
|
||||
assert_eq!(arguments(false, false), (false, vec![]));
|
||||
for inputs in [(true, false), (false, true)] {
|
||||
assert_eq!(
|
||||
arguments(inputs.0, inputs.1),
|
||||
(
|
||||
true,
|
||||
vec!["-map", "1:a:0", "-af", "asetpts=(N+120)/SR/TB"]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
arguments(true, true),
|
||||
(
|
||||
true,
|
||||
vec![
|
||||
"-filter_complex",
|
||||
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0,asetpts=(N+120)/SR/TB[a]",
|
||||
"-map",
|
||||
"[a]",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn cast_recording_stale_indicator_does_not_stop_new_recording_and_stream_stop_finishes() {
|
||||
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
|
||||
let mut cast=Casting::new(dir.path().into(),dir.path().into());
|
||||
assert!(cast.start_recording(&output).is_err());
|
||||
cast.started=Some(Instant::now());
|
||||
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
|
||||
assert!(cast.start_recording(&output).is_err());
|
||||
assert!(cast.start_preview(&Settings::default(),dir.path()).is_err());
|
||||
cast.stop_recording_id("old-recording-id");assert!(cast.recording.is_some());
|
||||
let id=cast.recording.as_ref().unwrap().id.clone();cast.stop_recording_id(&id);
|
||||
assert!(cast.recording.is_none());assert!(cast.started.is_some());
|
||||
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
|
||||
cast.stop();assert!(cast.recording.is_none());assert!(cast.started.is_none());
|
||||
assert_eq!(fs::read_to_string(cast.recording_path.as_ref().unwrap()).unwrap(),"header\ntrailer\n");
|
||||
}
|
||||
#[test]
|
||||
fn cast_recording_process_failure_keeps_stream_and_reports_error() {
|
||||
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
|
||||
let mut cast=Casting::new(dir.path().into(),dir.path().into());cast.started=Some(Instant::now());
|
||||
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,true));
|
||||
let end=Instant::now()+Duration::from_secs(3);
|
||||
while cast.recording.is_some()&&Instant::now()<end {cast.maintain();std::thread::sleep(Duration::from_millis(25));}
|
||||
assert!(cast.recording.is_none());assert!(cast.started.is_some());assert!(cast.recording_error.is_some());
|
||||
}
|
||||
#[test]
|
||||
fn copied_video_packets_receive_frame_timestamps() {
|
||||
assert_eq!(
|
||||
video_packet_timestamps(60),
|
||||
"setts=time_base=1/60:pts=N:dts=N:duration=1"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn media_worker_survives_the_http_request_thread() {
|
||||
let mut child = std::thread::spawn(|| {
|
||||
let mut command = Command::new("sleep");
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Persistent, watch-only capability; deliberately separate from cast settings.
|
||||
use anyhow::{ensure, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fs, io::Write, os::unix::fs::OpenOptionsExt, path::Path};
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct Grant {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
impl Grant {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let bytes = match fs::read(root.join("cast-watch-access.json")) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let grant: Self = serde_json::from_slice(&bytes)?;
|
||||
ensure!(grant.token.as_ref().is_none_or(|t| t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())), "Invalid watch access record");
|
||||
Ok(grant)
|
||||
}
|
||||
pub fn save(root: &Path, enabled: bool) -> Result<Self> {
|
||||
let mut grant = Self::load(root)?;
|
||||
if enabled && grant.token.is_none() {
|
||||
grant.token = Some(crate::session::cast_random_key());
|
||||
} else if !enabled {
|
||||
grant.token = None;
|
||||
}
|
||||
let temporary = root.join("cast-watch-access.json.tmp");
|
||||
let mut file = fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(&temporary)?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))?;
|
||||
let result = (|| -> Result<()> {
|
||||
file.write_all(&serde_json::to_vec(&grant)?)?;
|
||||
file.sync_all()?;
|
||||
fs::rename(&temporary, root.join("cast-watch-access.json"))?;
|
||||
fs::File::open(root)?.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() { let _ = fs::remove_file(&temporary); }
|
||||
result?;
|
||||
Ok(grant)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Access {
|
||||
pub token: Option<String>,
|
||||
pub sessions: std::collections::BTreeMap<String, String>,
|
||||
pub revoked: Vec<String>,
|
||||
}
|
||||
impl Access {
|
||||
pub fn accepts(&self, candidate: &str) -> bool {
|
||||
self.token.as_deref().is_some_and(|token| {
|
||||
token.len() == candidate.len() && token.bytes().zip(candidate.bytes()).fold(0u8, |diff, (a,b)| diff | (a ^ b)) == 0
|
||||
})
|
||||
}
|
||||
pub fn replace(&mut self, token: Option<String>) {
|
||||
if self.token != token {
|
||||
self.revoked.extend(std::mem::take(&mut self.sessions).into_values());
|
||||
self.token = token;
|
||||
}
|
||||
}
|
||||
pub fn path(&self) -> String {
|
||||
match &self.token {
|
||||
Some(token) => format!("/cast/watch?token={token}"),
|
||||
None => "/cast/watch".into(),
|
||||
}
|
||||
}
|
||||
pub fn disconnect_revoked(&mut self) {
|
||||
let client = ureq::AgentBuilder::new().redirects(0).timeout(std::time::Duration::from_secs(2)).build();
|
||||
self.revoked.retain(|target| {
|
||||
match client.delete(target).set("If-Match", "*").call() {
|
||||
Ok(_) | Err(ureq::Error::Status(404 | 410, _)) => false,
|
||||
Err(_) => true,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
#[test]
|
||||
fn watch_access_persists_rotates_and_preserves_on_failure() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
assert!(Grant::load(root.path()).unwrap().token.is_none());
|
||||
let first = Grant::save(root.path(), true).unwrap().token.unwrap();
|
||||
assert_eq!(first.len(), 64);
|
||||
assert_eq!(Grant::load(root.path()).unwrap().token.as_ref(), Some(&first));
|
||||
assert_eq!(Grant::save(root.path(), true).unwrap().token.as_ref(), Some(&first));
|
||||
assert_eq!(fs::metadata(root.path().join("cast-watch-access.json")).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
fs::create_dir(root.path().join("cast-watch-access.json.tmp")).unwrap();
|
||||
assert!(Grant::save(root.path(), false).is_err());
|
||||
assert_eq!(Grant::load(root.path()).unwrap().token.as_ref(), Some(&first));
|
||||
fs::remove_dir(root.path().join("cast-watch-access.json.tmp")).unwrap();
|
||||
assert!(Grant::save(root.path(), false).unwrap().token.is_none());
|
||||
assert_ne!(Grant::save(root.path(), true).unwrap().token.unwrap(), first);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ impl Default for Jobs {
|
||||
}
|
||||
}
|
||||
impl Jobs {
|
||||
pub fn active_kind(&self, kind: &str) -> bool {
|
||||
self.state.lock().unwrap().jobs.values().any(|job| job.active && job.public["kind"] == kind)
|
||||
}
|
||||
fn expire(state: &mut State) {
|
||||
for job in state.jobs.values_mut() {
|
||||
if !job.active
|
||||
|
||||
@@ -21,6 +21,7 @@ mod relations;
|
||||
mod service;
|
||||
mod session;
|
||||
mod steam;
|
||||
mod storage_location;
|
||||
mod subscriptions;
|
||||
mod terminal;
|
||||
#[cfg(test)]
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Component, Path};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const API_VERSION: u32 = 1;
|
||||
|
||||
@@ -814,6 +814,8 @@ pub struct AgreementAcceptance {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Database {
|
||||
#[serde(default)]
|
||||
pub plugin_storage: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub casting: crate::cast::settings::Settings,
|
||||
#[serde(default = "default_language")]
|
||||
|
||||
+6
-6
@@ -133,11 +133,9 @@ pub fn open_web_link(url: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command(m: &Manifest, payload: &Path, manager: u32, logs: &Path, unit: &str) -> Result<Command> {
|
||||
fn command(m: &Manifest, payload: &Path, manager: u32, _logs: &Path, storage: &Path, unit: &str) -> Result<Command> {
|
||||
let b = m.backend.as_ref().context("Plugin has no backend")?;
|
||||
let home = logs
|
||||
.parent()
|
||||
.context("Missing state directory")?
|
||||
let home = storage
|
||||
.join("data")
|
||||
.join(&m.id)
|
||||
.join(match b.run_as {
|
||||
@@ -216,6 +214,7 @@ pub fn hook(
|
||||
payload: &Path,
|
||||
manager: u32,
|
||||
logs: &Path,
|
||||
storage: &Path,
|
||||
context: Value,
|
||||
timeout: Duration,
|
||||
) -> Result<()> {
|
||||
@@ -231,7 +230,7 @@ pub fn hook(
|
||||
ui_visibility_events: false,
|
||||
});
|
||||
let unit = format!("framely-hook-{}-{:032x}", m.id, rand::random::<u128>());
|
||||
let cmd = command(&launch, payload, manager, logs, &unit)?;
|
||||
let cmd = command(&launch, payload, manager, logs, storage, &unit)?;
|
||||
// Insert env/property arguments before the executable delimiter.
|
||||
let args: Vec<_> = cmd.get_args().map(|a| a.to_owned()).collect();
|
||||
let split = args
|
||||
@@ -295,9 +294,10 @@ impl Running {
|
||||
manager: u32,
|
||||
events: Events,
|
||||
logs: &Path,
|
||||
storage: &Path,
|
||||
) -> Result<Self> {
|
||||
let unit = format!("framely-backend-{}", m.id);
|
||||
let mut cmd = command(m, payload, manager, logs, &unit)?;
|
||||
let mut cmd = command(m, payload, manager, logs, storage, &unit)?;
|
||||
fs::create_dir_all(logs)?;
|
||||
cmd.stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().context("Cannot start plugin service")?;
|
||||
|
||||
+302
-30
@@ -43,11 +43,22 @@ pub struct Service {
|
||||
runtime: BTreeMap<String, Value>,
|
||||
waiting: BTreeSet<String>,
|
||||
batching: bool,
|
||||
storage_jobs: crate::jobs::Jobs,
|
||||
plugin_migration: bool,
|
||||
visibility: crate::visibility::Tracker,
|
||||
visibility_pending: BTreeSet<String>,
|
||||
launch_contexts: BTreeMap<String, Value>,
|
||||
}
|
||||
impl Service {
|
||||
fn plugin_store(&self) -> &Path {
|
||||
self.db.plugin_storage.as_deref().unwrap_or(&self.root)
|
||||
}
|
||||
fn ensure_plugin_store(&self) -> Result<()> {
|
||||
let path = self.plugin_store();
|
||||
let meta = if self.db.plugin_storage.is_some() { fs::symlink_metadata(path)? } else { fs::metadata(path)? };
|
||||
ensure!(meta.is_dir() && (self.db.plugin_storage.is_none() || !meta.file_type().is_symlink()), "插件存储目录不可用:{}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
pub fn prepare_uninstall(&mut self) -> Result<Value> {
|
||||
self.cancel_automatic_check();
|
||||
self.updater.ensure_idle()?;
|
||||
@@ -78,7 +89,7 @@ impl Service {
|
||||
Value::Null,
|
||||
)
|
||||
.with_context(|| format!("插件 {id} 卸载失败,本体保留,请重试"))?;
|
||||
fs::remove_dir_all(self.root.join("plugins").join(id))?;
|
||||
fs::remove_dir_all(self.plugin_store().join("plugins").join(id))?;
|
||||
self.db.plugins.remove(id);
|
||||
self.db.notification_settings.plugins.remove(id);
|
||||
self.runtime.remove(id);
|
||||
@@ -99,6 +110,13 @@ impl Service {
|
||||
} else {
|
||||
Database::default()
|
||||
};
|
||||
let migration = root.join("plugin-storage-migration.json");
|
||||
if let Ok(bytes) = fs::read(&migration) {
|
||||
let record: Value = serde_json::from_slice(&bytes)?;
|
||||
if record["target"].as_str() == db.plugin_storage.as_deref().and_then(Path::to_str) {
|
||||
fs::remove_file(migration)?;
|
||||
}
|
||||
}
|
||||
db.update_check.validate()?;
|
||||
db.launcher.validate()?;
|
||||
db.casting.validate()?;
|
||||
@@ -124,8 +142,9 @@ impl Service {
|
||||
if let Ok(bytes) = fs::read(root.join("install-transaction.json")) {
|
||||
let journal: Value = serde_json::from_slice(&bytes)?;
|
||||
let original: Database = serde_json::from_value(journal["database"].clone())?;
|
||||
restore_replaced(root, &journal)?;
|
||||
restore_links(
|
||||
root,
|
||||
original.plugin_storage.as_deref().unwrap_or(root),
|
||||
&original,
|
||||
&serde_json::from_value::<Vec<String>>(journal["targets"].clone())?,
|
||||
)?;
|
||||
@@ -144,7 +163,7 @@ impl Service {
|
||||
crate::subscriptions::initialize(&mut db)?;
|
||||
// Rehydrate presentation after a state-schema downgrade from immutable payloads.
|
||||
for installed in db.plugins.values_mut() {
|
||||
let path = root
|
||||
let path = db.plugin_storage.as_deref().unwrap_or(root)
|
||||
.join("plugins")
|
||||
.join(&installed.manifest.id)
|
||||
.join("versions")
|
||||
@@ -203,6 +222,8 @@ impl Service {
|
||||
runtime: BTreeMap::new(),
|
||||
waiting: resume,
|
||||
batching: false,
|
||||
storage_jobs: Default::default(),
|
||||
plugin_migration: false,
|
||||
visibility: Default::default(),
|
||||
visibility_pending: BTreeSet::new(),
|
||||
launch_contexts: BTreeMap::new(),
|
||||
@@ -240,7 +261,7 @@ impl Service {
|
||||
fn payload(&self, id: &str) -> Result<PathBuf> {
|
||||
let p = self.plugin(id)?;
|
||||
Ok(self
|
||||
.root
|
||||
.plugin_store()
|
||||
.join("plugins")
|
||||
.join(id)
|
||||
.join("versions")
|
||||
@@ -288,7 +309,7 @@ impl Service {
|
||||
previous: Option<&str>,
|
||||
extra: Value,
|
||||
) -> Value {
|
||||
json!({"pluginId":m.id,"phase":phase,"reason":reason,"version":m.version,"previousVersion":previous,"dataDir":self.root.join("data").join(&m.id).join(match m.run_as(){RunAs::Steamos=>"steamos",RunAs::Root=>"root"}),"exit":extra,"launchContext":self.launch_contexts.get(&format!("{}/quick",m.id))})
|
||||
json!({"pluginId":m.id,"phase":phase,"reason":reason,"version":m.version,"previousVersion":previous,"dataDir":self.plugin_store().join("data").join(&m.id).join(match m.run_as(){RunAs::Steamos=>"steamos",RunAs::Root=>"root"}),"exit":extra,"launchContext":self.launch_contexts.get(&format!("{}/quick",m.id))})
|
||||
}
|
||||
fn command_hook(
|
||||
&mut self,
|
||||
@@ -315,6 +336,7 @@ impl Service {
|
||||
payload,
|
||||
self.manager,
|
||||
&self.root.join("logs"),
|
||||
self.plugin_store(),
|
||||
self.context(m, phase, reason, previous, extra),
|
||||
Duration::from_secs(l.timeout_seconds),
|
||||
);
|
||||
@@ -376,6 +398,7 @@ impl Service {
|
||||
self.manager,
|
||||
self.events.clone(),
|
||||
&self.root.join("logs"),
|
||||
self.plugin_store(),
|
||||
)?;
|
||||
if let Some(l) = m.lifecycle.as_ref().filter(|l| l.on_start) {
|
||||
running.call_timeout(
|
||||
@@ -634,6 +657,7 @@ impl Service {
|
||||
}
|
||||
}
|
||||
pub fn maintenance(&mut self) {
|
||||
if self.plugin_migration { return; }
|
||||
self.expire_notifications();
|
||||
if self.visibility.expire(Instant::now()) {
|
||||
self.publish_visibility();
|
||||
@@ -736,6 +760,9 @@ impl Service {
|
||||
.is_some_and(|a| a.version == AGREEMENT_VERSION && a.accepted_at > 0)
|
||||
}
|
||||
pub fn handle(&mut self, method: &str, p: Value) -> Result<Value> {
|
||||
if self.plugin_migration && (method.starts_with("plugin.") || matches!(method, "install" | "install.batch" | "safeMode" | "system.uninstall.prepare")) {
|
||||
bail!("插件存储正在迁移,请等待完成");
|
||||
}
|
||||
ensure!(
|
||||
self.agreement_accepted()
|
||||
|| matches!(
|
||||
@@ -752,6 +779,7 @@ impl Service {
|
||||
| "agreement.revoke"
|
||||
| "network.password.verify"
|
||||
| "network.password.configured"
|
||||
| "cast.watchAccess.get"
|
||||
| "network.password.setup"
|
||||
| "system.uninstall.prepare"
|
||||
),
|
||||
@@ -766,6 +794,18 @@ impl Service {
|
||||
}
|
||||
let id = p["plugin"].as_str().unwrap_or("").to_owned();
|
||||
match method {
|
||||
"storage.plugin.status" => {
|
||||
let store = self.plugin_store();
|
||||
let (program_bytes, program_incomplete) = crate::storage_location::disk_bytes(&store.join("plugins"))?;
|
||||
let (data_bytes, data_incomplete) = crate::storage_location::disk_bytes(&store.join("data"))?;
|
||||
let details = self.db.plugins.iter().map(|(id, installed)| {
|
||||
let (program, a) = crate::storage_location::disk_bytes(&store.join("plugins").join(id))?;
|
||||
let (data, b) = crate::storage_location::disk_bytes(&store.join("data").join(id))?;
|
||||
Ok(json!({"id":id,"name":installed.manifest.name,"bytes":program.saturating_add(data),"programBytes":program,"dataBytes":data,"sizeIncomplete":a || b}))
|
||||
}).collect::<Result<Vec<Value>>>()?;
|
||||
let device = fs::metadata(store).ok().map(|m| std::os::unix::fs::MetadataExt::dev(&m));
|
||||
Ok(json!({"path":store,"bytes":program_bytes.saturating_add(data_bytes),"sizeIncomplete":program_incomplete || data_incomplete,"device":device,"details":details,"available":self.ensure_plugin_store().is_ok(),"migrating":self.plugin_migration,"interrupted":fs::read(self.root.join("plugin-storage-migration.json")).ok().and_then(|b|serde_json::from_slice::<Value>(&b).ok()).map(|v|v["target"].clone())}))
|
||||
},
|
||||
"host.ui.visibility" => {
|
||||
let views = serde_json::from_value(p)?;
|
||||
if self.visibility.report(views, Instant::now())? {
|
||||
@@ -1007,9 +1047,9 @@ impl Service {
|
||||
self.db.notification_settings.plugins.remove(&id);
|
||||
self.notifications.retain(|_, n| n.plugin != id);
|
||||
self.save_notifications()?;
|
||||
fs::remove_dir_all(self.root.join("plugins").join(&id))?;
|
||||
fs::remove_dir_all(self.plugin_store().join("plugins").join(&id))?;
|
||||
if p["purge"].as_bool() == Some(true) {
|
||||
let data = self.root.join("data").join(&id);
|
||||
let data = self.plugin_store().join("data").join(&id);
|
||||
if data.exists() {
|
||||
fs::remove_dir_all(data)?;
|
||||
}
|
||||
@@ -1081,6 +1121,11 @@ impl Service {
|
||||
self.save()?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
"cast.watchAccess.get" => Ok(serde_json::to_value(crate::cast::watch_access::Grant::load(&self.root)?)?),
|
||||
"cast.watchAccess.save" => {
|
||||
let enabled = p["enabled"].as_bool().context("Missing enabled")?;
|
||||
Ok(serde_json::to_value(crate::cast::watch_access::Grant::save(&self.root, enabled)?)?)
|
||||
}
|
||||
"cast.settings.save" => {
|
||||
let settings: crate::cast::settings::Settings = serde_json::from_value(p)?;
|
||||
settings.validate()?;
|
||||
@@ -2024,35 +2069,41 @@ impl Service {
|
||||
"Unknown installation source"
|
||||
);
|
||||
}
|
||||
let base = self.root.join("plugins").join(&id);
|
||||
let base = self.plugin_store().join("plugins").join(&id);
|
||||
self.ensure_plugin_store()?;
|
||||
fs::create_dir_all(base.join("versions"))?;
|
||||
let dest = base.join("versions").join(&v.manifest.version);
|
||||
let newly_created = !dest.exists();
|
||||
if newly_created {
|
||||
let stage = base.join(format!(".stage-{}", rand::random::<u64>()));
|
||||
if let Err(e) = package::unpack(&v, &stage) {
|
||||
let _ = fs::remove_dir_all(&stage);
|
||||
return Err(e);
|
||||
}
|
||||
fs::rename(stage, &dest)?;
|
||||
} else {
|
||||
let cached: Manifest = serde_json::from_slice(&fs::read(dest.join("manifest.json"))?)?;
|
||||
ensure!(
|
||||
serde_json::to_value(&cached)? == serde_json::to_value(&v.manifest)?,
|
||||
"同一插件版本的发布内容发生变化,拒绝替换"
|
||||
);
|
||||
for (file, expected) in &v.manifest.files {
|
||||
let path = dest.join(file);
|
||||
ensure!(
|
||||
fs::symlink_metadata(&path)?.file_type().is_file()
|
||||
&& package::digest(&fs::read(path)?) == *expected,
|
||||
"本机版本文件校验失败:{file}"
|
||||
);
|
||||
}
|
||||
let stage = base.join(format!(".stage-{}", rand::random::<u64>()));
|
||||
if let Err(e) = package::unpack(&v, &stage) {
|
||||
let _ = fs::remove_dir_all(&stage);
|
||||
return Err(e);
|
||||
}
|
||||
let was_running = self.running.contains_key(&id);
|
||||
let old = self.db.plugins.get(&id).cloned();
|
||||
if !newly_created {
|
||||
if let Err(e) = record_replacement(&self.root, &id, &v.manifest.version, &self.db, was_running, self.batching) {
|
||||
let _ = fs::remove_dir_all(&stage);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
self.stop_reason(&id, if old.is_some() { "update" } else { "install" });
|
||||
let switch = if !newly_created {
|
||||
let journal: Value = serde_json::from_slice(&fs::read(self.root.join("install-transaction.json"))?)?;
|
||||
let backup = replacement_path(&self.root, &journal, &id)?;
|
||||
fs::rename(&dest, &backup).and_then(|_| fs::rename(&stage, &dest))
|
||||
} else {
|
||||
fs::rename(&stage, &dest)
|
||||
};
|
||||
if let Err(error) = switch {
|
||||
let _ = fs::remove_dir_all(&stage);
|
||||
if !newly_created {
|
||||
restore_one_replacement(&self.root, &id)?;
|
||||
if !self.batching { clear_replacement_journal(&self.root)?; }
|
||||
}
|
||||
if !self.batching && was_running { let _ = self.start_reason(&id, "install-rollback"); }
|
||||
return Err(error.into());
|
||||
}
|
||||
let phase = if old.is_some() {
|
||||
"onUpdate"
|
||||
} else {
|
||||
@@ -2066,6 +2117,10 @@ impl Service {
|
||||
old.as_ref().map(|p| p.manifest.version.as_str()),
|
||||
Value::Null,
|
||||
) {
|
||||
if !newly_created {
|
||||
restore_one_replacement(&self.root, &id)?;
|
||||
if !self.batching { clear_replacement_journal(&self.root)?; }
|
||||
}
|
||||
if newly_created {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
}
|
||||
@@ -2100,6 +2155,10 @@ impl Service {
|
||||
fs::rename(link, base.join("current"))?;
|
||||
self.db.plugins.insert(id.clone(), installed);
|
||||
if let Err(e) = self.save() {
|
||||
if !newly_created {
|
||||
restore_one_replacement(&self.root, &id)?;
|
||||
if !self.batching { clear_replacement_journal(&self.root)?; }
|
||||
}
|
||||
if let Some(old) = old.clone() {
|
||||
let _ = fs::remove_file(base.join("current"));
|
||||
let _ = symlink(
|
||||
@@ -2125,6 +2184,10 @@ impl Service {
|
||||
if let Err(error) = self.start_reason(&id, "install") {
|
||||
// A startup hook failure is an installation failure, not a successful activation.
|
||||
self.stop_reason(&id, "install-rollback");
|
||||
if !newly_created {
|
||||
restore_one_replacement(&self.root, &id)?;
|
||||
clear_replacement_journal(&self.root)?;
|
||||
}
|
||||
if let Some(previous) = old {
|
||||
let _ = fs::remove_file(base.join("current"));
|
||||
let _ = symlink(
|
||||
@@ -2146,6 +2209,9 @@ impl Service {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
if !self.batching && !newly_created {
|
||||
clear_replacement_journal(&self.root)?;
|
||||
}
|
||||
Ok(json!(self.db.plugins[&id]))
|
||||
}
|
||||
fn disable_dependents(&mut self, id: &str, p: &Value) -> Result<()> {
|
||||
@@ -2361,7 +2427,9 @@ impl Service {
|
||||
self.stop_reason(id, "transaction-rollback");
|
||||
self.waiting.remove(id);
|
||||
}
|
||||
restore_links(&self.root, &original, &targets)?;
|
||||
let journal: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
restore_replaced(&self.root, &journal)?;
|
||||
restore_links(original.plugin_storage.as_deref().unwrap_or(&self.root), &original, &targets)?;
|
||||
self.db = original;
|
||||
self.save()?;
|
||||
for id in &running {
|
||||
@@ -2371,7 +2439,9 @@ impl Service {
|
||||
fs::remove_file(path)?;
|
||||
return Err(error).context("批量安装失败,已恢复原版本和启用状态");
|
||||
}
|
||||
let journal: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
fs::remove_file(path)?;
|
||||
discard_replacements(&self.root, &journal)?;
|
||||
fs::File::open(&self.root)?.sync_all()?;
|
||||
for id in impacted {
|
||||
process::event(
|
||||
@@ -2402,6 +2472,70 @@ fn restore_links(root: &Path, original: &Database, targets: &[String]) -> Result
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn replacement_path(root: &Path, journal: &Value, id: &str) -> Result<PathBuf> {
|
||||
valid_id(id)?;
|
||||
let name = journal["backups"][id]
|
||||
.as_str()
|
||||
.context("缺少同版本安装备份记录")?;
|
||||
ensure!(name.starts_with(".previous-") && !name.contains('/') && !name.contains('\\'), "无效插件备份路径");
|
||||
let original: Database = serde_json::from_value(journal["database"].clone())?;
|
||||
Ok(original.plugin_storage.as_deref().unwrap_or(root).join("plugins").join(id).join(name))
|
||||
}
|
||||
fn record_replacement(root: &Path, id: &str, version: &str, original: &Database, running: bool, batching: bool) -> Result<()> {
|
||||
let path = root.join("install-transaction.json");
|
||||
let mut journal: Value = if batching {
|
||||
serde_json::from_slice(&fs::read(&path)?)?
|
||||
} else {
|
||||
json!({"database": original, "targets": [id], "running": if running {vec![id]} else {vec![]}})
|
||||
};
|
||||
let name = format!(".previous-{}-{:016x}", version, rand::random::<u64>());
|
||||
ensure!(!name.contains('/') && !name.contains('\\'), "无效插件版本");
|
||||
journal["backups"][id] = json!(name);
|
||||
journal["replacementVersions"][id] = json!(version);
|
||||
let temporary = root.join("install-transaction.tmp");
|
||||
fs::write(&temporary, serde_json::to_vec(&journal)?)?;
|
||||
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
|
||||
fs::File::open(&temporary)?.sync_all()?;
|
||||
fs::rename(temporary, &path)?;
|
||||
fs::File::open(root)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
fn restore_one_replacement(root: &Path, id: &str) -> Result<()> {
|
||||
let journal: Value = serde_json::from_slice(&fs::read(root.join("install-transaction.json"))?)?;
|
||||
let backup = replacement_path(root, &journal, id)?;
|
||||
if backup.is_dir() {
|
||||
let version = journal["replacementVersions"][id].as_str().context("缺少替换版本")?;
|
||||
let original: Database = serde_json::from_value(journal["database"].clone())?;
|
||||
let dest = original.plugin_storage.as_deref().unwrap_or(root).join("plugins").join(id).join("versions").join(version);
|
||||
if dest.exists() { fs::remove_dir_all(&dest)?; }
|
||||
fs::rename(backup, dest)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn restore_replaced(root: &Path, journal: &Value) -> Result<()> {
|
||||
if let Some(backups) = journal["backups"].as_object() {
|
||||
for id in backups.keys() {
|
||||
restore_one_replacement(root, id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn discard_replacements(root: &Path, journal: &Value) -> Result<()> {
|
||||
if let Some(backups) = journal["backups"].as_object() {
|
||||
for id in backups.keys() {
|
||||
let path = replacement_path(root, journal, id)?;
|
||||
if path.exists() { fs::remove_dir_all(path)?; }
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn clear_replacement_journal(root: &Path) -> Result<()> {
|
||||
let path = root.join("install-transaction.json");
|
||||
let journal: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
fs::remove_file(path)?;
|
||||
fs::File::open(root)?.sync_all()?;
|
||||
discard_replacements(root, &journal)
|
||||
}
|
||||
fn plugin_restarts(db: &Database, id: &str) -> bool {
|
||||
db.plugins.get(id).is_some_and(|p| {
|
||||
p.enabled
|
||||
@@ -2423,6 +2557,105 @@ pub fn now_ms() -> u64 {
|
||||
.as_millis() as u64
|
||||
}
|
||||
fn dispatch(core: &Arc<Mutex<Service>>, method: &str, params: Value) -> Result<Value> {
|
||||
if method == "storage.plugin.job" {
|
||||
let service = core.lock().unwrap();
|
||||
return service.storage_jobs.status(params["job"].as_str().context("缺少任务 ID")?);
|
||||
}
|
||||
if method == "storage.plugin.start" {
|
||||
let path = PathBuf::from(params["path"].as_str().context("缺少目标路径")?);
|
||||
let (jobs, source, target, root, resume, follow_links) = {
|
||||
let mut service = core.lock().unwrap();
|
||||
ensure!(service.agreement_accepted(), "请先同意用户协议和隐私声明");
|
||||
ensure!(!service.plugin_migration, "插件存储正在迁移");
|
||||
ensure!(!service.root.join("install-transaction.json").exists(), "插件安装正在进行");
|
||||
let source = service.plugin_store().to_path_buf();
|
||||
service.ensure_plugin_store()?;
|
||||
let follow_links = service.db.plugin_storage.is_none();
|
||||
let root = service.root.clone();
|
||||
let journal = root.join("plugin-storage-migration.json");
|
||||
let (target, resume) = if journal.exists() {
|
||||
let record: Value = serde_json::from_slice(&fs::read(&journal)?)?;
|
||||
ensure!(record["source"] == json!(source), "存储迁移来源已改变,请检查原目录");
|
||||
if record["target"] == json!(path) {
|
||||
let resume = path.exists() && crate::storage_location::verified_selected(&source, &path, &["plugins".into(), "data".into()], follow_links)?;
|
||||
ensure!(!path.exists() || resume || (path.is_dir() && fs::read_dir(&path)?.next().is_none()), "上次迁移副本已与原目录不同,请选择新的空目录");
|
||||
(path.clone(), resume)
|
||||
} else { (crate::storage_location::validate_target(&source, &path, false)?, false) }
|
||||
} else {
|
||||
(crate::storage_location::validate_target(&source, &path, false)?, false)
|
||||
};
|
||||
service.plugin_migration = true;
|
||||
(service.storage_jobs.clone(), source, target, root, resume, follow_links)
|
||||
};
|
||||
let shared = core.clone();
|
||||
let task = jobs.task_progress("storage:plugin", move |cancel, progress| {
|
||||
let mut running = Vec::new();
|
||||
let result = (|| -> Result<Value> {
|
||||
crate::storage_location::write_journal(&root.join("plugin-storage-migration.json"), &json!({"source":source,"target":target}))?;
|
||||
progress(json!({"step":"stopping"}));
|
||||
{
|
||||
let mut service = shared.lock().unwrap();
|
||||
running = service.running.keys().cloned().collect();
|
||||
for id in running.iter().rev() { service.stop_reason(id, "storage-migration"); }
|
||||
service.recovery.clear();
|
||||
}
|
||||
cancel.check()?;
|
||||
if !resume { crate::storage_location::copy_selected(&source, &target, &["plugins".into(), "data".into()], follow_links, |step| progress(json!({"step":step})))?; }
|
||||
cancel.check()?;
|
||||
{
|
||||
let mut service = shared.lock().unwrap();
|
||||
let previous = service.db.plugin_storage.clone();
|
||||
service.db.plugin_storage = Some(target.clone());
|
||||
if let Err(error) = service.save() {
|
||||
service.db.plugin_storage = previous;
|
||||
return Err(error);
|
||||
}
|
||||
progress(json!({"step":"restarting"}));
|
||||
let restart = (|| -> Result<()> {
|
||||
for id in &running { service.start_reason(id, "storage-migration")?; }
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = restart {
|
||||
for id in &running { service.stop_reason(id, "storage-rollback"); }
|
||||
service.db.plugin_storage = previous;
|
||||
service.save()?;
|
||||
return Err(error).context("迁移后插件启动失败,已恢复原位置");
|
||||
}
|
||||
}
|
||||
fs::remove_file(root.join("plugin-storage-migration.json"))?;
|
||||
progress(json!({"step":"cleaning"}));
|
||||
let mut cleanup_errors = Vec::new();
|
||||
for name in ["plugins", "data"] {
|
||||
let old = source.join(name);
|
||||
if old.exists() {
|
||||
let remove = if follow_links && fs::symlink_metadata(&old).is_ok_and(|m|m.file_type().is_symlink()) {
|
||||
let physical = old.canonicalize();
|
||||
physical.and_then(|directory| {
|
||||
for entry in fs::read_dir(directory)? {
|
||||
let path = entry?.path();
|
||||
if path.is_dir() && !fs::symlink_metadata(&path)?.file_type().is_symlink() { fs::remove_dir_all(path)?; }
|
||||
else { fs::remove_file(path)?; }
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
} else { fs::remove_dir_all(old) };
|
||||
if let Err(error) = remove { cleanup_errors.push(error.to_string()); }
|
||||
}
|
||||
}
|
||||
Ok(json!({"path":target,"cleanupWarnings":cleanup_errors}))
|
||||
})();
|
||||
{
|
||||
let mut service = shared.lock().unwrap();
|
||||
if result.is_err() {
|
||||
for id in &running { let _ = service.start_reason(id, "storage-rollback"); }
|
||||
}
|
||||
service.plugin_migration = false;
|
||||
}
|
||||
result
|
||||
});
|
||||
if task.is_err() { core.lock().unwrap().plugin_migration = false; }
|
||||
return task;
|
||||
}
|
||||
if method == "network.password.verify" {
|
||||
let (root, verifier) = {
|
||||
let service = core.lock().unwrap();
|
||||
@@ -2578,6 +2811,45 @@ mod request_tests {
|
||||
assert_eq!(worker.join().unwrap(), json!(false));
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod storage_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn plugin_storage_migration_moves_files_and_survives_reload() {
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
let state = temp.path().join("state");
|
||||
let mut service = crate::tests::accepted_service(&state, unsafe { libc::geteuid() }).unwrap();
|
||||
let package = crate::tests::fixture(temp.path(), "test.storage", "1.0.0", 1, None);
|
||||
service.handle("install", json!({"package":base64::Engine::encode(&base64::engine::general_purpose::STANDARD, package),"approve":true})).unwrap();
|
||||
let data = state.join("data/test.storage/steamos/save.txt");
|
||||
fs::create_dir_all(data.parent().unwrap()).unwrap();
|
||||
fs::write(&data, "saved").unwrap();
|
||||
let persistent = temp.path().join("persistent");
|
||||
fs::create_dir(&persistent).unwrap();
|
||||
for name in ["plugins", "data"] {
|
||||
fs::rename(state.join(name), persistent.join(name)).unwrap();
|
||||
symlink(persistent.join(name), state.join(name)).unwrap();
|
||||
}
|
||||
let target = temp.path().join("relocated");
|
||||
let core = Arc::new(Mutex::new(service));
|
||||
let job = dispatch(&core, "storage.plugin.start", json!({"path":target})).unwrap()["job"].as_str().unwrap().to_owned();
|
||||
let result = loop {
|
||||
let status = dispatch(&core, "storage.plugin.job", json!({"job":job})).unwrap();
|
||||
if status["phase"] == "done" || status["phase"] == "failed" { break status; }
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
assert_eq!(result["phase"], "done", "{result}");
|
||||
assert_eq!(fs::read_to_string(target.join("data/test.storage/steamos/save.txt")).unwrap(), "saved");
|
||||
assert!(target.join("plugins/test.storage/versions/1.0.0/manifest.json").is_file());
|
||||
assert!(fs::symlink_metadata(state.join("plugins")).unwrap().file_type().is_symlink());
|
||||
assert!(fs::read_dir(persistent.join("plugins")).unwrap().next().is_none());
|
||||
assert!(fs::read_dir(persistent.join("data")).unwrap().next().is_none());
|
||||
drop(core);
|
||||
let loaded = Service::load(&state, unsafe { libc::geteuid() }).unwrap();
|
||||
assert_eq!(loaded.db.plugin_storage.as_deref(), Some(target.as_path()));
|
||||
assert!(loaded.payload("test.storage").unwrap().is_dir());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod notification_tests {
|
||||
|
||||
+310
-11
@@ -11,6 +11,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
static APK_STORAGE_API_GATE: Mutex<()> = Mutex::new(());
|
||||
|
||||
struct FrameAccess {
|
||||
plugin: String,
|
||||
@@ -36,6 +37,7 @@ struct Agent {
|
||||
files: Arc<crate::manager_files::Files>,
|
||||
catalog_cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
|
||||
casting: Mutex<crate::cast::runtime::Casting>,
|
||||
watch_access: Mutex<crate::cast::watch_access::Access>,
|
||||
receivers: Mutex<crate::cast::receiver::Receivers>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
@@ -74,6 +76,25 @@ fn push(q: &Mutex<VecDeque<Value>>, v: Value) {
|
||||
q.push_back(v);
|
||||
}
|
||||
impl Agent {
|
||||
fn watch_cookie(r: &Request) -> Option<String> {
|
||||
r.headers().iter().filter(|h| h.field.equiv("Cookie"))
|
||||
.flat_map(|h| h.value.as_str().split(';'))
|
||||
.find_map(|cookie| cookie.trim().strip_prefix("framely-watch=").map(str::to_owned))
|
||||
}
|
||||
fn watch_resource(path: &str, method: &Method) -> bool {
|
||||
(method == &Method::Get && matches!(path, "/cast/watch" | "/cast/access" | "/assets/app.js" | "/assets/app.css"))
|
||||
|| ((path == "/cast/whep" || path.starts_with("/cast/whep/"))
|
||||
&& matches!(method, Method::Post | Method::Patch | Method::Delete | Method::Options))
|
||||
}
|
||||
fn watch_expired(r: Request) -> Result<()> {
|
||||
r.respond(Response::from_string("<!doctype html><meta charset=\"utf-8\"><title>Framely</title><p>观看链接已失效</p>")
|
||||
.with_status_code(403)
|
||||
.with_header(header("Content-Type", "text/html; charset=utf-8"))
|
||||
.with_header(header("X-Framely-Watch-Expired", "1"))
|
||||
.with_header(header("Cache-Control", "no-store"))
|
||||
.with_header(header("Referrer-Policy", "no-referrer")))?;
|
||||
Ok(())
|
||||
}
|
||||
fn core(&self, m: &str, p: Value) -> Result<Value> {
|
||||
ipc::call(&self.socket, m, p)
|
||||
}
|
||||
@@ -180,10 +201,10 @@ impl Agent {
|
||||
plugin["manifest"]["files"].get(file).is_some(),
|
||||
"Unlisted asset"
|
||||
);
|
||||
let store = status["database"]["pluginStorage"].as_str().map(std::path::PathBuf::from).unwrap_or_else(|| self.state.clone());
|
||||
return send_file(
|
||||
r,
|
||||
&self
|
||||
.state
|
||||
&store
|
||||
.join("plugins")
|
||||
.join(id)
|
||||
.join("versions")
|
||||
@@ -228,8 +249,30 @@ impl Agent {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let configured = remote && self.core("network.password.configured", json!({}))? == true;
|
||||
if remote && (!configured || !self.network_authorized(&r)) {
|
||||
let watch_cookie = Self::watch_cookie(&r);
|
||||
if remote && path == "/cast/watch" && r.method() == &Method::Get {
|
||||
let token = r.url().split_once('?').and_then(|(_, query)| url::form_urlencoded::parse(query.as_bytes()).find(|(key,_)| key == "token").map(|(_,value)| value.into_owned()));
|
||||
if let Some(token) = token {
|
||||
if !self.watch_access.lock().unwrap().accepts(&token) { return Self::watch_expired(r); }
|
||||
r.respond(Response::empty(StatusCode(303))
|
||||
.with_header(header("Location", "/cast/watch"))
|
||||
.with_header(header("Set-Cookie", &format!("framely-watch={token}; Path=/; HttpOnly; SameSite=Strict")))
|
||||
.with_header(header("Cache-Control", "no-store"))
|
||||
.with_header(header("Referrer-Policy", "no-referrer")))?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let manager_authorized = remote && self.network_authorized(&r);
|
||||
let guest = remote && !manager_authorized && Self::watch_resource(&path, r.method())
|
||||
&& watch_cookie.as_ref().is_some_and(|token| self.watch_access.lock().unwrap().accepts(token));
|
||||
if remote && Self::watch_resource(&path, r.method()) && !manager_authorized && !guest
|
||||
&& (watch_cookie.is_some() || path == "/cast/access") { return Self::watch_expired(r); }
|
||||
if remote && path == "/cast/access" && (guest || manager_authorized) {
|
||||
r.respond(Response::empty(StatusCode(204)).with_header(header("Cache-Control", "no-store")))?;
|
||||
return Ok(());
|
||||
}
|
||||
let configured = remote && !guest && self.core("network.password.configured", json!({}))? == true;
|
||||
if remote && !guest && (!configured || !manager_authorized) {
|
||||
let status = self.core("status", json!({}))?;
|
||||
let protected = status["database"]["networkPanel"]["passwordEnabled"]
|
||||
.as_bool()
|
||||
@@ -365,6 +408,9 @@ impl Agent {
|
||||
r.as_reader().take(32769).read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.len() <= 32768, "Visibility report too large");
|
||||
let views: Value = serde_json::from_slice(&bytes)?;
|
||||
if let Some(id) = views["stopRecording"].as_str() {
|
||||
self.casting.lock().unwrap().stop_recording_id(id);
|
||||
}
|
||||
if views["views"].is_object() {
|
||||
self.core("host.ui.visibility",views["views"].clone())?;
|
||||
if let Some(ids)=views["closedCasts"].as_array(){ensure!(ids.len()<=32,"Too many closed casts");for id in ids{if let Some(id)=id.as_str(){let _=self.receivers.lock().unwrap().control(id,"stop",json!({}));}}}
|
||||
@@ -394,7 +440,7 @@ impl Agent {
|
||||
.unwrap_or(json!({}));
|
||||
return send_json(
|
||||
r,
|
||||
json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"]}),
|
||||
json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"],"recording":self.casting.lock().unwrap().recording_status()}),
|
||||
);
|
||||
}
|
||||
if let Some(rel) = path.strip_prefix("/plugin-assets/") {
|
||||
@@ -410,8 +456,8 @@ impl Agent {
|
||||
ensure!(!p.is_null(), "Unknown plugin");
|
||||
let m: Manifest = serde_json::from_value(p["manifest"].clone())?;
|
||||
ensure!(m.files.contains_key(file), "Unlisted asset");
|
||||
let root = self
|
||||
.state
|
||||
let store = status["database"]["pluginStorage"].as_str().map(std::path::PathBuf::from).unwrap_or_else(|| self.state.clone());
|
||||
let root = store
|
||||
.join("plugins")
|
||||
.join(id)
|
||||
.join("versions")
|
||||
@@ -510,6 +556,15 @@ impl Agent {
|
||||
}
|
||||
if let Some(rel) = path.strip_prefix("/assets/") {
|
||||
safe_path(rel)?;
|
||||
if guest {
|
||||
let mime = if path.ends_with(".js") { "text/javascript" } else { "text/css" };
|
||||
r.respond(Response::from_data(fs::read(self.assets.join(&path[1..]))?)
|
||||
.with_header(header("Content-Type", mime))
|
||||
.with_header(header("X-Content-Type-Options", "nosniff"))
|
||||
.with_header(header("Cache-Control", "no-store"))
|
||||
.with_header(header("Referrer-Policy", "no-referrer")))?;
|
||||
return Ok(());
|
||||
}
|
||||
return send_file(r, &self.assets.join(&path[1..]), true);
|
||||
}
|
||||
if let Some(key) = path.strip_prefix("/boot/") {
|
||||
@@ -537,7 +592,7 @@ impl Agent {
|
||||
}
|
||||
ensure!(
|
||||
if remote {
|
||||
self.network_authorized(&r)
|
||||
guest || self.network_authorized(&r)
|
||||
} else {
|
||||
self.authorized(&r)
|
||||
},
|
||||
@@ -790,11 +845,20 @@ impl Agent {
|
||||
);
|
||||
}
|
||||
if path == "/cast/whep" || path.starts_with("/cast/whep/") {
|
||||
// Serialize guest creation/registration with revocation: disabling
|
||||
// cannot miss a WHEP session that is still being created upstream.
|
||||
let mut grant = if guest { Some(self.watch_access.lock().unwrap()) } else { None };
|
||||
if let Some(access) = grant.as_ref() {
|
||||
if !watch_cookie.as_ref().is_some_and(|token| access.accepts(token)) { return Self::watch_expired(r); }
|
||||
}
|
||||
ensure!(matches!(r.method(), Method::Post | Method::Patch | Method::Delete | Method::Options), "Invalid WHEP method");
|
||||
let origin = r.headers().iter().find(|h| h.field.equiv("Origin")).map(|h|h.value.as_str());
|
||||
ensure!(origin == Some(expected_origin.as_str()), "Cross-origin playback request denied");
|
||||
if r.method()==&Method::Options {r.respond(Response::empty(StatusCode(204)).with_header(header("Accept-Post","application/sdp")))?;return Ok(());}
|
||||
let suffix = path.strip_prefix("/cast/whep").unwrap();
|
||||
if let Some(access) = grant.as_ref() {
|
||||
if !suffix.is_empty() && !access.sessions.contains_key(&path) { return Self::watch_expired(r); }
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
r.as_reader().take(65537).read_to_end(&mut body)?;
|
||||
ensure!(body.len() <= 65536, "SDP too large");
|
||||
@@ -818,7 +882,7 @@ impl Agent {
|
||||
}
|
||||
let response = match request.send_bytes(&body) { Ok(v) => v, Err(ureq::Error::Status(_,v)) => v, Err(e) => return Err(e.into()) };
|
||||
let code = response.status();
|
||||
let mut headers = vec![header("Cache-Control", "no-store")];
|
||||
let mut headers = vec![header("Cache-Control", "no-store"), header("Referrer-Policy", "no-referrer")];
|
||||
for name in ["Content-Type", "ETag", "Accept-Patch"] {
|
||||
if let Some(value) = response.header(name) { headers.push(header(name,value)); }
|
||||
}
|
||||
@@ -827,6 +891,14 @@ impl Agent {
|
||||
let prefix=format!("/{media_path}/whep/");
|
||||
ensure!(parsed.path().starts_with(&prefix), "Invalid media session location");
|
||||
headers.push(header("Location", &format!("/cast/whep/{codec}/{}",parsed.path().strip_prefix(&prefix).unwrap())));
|
||||
if (200..300).contains(&code) {
|
||||
if let Some(access) = grant.as_mut() {
|
||||
access.sessions.insert(format!("/cast/whep/{codec}/{}", parsed.path().strip_prefix(&prefix).unwrap()), parsed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.method() == &Method::Delete && matches!(code, 200..=299 | 404 | 410) {
|
||||
if let Some(access) = grant.as_mut() { access.sessions.remove(&path); }
|
||||
}
|
||||
let mut bytes = Vec::new(); response.into_reader().take(65537).read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.len() <= 65536, "Media response too large");
|
||||
@@ -852,6 +924,10 @@ impl Agent {
|
||||
if remote && request["method"] == "ui.visibility.get" {
|
||||
request["params"] = json!({"view":""});
|
||||
}
|
||||
ensure!(
|
||||
!remote || !request["method"].as_str().is_some_and(|m| m.starts_with("apk.permission.")),
|
||||
"Headset permission window is local only"
|
||||
);
|
||||
let result = if remote && request["method"] == "agreement.decline" {
|
||||
Ok(json!({"closed":false}))
|
||||
} else {
|
||||
@@ -866,6 +942,7 @@ impl Agent {
|
||||
if path == "/"
|
||||
|| path == "/cast/watch"
|
||||
|| path.starts_with("/cast/window/")
|
||||
|| path == "/apk/permission"
|
||||
|| path == "/manager"
|
||||
|| path == "/notifications"
|
||||
|| path == "/launcher"
|
||||
@@ -883,7 +960,8 @@ impl Agent {
|
||||
} else {
|
||||
data
|
||||
};
|
||||
let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
|
||||
let data = if guest { String::from_utf8(data)?.replacen("<head>", "<head><meta name=\"framely-cast-guest\" content=\"true\">", 1).into_bytes() } else { data };
|
||||
let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Cache-Control","no-store")).with_header(header("Referrer-Policy","no-referrer")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
|
||||
r.respond(response)?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -892,6 +970,10 @@ impl Agent {
|
||||
}
|
||||
fn maintain_cast(&self) {
|
||||
self.casting.lock().unwrap().maintain();
|
||||
// Retry failed disconnects, without interrupting authenticated viewers.
|
||||
if let Ok(mut access) = self.watch_access.try_lock() {
|
||||
if !access.revoked.is_empty() { access.disconnect_revoked(); }
|
||||
}
|
||||
let events=self.receivers.lock().unwrap().poll();
|
||||
for event in events {
|
||||
let session=&event["session"];
|
||||
@@ -939,13 +1021,25 @@ impl Agent {
|
||||
match method {
|
||||
"cast.status" => {
|
||||
let mut value = self.casting.lock().unwrap().status();
|
||||
let access = self.watch_access.lock().unwrap();
|
||||
let watch_path = access.path();
|
||||
value["watchAccessEnabled"] = json!(access.token.is_some());
|
||||
value["watchPath"] = json!(watch_path);
|
||||
let receivers = self.receivers.lock().unwrap();
|
||||
value["sessions"] = json!(receivers.sessions.values().collect::<Vec<_>>());
|
||||
value["receiverError"] = json!(receivers.error);
|
||||
let network=self.network_status.lock().unwrap();
|
||||
value["watchAddresses"]=if network["enabled"]==true{json!(network_ipv4_addresses().into_iter().map(|ip|format!("http://{ip}:{}/cast/watch",network["port"])).collect::<Vec<_>>())}else{json!([])};
|
||||
value["watchAddresses"]=if network["enabled"]==true{json!(network_ipv4_addresses().into_iter().map(|ip|format!("http://{ip}:{}{watch_path}",network["port"])).collect::<Vec<_>>())}else{json!([])};
|
||||
Ok(value)
|
||||
},
|
||||
"cast.watchAccess.save" => {
|
||||
let mut access = self.watch_access.lock().unwrap();
|
||||
let result = self.core(method, p)?;
|
||||
access.replace(result["token"].as_str().map(str::to_owned));
|
||||
access.disconnect_revoked();
|
||||
Ok(json!({"enabled":access.token.is_some()}))
|
||||
},
|
||||
"cast.watchAccess.get" => anyhow::bail!("Internal watch access operation"),
|
||||
"cast.settings.save" => {
|
||||
let settings: crate::cast::settings::Settings = serde_json::from_value(p.clone())?;
|
||||
settings.validate()?;
|
||||
@@ -1007,6 +1101,23 @@ impl Agent {
|
||||
self.casting.lock().unwrap().start(&settings, panel.as_deref())
|
||||
}
|
||||
"cast.stop" => Ok(self.casting.lock().unwrap().stop()),
|
||||
"cast.record.start" => {
|
||||
let mut cast = self.casting.lock().unwrap();
|
||||
let auto_started = cast.status()["running"] != true;
|
||||
if auto_started {
|
||||
let status = self.core("status", json!({}))?;
|
||||
let settings: crate::cast::settings::Settings = serde_json::from_value(status["database"]["casting"].clone())?;
|
||||
let panel = if settings.source == crate::cast::settings::Source::Screen {
|
||||
let response = self.core("cast.panel.start", json!({}))?;
|
||||
Some(PathBuf::from(response["socket"].as_str().context("Missing panel socket")?))
|
||||
} else {None};
|
||||
cast.start(&settings, panel.as_deref())?;
|
||||
}
|
||||
let result = cast.start_recording(&crate::steam::home()?.join("Videos/framely"));
|
||||
if result.is_err() && auto_started {cast.stop();}
|
||||
result
|
||||
},
|
||||
"cast.record.stop" => self.casting.lock().unwrap().stop_recording(),
|
||||
"launcher.search.index" => {
|
||||
let names: Vec<String> = serde_json::from_value(p["names"].clone())?;
|
||||
ensure!(
|
||||
@@ -1035,6 +1146,8 @@ impl Agent {
|
||||
self.launcher_search.lock().unwrap().batch(&path, &names)
|
||||
}
|
||||
"apk.upload.start" => {
|
||||
let _gate = APK_STORAGE_API_GATE.lock().unwrap();
|
||||
ensure!(!self.jobs.active_kind("apk:storage"), "APK 存储正在迁移");
|
||||
let size = p["size"].as_u64().context("Missing APK upload size")?;
|
||||
let root = crate::apk::upload_dir(&crate::steam::home()?, size)?;
|
||||
self.jobs.uploads.start_in(size, &root)
|
||||
@@ -1069,7 +1182,37 @@ impl Agent {
|
||||
&crate::steam::home()?,
|
||||
p["ticket"].as_str().context("Missing APK review")?,
|
||||
),
|
||||
"apk.q2f.status" => crate::apk::q2f_status(&crate::steam::home()?),
|
||||
"apk.q2f.start" => {
|
||||
let _gate = APK_STORAGE_API_GATE.lock().unwrap();
|
||||
ensure!(!self.jobs.active_kind("apk:storage") && !self.jobs.active_kind("apk:operation"), "请等待 APK 操作或存储迁移完成");
|
||||
let home = crate::steam::home()?;
|
||||
let ticket = p["ticket"].as_str().context("Missing APK review")?.to_owned();
|
||||
self.jobs.task_progress("apk:operation", move |cancel, progress| {
|
||||
crate::apk::q2f_convert(&home, &ticket, &cancel, |value| { progress(value); Ok(()) })
|
||||
})
|
||||
}
|
||||
"apk.cleanup.list" => crate::apk::cleanup_list(&crate::steam::home()?),
|
||||
"apk.storage.status" => crate::apk::storage_status(&crate::steam::home()?),
|
||||
"apk.permission.pending" => Ok(json!(crate::apk::permissions::pending(&crate::steam::home()?)?)),
|
||||
"apk.permission.frame" => crate::apk::permissions::frame(
|
||||
&crate::steam::home()?, p["id"].as_str().context("Missing permission request")?
|
||||
),
|
||||
"apk.permission.click" => crate::apk::permissions::click(
|
||||
&crate::steam::home()?, p["id"].as_str().context("Missing permission request")?,
|
||||
p["x"].as_u64().context("Invalid Android input position")?.try_into()?,
|
||||
p["y"].as_u64().context("Invalid Android input position")?.try_into()?,
|
||||
),
|
||||
"apk.storage.start" => {
|
||||
let _gate = APK_STORAGE_API_GATE.lock().unwrap();
|
||||
ensure!(!self.jobs.uploads.active() && !self.jobs.active_kind("apk:storage") && !self.jobs.active_kind("apk:operation"), "请等待 APK 上传或操作完成");
|
||||
let home = crate::steam::home()?;
|
||||
let kind = p["kind"].as_str().context("缺少存储类型")?.to_owned();
|
||||
let path = std::path::PathBuf::from(p["path"].as_str().context("缺少目标目录")?);
|
||||
self.jobs.task_progress("apk:storage", move |cancel, progress| {
|
||||
crate::apk::migrate_storage(&home, &kind, &path, cancel, progress)
|
||||
})
|
||||
}
|
||||
"apk.logs" => crate::apk::logs(
|
||||
&crate::steam::home()?,
|
||||
p["app"].as_str().context("Missing APK application")?,
|
||||
@@ -1437,6 +1580,9 @@ impl Agent {
|
||||
Ok(json!(true))
|
||||
}
|
||||
"status"
|
||||
| "storage.plugin.status"
|
||||
| "storage.plugin.start"
|
||||
| "storage.plugin.job"
|
||||
| "inspect"
|
||||
| "install"
|
||||
| "plugin.restart"
|
||||
@@ -1857,6 +2003,10 @@ pub fn serve(
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))?;
|
||||
let agent = Arc::new(Agent {
|
||||
watch_access: Mutex::new(crate::cast::watch_access::Access {
|
||||
token: ipc::call(&socket, "cast.watchAccess.get", json!({}))?["token"].as_str().map(str::to_owned),
|
||||
..Default::default()
|
||||
}),
|
||||
casting: Mutex::new(crate::cast::runtime::Casting::new(runtime.join("cast"), crate::cast::runtime::binary_directory(&assets))),
|
||||
receivers: Mutex::new(crate::cast::receiver::Receivers::new(runtime.join("receive"), crate::cast::runtime::binary_directory(&assets))),
|
||||
socket,
|
||||
@@ -1884,6 +2034,34 @@ pub fn serve(
|
||||
}
|
||||
cast_agent.maintain_cast();std::thread::sleep(Duration::from_millis(100));
|
||||
}});
|
||||
let permission_agent = agent.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut shown = String::new();
|
||||
let mut missing = 0u8;
|
||||
loop {
|
||||
if let Ok(home) = crate::steam::home() {
|
||||
if let Ok(pending) = crate::apk::permissions::pending(&home) {
|
||||
let next = pending.as_ref().map(|p| p.id.clone()).unwrap_or_default();
|
||||
if next.is_empty() && !shown.is_empty() && missing < 1 {
|
||||
missing += 1;
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
continue;
|
||||
}
|
||||
missing = 0;
|
||||
if next != shown {
|
||||
if !shown.is_empty() {
|
||||
push(&permission_agent.commands, json!({"kind":"apk.permission.close"}));
|
||||
}
|
||||
if let Some(request) = pending {
|
||||
push(&permission_agent.commands, json!({"kind":"apk.permission.open","id":request.id}));
|
||||
}
|
||||
shown = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
});
|
||||
let file_agent = agent.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
if let Ok(home) = crate::steam::home() {
|
||||
@@ -2362,6 +2540,7 @@ mod tests {
|
||||
|
||||
fn agent(server: &Server, root: &Path) -> Arc<Agent> {
|
||||
Arc::new(Agent {
|
||||
watch_access: Mutex::default(),
|
||||
casting: Mutex::new(crate::cast::runtime::Casting::new(root.join("cast"), root.join("media"))),
|
||||
receivers: Mutex::new(crate::cast::receiver::Receivers::new(root.join("receive"), root.join("media"))),
|
||||
socket: root.join("control.sock"),
|
||||
@@ -2811,6 +2990,126 @@ mod tests {
|
||||
thread.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_access_limits_http_routes_and_revokes_only_guest_whep() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
let agent = agent(&server, root.path());
|
||||
let mut core = crate::tests::accepted_service(&agent.state, unsafe { libc::geteuid() }).unwrap();
|
||||
// Authentication is exercised through existing manager cookies, not a
|
||||
// password hash, so no password derivation is needed in this fixture.
|
||||
fs::write(agent.state.join("network-password.json"), b"{}").unwrap();
|
||||
core.db.network_panel.password_enabled = true;
|
||||
let socket = UnixListener::bind(&agent.socket).unwrap();
|
||||
socket.set_nonblocking(true).unwrap();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let core_stop = stop.clone();
|
||||
let core_worker = std::thread::spawn(move || {
|
||||
while !core_stop.load(Ordering::Relaxed) {
|
||||
if let Ok((mut stream,_)) = socket.accept() {
|
||||
let request = ipc::read(&mut stream).unwrap();
|
||||
let response = ipc::response(core.handle(request["method"].as_str().unwrap(), request["params"].clone()));
|
||||
ipc::write(&mut stream, &response).unwrap();
|
||||
} else { std::thread::sleep(Duration::from_millis(5)); }
|
||||
}
|
||||
});
|
||||
let worker_agent = agent.clone();
|
||||
let worker_stop = stop.clone();
|
||||
let worker = std::thread::spawn(move || {
|
||||
while !worker_stop.load(Ordering::Relaxed) {
|
||||
if let Ok(Some(r)) = server.recv_timeout(Duration::from_millis(50)) {
|
||||
let _ = worker_agent.handle_http(r, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
let media = Server::http("127.0.0.1:0").unwrap();
|
||||
agent.casting.lock().unwrap().test_whep_server(media.server_addr().to_ip().unwrap().port());
|
||||
let media_stop = stop.clone();
|
||||
let deleted = Arc::new(Mutex::new(Vec::new()));
|
||||
let media_deleted = deleted.clone();
|
||||
let (creating_tx, creating_rx) = std::sync::mpsc::channel();
|
||||
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
|
||||
let media_worker = std::thread::spawn(move || {
|
||||
let mut number = 0;
|
||||
while !media_stop.load(Ordering::Relaxed) {
|
||||
if let Ok(Some(r)) = media.recv_timeout(Duration::from_millis(50)) {
|
||||
if r.method() == &Method::Post {
|
||||
number += 1;
|
||||
if number == 3 {
|
||||
creating_tx.send(()).unwrap();
|
||||
finish_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
}
|
||||
r.respond(Response::from_string("answer").with_status_code(201)
|
||||
.with_header(header("Location", &format!("/headset/whep/00000000-0000-0000-0000-{number:012}")))).unwrap();
|
||||
} else if r.method() == &Method::Delete {
|
||||
media_deleted.lock().unwrap().push(r.url().to_owned());
|
||||
r.respond(Response::empty(StatusCode(204))).unwrap();
|
||||
} else { r.respond(Response::empty(StatusCode(400))).unwrap(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":true}})).unwrap();
|
||||
let token = agent.watch_access.lock().unwrap().token.clone().unwrap();
|
||||
let client = ureq::AgentBuilder::new().redirects(0).timeout(Duration::from_secs(3)).build();
|
||||
let origin = &agent.origin;
|
||||
let grant = client.get(&format!("{origin}/cast/watch?token={token}")).call().unwrap();
|
||||
assert_eq!(grant.status(), 303);
|
||||
assert_eq!(grant.header("Location"), Some("/cast/watch"));
|
||||
assert_eq!(grant.header("Referrer-Policy"), Some("no-referrer"));
|
||||
let cookie_header = grant.header("Set-Cookie").unwrap();
|
||||
assert!(cookie_header.contains("HttpOnly") && cookie_header.contains("SameSite=Strict"));
|
||||
let cookie = cookie_header.split(';').next().unwrap().to_owned();
|
||||
fs::remove_file(agent.state.join("network-password.json")).unwrap();
|
||||
let watch = client.get(&format!("{origin}/cast/watch")).set("Cookie", &cookie).call().unwrap();
|
||||
assert_eq!(watch.header("Cache-Control"), Some("no-store"));
|
||||
assert!(watch.into_string().unwrap().contains("framely-cast-guest"));
|
||||
for asset in ["app.js", "app.css"] {
|
||||
assert!(client.get(&format!("{origin}/assets/{asset}")).set("Cookie", &cookie).call().is_ok());
|
||||
}
|
||||
fs::write(agent.state.join("network-password.json"), b"{}").unwrap();
|
||||
let manager = client.get(&format!("{origin}/manager")).set("Cookie", &cookie).call().unwrap().into_string().unwrap();
|
||||
assert!(manager.contains("action=\"/login\""));
|
||||
for path in ["/api", "/manager-api/files", "/api/upload/a/0", "/host/poll"] {
|
||||
assert!(client.post(&format!("{origin}{path}")).set("Cookie", &cookie).set("Origin", origin).send_json(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).is_err());
|
||||
}
|
||||
assert!(client.get(&format!("{origin}/cast/watch?token=invalid")).call().is_err());
|
||||
assert!(client.post(&format!("{origin}/cast/whep")).set("Cookie", &cookie).set("Origin", "http://evil.test").send_string("a=rtpmap:96 H264/90000").is_err());
|
||||
let guest = client.post(&format!("{origin}/cast/whep")).set("Cookie", &cookie).set("Origin", origin).send_string("a=rtpmap:96 H264/90000").unwrap();
|
||||
assert_eq!(guest.status(), 201);
|
||||
let guest_resource = guest.header("Location").unwrap().to_owned();
|
||||
let manager_cookie = format!("framely-network={}", agent.network_key.lock().unwrap());
|
||||
let logged_in = client.post(&format!("{origin}/cast/whep")).set("Cookie", &manager_cookie).set("Origin", origin).send_string("a=rtpmap:96 H264/90000").unwrap();
|
||||
assert_eq!(logged_in.status(), 201);
|
||||
assert_eq!(agent.watch_access.lock().unwrap().sessions.len(), 1);
|
||||
agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).unwrap();
|
||||
assert_eq!(deleted.lock().unwrap().as_slice(), &["/headset/whep/00000000-0000-0000-0000-000000000001"]);
|
||||
assert!(client.get(&format!("{origin}/cast/access")).set("Cookie", &cookie).call().is_err());
|
||||
assert!(client.get(&format!("{origin}/cast/watch?token={token}")).call().is_err());
|
||||
assert!(client.delete(&format!("{origin}{guest_resource}")).set("Cookie", &cookie).set("Origin", origin).call().is_err());
|
||||
assert_eq!(client.get(&format!("{origin}/cast/access")).set("Cookie", &manager_cookie).call().unwrap().status(), 204);
|
||||
// Revoke while the upstream WHEP POST is in flight. Disabling must
|
||||
// wait for registration and then delete that new session as well.
|
||||
agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":true}})).unwrap();
|
||||
let new_token = agent.watch_access.lock().unwrap().token.clone().unwrap();
|
||||
assert_ne!(new_token, token);
|
||||
let race_origin = origin.clone();
|
||||
let creating = std::thread::spawn(move || {
|
||||
ureq::post(&format!("{race_origin}/cast/whep")).set("Cookie", &format!("framely-watch={new_token}"))
|
||||
.set("Origin", &race_origin).send_string("a=rtpmap:96 H264/90000").unwrap().status()
|
||||
});
|
||||
creating_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
assert!(agent.watch_access.try_lock().is_err());
|
||||
let disabling_agent = agent.clone();
|
||||
let disabling = std::thread::spawn(move || disabling_agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).unwrap());
|
||||
finish_tx.send(()).unwrap();
|
||||
assert_eq!(creating.join().unwrap(), 201);
|
||||
disabling.join().unwrap();
|
||||
assert_eq!(deleted.lock().unwrap().len(), 2);
|
||||
assert!(deleted.lock().unwrap()[1].ends_with("000000000003"));
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
worker.join().unwrap(); core_worker.join().unwrap(); media_worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_panel_requires_login_and_same_origin_and_blocks_native_routes() {
|
||||
let password = "密码".repeat(70);
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, ffi::CString, fs, io::Read, os::unix::fs::{MetadataExt, FileTypeExt, symlink, PermissionsExt}, path::{Path, PathBuf}, process::Command};
|
||||
|
||||
/// Disk blocks used by a tree. The root may be an intentional storage link
|
||||
/// (the default plugin directories are links); nested links and mounts are skipped.
|
||||
pub fn disk_bytes(path: &Path) -> Result<(u64, bool)> {
|
||||
let root = match fs::symlink_metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, false)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let path = if root.file_type().is_symlink() {
|
||||
match path.canonicalize() {
|
||||
Ok(path) => path,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, true)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
} else { path.to_path_buf() };
|
||||
let root = fs::symlink_metadata(&path)?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut total = 0u64;
|
||||
let mut incomplete = false;
|
||||
fn walk(path: &Path, dev: u64, seen: &mut std::collections::HashSet<(u64, u64)>, total: &mut u64, incomplete: &mut bool) -> Result<()> {
|
||||
let meta = match fs::symlink_metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if meta.dev() != dev || !seen.insert((meta.dev(), meta.ino())) { return Ok(()); }
|
||||
*total = total.saturating_add(meta.blocks().saturating_mul(512));
|
||||
if meta.is_dir() && !meta.file_type().is_symlink() {
|
||||
let entries = match fs::read_dir(path) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
for entry in entries {
|
||||
match entry {
|
||||
Ok(entry) => walk(&entry.path(), dev, seen, total, incomplete)?,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => *incomplete = true,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
walk(&path, root.dev(), &mut seen, &mut total, &mut incomplete)?;
|
||||
Ok((total, incomplete))
|
||||
}
|
||||
|
||||
pub fn volume(path: &Path) -> Result<Value> {
|
||||
let device = fs::metadata(path)?.dev();
|
||||
let path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?;
|
||||
let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
|
||||
let result = unsafe { libc::statvfs(path.as_ptr(), stat.as_mut_ptr()) };
|
||||
if result != 0 { return Err(std::io::Error::last_os_error().into()); }
|
||||
let stat = unsafe { stat.assume_init() };
|
||||
let block = stat.f_frsize as u64;
|
||||
let total = (stat.f_blocks as u64).saturating_mul(block);
|
||||
let free = (stat.f_bfree as u64).saturating_mul(block);
|
||||
Ok(serde_json::json!({"total":total,"free":free,"device":device}))
|
||||
}
|
||||
|
||||
pub fn bytes(path: &Path) -> Result<u64> {
|
||||
let meta = match fs::symlink_metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let root = if meta.file_type().is_symlink() { path.canonicalize()? } else { path.to_path_buf() };
|
||||
let mut total = 0u64;
|
||||
fn walk(path: &Path, total: &mut u64) -> Result<()> {
|
||||
let m = fs::symlink_metadata(path)?;
|
||||
if m.is_file() { *total = total.saturating_add(m.len()); }
|
||||
if m.is_dir() && !m.file_type().is_symlink() {
|
||||
for entry in fs::read_dir(path)? { walk(&entry?.path(), total)?; }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
walk(&root, &mut total)?;
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Legacy logical-size check retained for the migration permission test.
|
||||
#[cfg(test)]
|
||||
pub fn display_bytes(path: &Path) -> Result<(u64, bool)> {
|
||||
let meta = match fs::symlink_metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, false)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let root = if meta.file_type().is_symlink() {
|
||||
match path.canonicalize() {
|
||||
Ok(root) => root,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
} else { path.to_path_buf() };
|
||||
fn walk(path: &Path, total: &mut u64, incomplete: &mut bool) -> Result<()> {
|
||||
let meta = match fs::symlink_metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if meta.is_file() { *total = total.saturating_add(meta.len()); }
|
||||
if meta.is_dir() && !meta.file_type().is_symlink() {
|
||||
let entries = match fs::read_dir(path) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
for entry in entries { walk(&entry?.path(), total, incomplete)?; }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let (mut total, mut incomplete) = (0, false);
|
||||
walk(&root, &mut total, &mut incomplete)?;
|
||||
Ok((total, incomplete))
|
||||
}
|
||||
|
||||
fn inventory(path: &Path) -> Result<BTreeMap<PathBuf, String>> {
|
||||
let mut files = BTreeMap::new();
|
||||
fn walk(root: &Path, rel: &Path, files: &mut BTreeMap<PathBuf, String>) -> Result<()> {
|
||||
let path = root.join(rel);
|
||||
let meta = fs::symlink_metadata(&path)?;
|
||||
let value = if meta.file_type().is_symlink() {
|
||||
format!("link:{}:{}:{}", fs::read_link(&path)?.display(), meta.uid(), meta.gid())
|
||||
} else if meta.is_dir() {
|
||||
for entry in fs::read_dir(&path)? { walk(root, &rel.join(entry?.file_name()), files)?; }
|
||||
if rel.as_os_str().is_empty() { "directory".to_owned() }
|
||||
else { format!("directory:{}:{}:{}", meta.uid(), meta.gid(), meta.mode() & 0o7777) }
|
||||
} else if meta.is_file() {
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hash = Sha256::new();
|
||||
let mut buffer = [0u8; 65536];
|
||||
loop { let n = file.read(&mut buffer)?; if n == 0 { break; } hash.update(&buffer[..n]); }
|
||||
format!("sha256:{}:{}:{}:{}", hex::encode(hash.finalize()), meta.uid(), meta.gid(), meta.mode() & 0o7777)
|
||||
} else if meta.file_type().is_char_device() && meta.rdev() == 0 {
|
||||
"overlay-whiteout".to_owned()
|
||||
} else { anyhow::bail!("存储中含有无法迁移的文件:{}", path.display()); };
|
||||
files.insert(rel.to_path_buf(), value);
|
||||
Ok(())
|
||||
}
|
||||
walk(path, Path::new(""), &mut files)?;
|
||||
Ok(files)
|
||||
}
|
||||
pub fn verified_equal(source: &Path, target: &Path) -> Result<bool> {
|
||||
if !source.is_dir() || !target.is_dir() { return Ok(false); }
|
||||
let _source_readable = ReadableDirs::prepare(source)?;
|
||||
let _target_readable = ReadableDirs::prepare(target)?;
|
||||
Ok(inventory(source)? == inventory(target)?)
|
||||
}
|
||||
fn selected_source(source: &Path, name: &str, follow_root_links: bool) -> Result<PathBuf> {
|
||||
let path = source.join(name);
|
||||
let meta = match fs::symlink_metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(path),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
ensure!(follow_root_links || !meta.file_type().is_symlink(), "存储目录不能是链接:{}", path.display());
|
||||
let resolved = if meta.file_type().is_symlink() { path.canonicalize()? } else { path };
|
||||
ensure!(resolved.is_dir(), "存储项目不是目录:{}", resolved.display());
|
||||
Ok(resolved)
|
||||
}
|
||||
pub fn verified_selected(source: &Path, target: &Path, names: &[String], follow_root_links: bool) -> Result<bool> {
|
||||
if !target.is_dir() { return Ok(false); }
|
||||
let mut expected = std::collections::BTreeSet::new();
|
||||
for name in names {
|
||||
if selected_source(source, name, follow_root_links)?.is_dir() { expected.insert(name.clone()); }
|
||||
}
|
||||
let actual: std::collections::BTreeSet<String> = fs::read_dir(target)?.map(|entry| entry.map(|e|e.file_name().to_string_lossy().to_string())).collect::<std::io::Result<_>>()?;
|
||||
if actual != expected { return Ok(false); }
|
||||
for name in expected {
|
||||
if !verified_equal(&selected_source(source, &name, follow_root_links)?, &target.join(name))? { return Ok(false); }
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
pub fn write_journal(path: &Path, value: &Value) -> Result<()> {
|
||||
let temporary = path.with_extension("tmp");
|
||||
fs::write(&temporary, serde_json::to_vec(value)?)?;
|
||||
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
|
||||
fs::File::open(&temporary)?.sync_all()?;
|
||||
fs::rename(temporary, path)?;
|
||||
fs::File::open(path.parent().context("缺少存储状态目录")?)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_target(source: &Path, target: &Path, allow_root: bool) -> Result<PathBuf> {
|
||||
ensure!(target.is_absolute() && target != Path::new("/"), "请选择绝对目录路径");
|
||||
let parent = target.parent().context("缺少目标目录的上级目录")?.canonicalize()?;
|
||||
ensure!(parent.is_dir(), "目标目录的上级目录不存在");
|
||||
let target = parent.join(target.file_name().context("无效目录名称")?);
|
||||
ensure!(!target.starts_with("/tmp") && !target.starts_with("/proc") && !target.starts_with("/sys") && !target.starts_with("/dev") && !target.starts_with("/run/framely"), "不能在临时目录或系统目录保存数据");
|
||||
ensure!(allow_root || target.starts_with("/home") || target.starts_with("/run/media"), "请选择用户目录或已挂载存储");
|
||||
let source = source.canonicalize().unwrap_or_else(|_| source.to_path_buf());
|
||||
ensure!(source != target && !source.starts_with(&target) && !target.starts_with(&source), "目标目录不能位于原目录内部或包含原目录");
|
||||
if let Ok(meta) = fs::symlink_metadata(&target) {
|
||||
ensure!(meta.is_dir() && !meta.file_type().is_symlink(), "目标必须是空目录,不能是链接");
|
||||
ensure!(fs::read_dir(&target)?.next().is_none(), "目标目录必须为空");
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
fn prepare_space(parent: &Path, needed: u64) -> Result<()> {
|
||||
let c_path = CString::new(parent.as_os_str().as_encoded_bytes())?;
|
||||
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
ensure!(unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } == 0, "无法检查目标存储空间");
|
||||
let available = stat.f_bavail.saturating_mul(stat.f_frsize);
|
||||
ensure!(available >= needed.saturating_add(128 * 1024 * 1024), "目标存储空间不足");
|
||||
let probe = parent.join(format!(".framely-probe-{:016x}", rand::random::<u64>()));
|
||||
fs::create_dir(&probe)?;
|
||||
let result = (|| -> Result<()> {
|
||||
let file = probe.join("file");
|
||||
fs::write(&file, b"framely")?;
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o600))?;
|
||||
ensure!(fs::metadata(&file)?.permissions().mode() & 0o777 == 0o600, "目标文件系统不支持 Linux 文件权限");
|
||||
fs::hard_link(&file, probe.join("hardlink"))?;
|
||||
symlink("file", probe.join("symlink"))?;
|
||||
ensure!(fs::read_link(probe.join("symlink"))? == Path::new("file"), "目标文件系统不支持符号链接");
|
||||
Ok(())
|
||||
})();
|
||||
let _ = fs::remove_dir_all(probe);
|
||||
result
|
||||
}
|
||||
|
||||
struct ReadableDirs {
|
||||
root: PathBuf,
|
||||
changed: Vec<(PathBuf, u32)>,
|
||||
}
|
||||
impl ReadableDirs {
|
||||
fn prepare(root: &Path) -> Result<Self> {
|
||||
Self::prepare_with(root, 0o500)
|
||||
}
|
||||
fn prepare_with(root: &Path, required: u32) -> Result<Self> {
|
||||
let mut guard = Self { root: root.to_path_buf(), changed: Vec::new() };
|
||||
if root.exists() { guard.walk(root, required)?; }
|
||||
Ok(guard)
|
||||
}
|
||||
fn walk(&mut self, path: &Path, required: u32) -> Result<()> {
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
if !meta.is_dir() || meta.file_type().is_symlink() { return Ok(()); }
|
||||
let mode = meta.permissions().mode() & 0o7777;
|
||||
if mode & required != required {
|
||||
ensure!(meta.uid() == unsafe { libc::geteuid() } || unsafe { libc::geteuid() } == 0,
|
||||
"存储目录权限不足,无法迁移:{}", path.display());
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode | required))?;
|
||||
self.changed.push((path.strip_prefix(&self.root)?.to_path_buf(), mode));
|
||||
}
|
||||
for entry in fs::read_dir(path)? { self.walk(&entry?.path(), required)?; }
|
||||
Ok(())
|
||||
}
|
||||
fn restore_copy(&self, copy: &Path) -> Result<()> {
|
||||
for (relative, mode) in self.changed.iter().rev() {
|
||||
fs::set_permissions(copy.join(relative), fs::Permissions::from_mode(*mode))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for ReadableDirs {
|
||||
fn drop(&mut self) {
|
||||
for (relative, mode) in self.changed.iter().rev() {
|
||||
let _ = fs::set_permissions(self.root.join(relative), fs::Permissions::from_mode(*mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn remove_tree(path: &Path) -> Result<()> {
|
||||
let _readable = ReadableDirs::prepare_with(path, 0o700)?;
|
||||
fs::remove_dir_all(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn copy_verified(source: &Path, target: &Path, mut progress: impl FnMut(&str)) -> Result<()> {
|
||||
let target = validate_target(source, target, false)?;
|
||||
let readable = ReadableDirs::prepare(source)?;
|
||||
let parent = target.parent().unwrap();
|
||||
prepare_space(parent, bytes(source)?)?;
|
||||
let stage = parent.join(format!(".framely-storage-{:016x}", rand::random::<u64>()));
|
||||
fs::create_dir(&stage)?;
|
||||
let result = (|| -> Result<()> {
|
||||
progress("copying");
|
||||
if source.exists() {
|
||||
let output = Command::new("cp").arg("-a").arg("--reflink=auto")
|
||||
.arg("--").arg(source.join(".")).arg(&stage).output()?;
|
||||
ensure!(output.status.success(), "复制存储失败:{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
progress("verifying");
|
||||
let source_files = if source.exists() { inventory(source)? } else { BTreeMap::new() };
|
||||
let target_files = inventory(&stage)?;
|
||||
ensure!(source_files == target_files || (!source.exists() && target_files.len() == 1), "迁移后的文件校验失败,仍使用原位置");
|
||||
readable.restore_copy(&stage)?;
|
||||
progress("switching");
|
||||
if target.exists() { fs::remove_dir(&target)?; }
|
||||
fs::rename(&stage, &target)?;
|
||||
fs::File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() { let _ = fs::remove_dir_all(&stage); }
|
||||
result
|
||||
}
|
||||
|
||||
pub fn copy_selected(source: &Path, target: &Path, names: &[String], follow_root_links: bool, mut progress: impl FnMut(&str)) -> Result<()> {
|
||||
let target = validate_target(source, target, false)?;
|
||||
let parent = target.parent().unwrap();
|
||||
let mut needed = 0u64;
|
||||
let mut readable = Vec::new();
|
||||
for name in names {
|
||||
let from = selected_source(source, name, follow_root_links)?;
|
||||
readable.push((name.clone(), ReadableDirs::prepare(&from)?));
|
||||
needed = needed.saturating_add(bytes(&from)?);
|
||||
}
|
||||
prepare_space(parent, needed)?;
|
||||
let stage = parent.join(format!(".framely-storage-{:016x}", rand::random::<u64>()));
|
||||
fs::create_dir(&stage)?;
|
||||
let result = (|| -> Result<()> {
|
||||
progress("copying");
|
||||
for name in names {
|
||||
ensure!(!name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\'), "无效容器名称");
|
||||
let from = selected_source(source, name, follow_root_links)?;
|
||||
if !from.exists() { continue; }
|
||||
let output = Command::new("cp").args(["-a", "--reflink=auto", "--"])
|
||||
.arg(&from).arg(stage.join(name)).output()?;
|
||||
ensure!(output.status.success(), "复制容器失败:{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
progress("verifying");
|
||||
for name in names {
|
||||
let from = selected_source(source, name, follow_root_links)?;
|
||||
if from.exists() { ensure!(inventory(&from)? == inventory(&stage.join(name))?, "容器迁移校验失败:{name}"); }
|
||||
}
|
||||
for (name, guard) in &readable { if stage.join(name).exists() { guard.restore_copy(&stage.join(name))?; } }
|
||||
progress("switching");
|
||||
if target.exists() { fs::remove_dir(&target)?; }
|
||||
fs::rename(&stage, &target)?;
|
||||
fs::File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() { let _ = fs::remove_dir_all(&stage); }
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn copy_and_verify_preserve_data_and_reject_unsafe_targets() {
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
let source = temp.path().join("source");
|
||||
fs::create_dir(&source).unwrap();
|
||||
fs::write(source.join("saved.apk"), b"apk data").unwrap();
|
||||
symlink("saved.apk", source.join("current")).unwrap();
|
||||
let target = temp.path().join("moved");
|
||||
let mut steps = Vec::new();
|
||||
copy_verified(&source, &target, |step| steps.push(step.to_owned())).unwrap();
|
||||
assert_eq!(steps, ["copying", "verifying", "switching"]);
|
||||
assert_eq!(fs::read(target.join("saved.apk")).unwrap(), b"apk data");
|
||||
assert_eq!(fs::read_link(target.join("current")).unwrap(), Path::new("saved.apk"));
|
||||
assert_eq!(fs::read(source.join("saved.apk")).unwrap(), b"apk data");
|
||||
assert!(validate_target(&source, &source.join("inside"), false).is_err());
|
||||
assert!(copy_verified(&source, &target, |_| {}).is_err());
|
||||
assert!(prepare_space(temp.path(), u64::MAX).is_err());
|
||||
assert_eq!(fs::read(source.join("saved.apk")).unwrap(), b"apk data");
|
||||
}
|
||||
#[test]
|
||||
fn selected_copy_excludes_external_containers() {
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
let source = temp.path().join("contexts");
|
||||
fs::create_dir(&source).unwrap();
|
||||
for name in ["managed", "external"] {
|
||||
fs::create_dir(source.join(name)).unwrap();
|
||||
fs::write(source.join(name).join("state"), name).unwrap();
|
||||
}
|
||||
let work = source.join("managed/app_workdir/work");
|
||||
fs::create_dir_all(&work).unwrap();
|
||||
fs::write(work.join("overlay-state"), b"private").unwrap();
|
||||
fs::set_permissions(&work, fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let target = temp.path().join("android");
|
||||
copy_selected(&source, &target, &["managed".into()], false, |_| {}).unwrap();
|
||||
assert_eq!(fs::read_to_string(target.join("managed/state")).unwrap(), "managed");
|
||||
assert!(verified_selected(&source, &target, &["managed".into()], false).unwrap());
|
||||
fs::set_permissions(&work, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::set_permissions(target.join("managed/app_workdir/work"), fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(!target.join("external").exists());
|
||||
assert!(source.join("external/state").is_file());
|
||||
}
|
||||
#[test]
|
||||
fn plugin_roots_copy_real_data_instead_of_storage_links() {
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
let source = temp.path().join("state");
|
||||
let physical = temp.path().join("persistent-plugins");
|
||||
fs::create_dir(&source).unwrap();
|
||||
fs::create_dir(&physical).unwrap();
|
||||
fs::write(physical.join("manifest.json"), b"plugin").unwrap();
|
||||
symlink(&physical, source.join("plugins")).unwrap();
|
||||
let target = temp.path().join("moved");
|
||||
assert!(copy_selected(&source,&target,&["plugins".into()],false,|_|{}).is_err());
|
||||
copy_selected(&source,&target,&["plugins".into()],true,|_|{}).unwrap();
|
||||
assert!(target.join("plugins").is_dir());
|
||||
assert!(!fs::symlink_metadata(target.join("plugins")).unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read(target.join("plugins/manifest.json")).unwrap(),b"plugin");
|
||||
assert!(verified_selected(&source,&target,&["plugins".into()],true).unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn display_size_marks_unreadable_overlay_work_without_hiding_migration_error() {
|
||||
if unsafe { libc::geteuid() } == 0 { return; }
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
fs::write(temp.path().join("visible"), b"saved").unwrap();
|
||||
let work = temp.path().join("app_workdir/work");
|
||||
fs::create_dir_all(&work).unwrap();
|
||||
fs::write(work.join("overlay-state"), b"private").unwrap();
|
||||
fs::set_permissions(&work, fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let shown = display_bytes(temp.path());
|
||||
let strict = bytes(temp.path());
|
||||
assert_eq!(shown.unwrap(), (5, true));
|
||||
assert!(strict.is_err());
|
||||
let target = temp.path().parent().unwrap().join(format!("framely-storage-test-{:016x}", rand::random::<u64>()));
|
||||
copy_verified(temp.path(), &target, |_| {}).unwrap();
|
||||
assert_eq!(fs::metadata(&work).unwrap().permissions().mode() & 0o777, 0);
|
||||
let copied_work = target.join("app_workdir/work");
|
||||
assert_eq!(fs::metadata(&copied_work).unwrap().permissions().mode() & 0o777, 0);
|
||||
assert!(verified_equal(temp.path(), &target).unwrap());
|
||||
fs::set_permissions(&work, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::set_permissions(&copied_work, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert_eq!(fs::read(copied_work.join("overlay-state")).unwrap(), b"private");
|
||||
fs::remove_dir_all(target).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn disk_usage_does_not_follow_links_or_count_hardlinks_twice() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let file = temp.path().join("payload");
|
||||
fs::write(&file, vec![7; 8192]).unwrap();
|
||||
fs::hard_link(&file, temp.path().join("same-blocks")).unwrap();
|
||||
symlink(&file, temp.path().join("shortcut")).unwrap();
|
||||
let (used, incomplete) = disk_bytes(temp.path()).unwrap();
|
||||
let root_blocks = fs::metadata(temp.path()).unwrap().blocks() * 512;
|
||||
let file_blocks = fs::metadata(&file).unwrap().blocks() * 512;
|
||||
let link_blocks = fs::symlink_metadata(temp.path().join("shortcut")).unwrap().blocks() * 512;
|
||||
assert_eq!(used, root_blocks + file_blocks + link_blocks);
|
||||
assert!(!incomplete);
|
||||
}
|
||||
#[test]
|
||||
fn disk_usage_follows_storage_root_link_but_not_nested_links() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let target = temp.path().join("real-plugins");
|
||||
fs::create_dir(&target).unwrap();
|
||||
fs::write(target.join("plugin"), vec![3; 8192]).unwrap();
|
||||
let external = temp.path().join("unrelated");
|
||||
fs::write(&external, vec![4; 8192]).unwrap();
|
||||
symlink(&external, target.join("nested-link")).unwrap();
|
||||
let root = temp.path().join("plugins");
|
||||
symlink(&target, &root).unwrap();
|
||||
assert_eq!(disk_bytes(&root).unwrap(), disk_bytes(&target).unwrap());
|
||||
assert!(disk_bytes(&root).unwrap().0 > 0);
|
||||
}
|
||||
}
|
||||
+61
-4
@@ -253,11 +253,12 @@ fn selected_versions_and_reinstallation_preserve_favorites() {
|
||||
*data = serde_json::to_vec(&m).unwrap();
|
||||
}
|
||||
});
|
||||
assert!(install(&mut core, &altered, json!({}))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("同一插件版本"));
|
||||
install(&mut core, &altered, json!({})).unwrap();
|
||||
assert_eq!(core.db.plugins["test.plugin"].manifest.version, "1.0.0");
|
||||
assert_eq!(core.db.plugins["test.plugin"].manifest.name, "Changed release");
|
||||
assert!(core.db.plugins["test.plugin"].favorite);
|
||||
let disk: Value = serde_json::from_slice(&fs::read(state.join("plugins/test.plugin/versions/1.0.0/manifest.json")).unwrap()).unwrap();
|
||||
assert_eq!(disk["name"], "Changed release");
|
||||
assert!(!state
|
||||
.join("plugins/test.plugin/versions/1.0.0/public-key.hex")
|
||||
.exists());
|
||||
@@ -1145,6 +1146,60 @@ fn batch_hook_failure_and_interrupted_transaction_restore_state() {
|
||||
assert!(!restored.root.join("plugins/test.first").exists());
|
||||
}
|
||||
#[test]
|
||||
fn same_version_reinstall_restores_files_after_hook_failure_and_interruption() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _tools = fake_backend_tools(tmp.path());
|
||||
let state = tmp.path().join("state");
|
||||
let mut core = accepted_service(&state, unsafe { libc::geteuid() }).unwrap();
|
||||
let original = relation_package(tmp.path(), "test.same", "1.0.0", json!({}));
|
||||
install(&mut core, &original, json!({})).unwrap();
|
||||
let data = state.join("data/test.same/steamos/save");
|
||||
fs::create_dir_all(data.parent().unwrap()).unwrap();
|
||||
fs::write(&data, b"user data").unwrap();
|
||||
let broken = relation_package(tmp.path(), "test.same", "1.0.0", json!({"name":"broken","lifecycle":{"runAs":"steamos","onUpdate":{"entry":"fail.sh"}}}));
|
||||
assert!(install(&mut core, &broken, json!({})).is_err());
|
||||
let payload = state.join("plugins/test.same/versions/1.0.0");
|
||||
let restored: Value = serde_json::from_slice(&fs::read(payload.join("manifest.json")).unwrap()).unwrap();
|
||||
assert_eq!(restored["name"], "test.same");
|
||||
assert_eq!(fs::read(&data).unwrap(), b"user data");
|
||||
assert!(!state.join("install-transaction.json").exists());
|
||||
|
||||
let old_db = core.db.clone();
|
||||
let backup_name = ".previous-1.0.0-interrupted";
|
||||
fs::write(state.join("install-transaction.json"), serde_json::to_vec(&json!({"database":old_db,"targets":["test.same"],"running":[],"backups":{"test.same":backup_name},"replacementVersions":{"test.same":"1.0.0"}})).unwrap()).unwrap();
|
||||
fs::rename(&payload, state.join("plugins/test.same").join(backup_name)).unwrap();
|
||||
fs::create_dir(&payload).unwrap();
|
||||
fs::write(payload.join("manifest.json"), b"replaced").unwrap();
|
||||
let recovered = Service::load(&state, core.manager).unwrap();
|
||||
assert_eq!(recovered.db.plugins["test.same"].manifest.name, "test.same");
|
||||
let manifest: Value = serde_json::from_slice(&fs::read(payload.join("manifest.json")).unwrap()).unwrap();
|
||||
assert_eq!(manifest["name"], "test.same");
|
||||
assert_eq!(fs::read(&data).unwrap(), b"user data");
|
||||
}
|
||||
#[test]
|
||||
fn batch_failure_restores_same_version_payload() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _tools = fake_backend_tools(tmp.path());
|
||||
let mut core = accepted_service(&tmp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
|
||||
let old = relation_package(tmp.path(), "test.replace", "1.0.0", json!({}));
|
||||
install(&mut core, &old, json!({})).unwrap();
|
||||
let replacement = relation_package(tmp.path(), "test.replace", "1.0.0", json!({"name":"new payload"}));
|
||||
let bad = relation_package(tmp.path(), "test.fails", "1.0.0", json!({"lifecycle":{"runAs":"steamos","onInstall":{"entry":"fail.sh"}}}));
|
||||
let plan = crate::planner::Plan {
|
||||
fingerprint:crate::relations::fingerprint(&core.db).unwrap(), root:"test.fails".into(),
|
||||
items:vec![
|
||||
crate::planner::Item{manifest:package::verify(&replacement).unwrap().manifest,source:None,action:"reinstall".into(),run_as_changed:false},
|
||||
crate::planner::Item{manifest:package::verify(&bad).unwrap().manifest,source:None,action:"install".into(),run_as_changed:false},
|
||||
], add_sources:vec![], enable:vec!["test.fails".into()], disable:vec![], affected:vec!["test.replace".into(),"test.fails".into()],
|
||||
};
|
||||
let result = apply_prepared(&mut core, crate::planner::Prepared{plan,packages:vec![(package::Staged::from_bytes(&replacement).unwrap(),None),(package::Staged::from_bytes(&bad).unwrap(),None)]});
|
||||
assert!(result.is_err());
|
||||
let manifest:Value=serde_json::from_slice(&fs::read(core.root.join("plugins/test.replace/versions/1.0.0/manifest.json")).unwrap()).unwrap();
|
||||
assert_eq!(manifest["name"],"test.replace");
|
||||
assert_eq!(core.db.plugins["test.replace"].manifest.name,"test.replace");
|
||||
assert!(!core.root.join("install-transaction.json").exists());
|
||||
}
|
||||
#[test]
|
||||
fn cross_source_resolution_requires_choice_and_uses_the_chosen_package() {
|
||||
use tiny_http::{Response, Server};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1542,6 +1597,7 @@ fn hook_failure_does_not_stop_plugin_with_hook_suffix() {
|
||||
unsafe { libc::geteuid() },
|
||||
Default::default(),
|
||||
&tmp.path().join("logs"),
|
||||
tmp.path(),
|
||||
)
|
||||
.unwrap();
|
||||
let pid = tmp.path().join("framely-backend-review.foo-hook.pid");
|
||||
@@ -1575,6 +1631,7 @@ fn hook_failure_does_not_stop_plugin_with_hook_suffix() {
|
||||
&hook_payload,
|
||||
unsafe { libc::geteuid() },
|
||||
&tmp.path().join("logs"),
|
||||
tmp.path(),
|
||||
json!({"phase":"onInstall"}),
|
||||
std::time::Duration::from_secs(1),
|
||||
);
|
||||
|
||||
+29
-19
@@ -182,10 +182,10 @@ fn resolve_release(
|
||||
let Ok(version) = semver::Version::parse(tag) else {
|
||||
continue;
|
||||
};
|
||||
let testing = channel == UpdateChannel::Testing;
|
||||
let is_prerelease = !version.pre.is_empty();
|
||||
if release.draft
|
||||
|| release.prerelease != testing
|
||||
|| version.pre.is_empty() == testing
|
||||
|| release.prerelease != is_prerelease
|
||||
|| (channel == UpdateChannel::Stable && is_prerelease)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -242,7 +242,7 @@ fn resolve_release(
|
||||
let release = verify_descriptor(&bytes)?;
|
||||
let version = descriptor_version(&release.version)?;
|
||||
ensure!(
|
||||
version.pre.is_empty() == (channel == UpdateChannel::Stable),
|
||||
channel == UpdateChannel::Testing || version.pre.is_empty(),
|
||||
"更新清单不属于所选渠道,请配置该渠道的清单地址"
|
||||
);
|
||||
Ok(Some((release, bytes, version.to_string())))
|
||||
@@ -678,17 +678,29 @@ mod tests {
|
||||
let testing = resolve_fixture(UpdateChannel::Testing, releases)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(testing.2, "0.4.2-preview.10");
|
||||
assert_eq!(testing.2, "0.4.2");
|
||||
assert!(resolve_fixture(
|
||||
UpdateChannel::Stable,
|
||||
vec![fixture("0.4.2-preview.1", false)]
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(
|
||||
assert_eq!(
|
||||
resolve_fixture(UpdateChannel::Testing, vec![fixture("0.4.2", false)])
|
||||
.unwrap()
|
||||
.is_none()
|
||||
.unwrap()
|
||||
.2,
|
||||
"0.4.2"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_fixture(
|
||||
UpdateChannel::Testing,
|
||||
vec![fixture("0.4.2", false), fixture("0.4.3-preview.1", false)]
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.2,
|
||||
"0.4.3-preview.1"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
@@ -762,9 +774,10 @@ mod tests {
|
||||
.ends_with("framely-0.4.2-preview.1-012345abcdef-linux-arm64.tar.gz"));
|
||||
}
|
||||
#[test]
|
||||
fn custom_descriptors_distinguish_channels_and_ignore_packaging_hashes() {
|
||||
fn custom_descriptors_allow_stable_on_testing_and_ignore_packaging_hashes() {
|
||||
for (version, channel) in [
|
||||
("0.4.2", UpdateChannel::Stable),
|
||||
("0.4.2", UpdateChannel::Testing),
|
||||
("0.4.2-beta.1", UpdateChannel::Testing),
|
||||
] {
|
||||
let (_, bytes) = fixture(version, false);
|
||||
@@ -780,17 +793,14 @@ mod tests {
|
||||
.2,
|
||||
version
|
||||
);
|
||||
let other_channel = if channel == UpdateChannel::Stable {
|
||||
UpdateChannel::Testing
|
||||
} else {
|
||||
UpdateChannel::Stable
|
||||
};
|
||||
assert!(
|
||||
resolve_release(&source, other_channel, &Default::default(), &mut |_, _| Ok(
|
||||
bytes.clone()
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
if version.contains('-') {
|
||||
assert!(
|
||||
resolve_release(&source, UpdateChannel::Stable, &Default::default(), &mut |_, _| Ok(
|
||||
bytes.clone()
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,11 @@ struct Upload {
|
||||
touched: Instant,
|
||||
}
|
||||
impl Uploads {
|
||||
pub fn active(&self) -> bool {
|
||||
let mut slot = self.0.lock().unwrap();
|
||||
Self::expire(&mut slot);
|
||||
slot.is_some()
|
||||
}
|
||||
fn expire(slot: &mut Option<Upload>) {
|
||||
if slot
|
||||
.as_ref()
|
||||
|
||||
@@ -15,7 +15,7 @@ window.fetch=async(url,init)=>{if(String(url).startsWith('/host/files/')){const
|
||||
const id='job-'+counter++;let value:any=true;
|
||||
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true,gamepadEnabled,gamepadTriggerThreshold,autoStopContainer,stopContainerOnClose};
|
||||
else if(method==='apk.inspect.start')value={ticket:'review-ticket',metadata:meta('com.example.new','New APK'),bytes:4};
|
||||
else{if(params.operation==='lifecycle.settings'){autoStopContainer=params.autoStopContainer;stopContainerOnClose=params.stopContainerOnClose;}if(params.operation==='gamepad.settings'){gamepadEnabled=params.enabled;if(params.triggerThreshold!==undefined)gamepadTriggerThreshold=params.triggerThreshold;}const app=apps.find(a=>a.id===params.app);if(params.operation==='steam.settings')app.steamLaunch=params.enabled;if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall'&&!uninstallError)app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
else{if(params.operation==='lifecycle.settings'){autoStopContainer=params.autoStopContainer;stopContainerOnClose=params.stopContainerOnClose;}if(params.operation==='gamepad.settings'){gamepadEnabled=params.enabled;if(params.triggerThreshold!==undefined)gamepadTriggerThreshold=params.triggerThreshold;}const app=apps.find(a=>a.id===params.app);if(params.operation==='steam.settings')app.steamLaunch=params.enabled;if(params.operation==='settings'){if('steamName' in params)app.steamName=params.steamName.trim()||null;else Object.assign(app,{activity:params.activity,showWindow:params.showWindow});}if(params.operation==='uninstall'&&!uninstallError)app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
jobs.set(id,{id,kind:'apk',phase:method==='apk.operation.start'&&(fail||(params.operation==='uninstall'&&uninstallError))?'failed':'done',result:value,error:uninstallError??'Fixture signature mismatch',received:0});result={job:id};
|
||||
}else if(method==='job.status')result=jobs.get(params.job);
|
||||
return new Response(JSON.stringify({result}));};
|
||||
@@ -44,8 +44,17 @@ async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIE
|
||||
button('Containers').click();await until(()=>document.querySelectorAll('.apk-container').length===3);
|
||||
for(const title of ['Start container','Stop container']){button(title).click();await until(()=>!!document.querySelector('.apk-dialog'));if(document.querySelector('.apk-dialog')!.textContent!.includes(t('永久删除的数据无法撤销。')))throw Error('Container operation displayed data deletion warning');if(!button('Confirm',document.querySelector('.apk-dialog')!).classList.contains('primary'))throw Error('Non-destructive container confirmation uses destructive styling');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));}
|
||||
const external=document.querySelectorAll('.apk-container')[1];button('Delete container',external).click();await until(()=>document.querySelector('.apk-dialog'));if(!document.querySelector('.apk-dialog')!.textContent!.includes(t('容器数据、关联 APK 缓存和 Steam 库条目将永久删除,历史备份保留。')))throw Error('External deletion lacks data warning');button('Cancel',document.querySelector('.apk-dialog')!).click();await until(()=>!document.querySelector('.apk-dialog'));if(calls.some(c=>c.params.operation==='container.delete'))throw Error('Cancelled deletion modified external container');
|
||||
button('Apps').click();await until(()=>document.querySelectorAll('.apk-row').length===2);
|
||||
button('Manage',document.querySelector('.apk-row')!).click();await until(()=>document.querySelector('.apk-dialog'));const advanced=document.querySelector<HTMLDetailsElement>('.apk-advanced')!;advanced.open=true;document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Handle window display automatically"]')!.click();await until(()=>calls.some(c=>c.method==='apk.operation.start'&&c.params.operation==='settings'));await until(()=>!document.querySelector('progress'));await until(()=>document.querySelector('.apk-dialog')&&!button('Uninstall app',document.querySelector('.apk-dialog')!).disabled);
|
||||
button(t('应用')).click();await until(()=>document.querySelectorAll('.apk-row').length===2);
|
||||
button('Manage',document.querySelector('.apk-row')!).click();await until(()=>document.querySelector('.apk-dialog'));const steamName=()=>document.querySelector<HTMLInputElement>('.apk-steam-name input')!;
|
||||
if(!steamName()||steamName().placeholder!=='Wolvic')throw Error('Per-app Steam name setting missing');
|
||||
for(const name of ['My Wolvic','']){
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(steamName(),name);steamName().dispatchEvent(new Event('input',{bubbles:true}));await wait();
|
||||
button('Save',document.querySelector('.apk-steam-name')!).click();
|
||||
await until(()=>calls.some(c=>c.params.operation==='settings'&&c.params.steamName===name));
|
||||
await until(()=>!!steamName()&&!steamName().disabled);
|
||||
if(steamName().value!==name||apps[0].metadata.name!=='Wolvic')throw Error('Custom name changed original APK name');
|
||||
}
|
||||
const advanced=document.querySelector<HTMLDetailsElement>('.apk-advanced')!;advanced.open=true;document.querySelector<HTMLButtonElement>('[role=switch][aria-label="Handle window display automatically"]')!.click();await until(()=>calls.some(c=>c.method==='apk.operation.start'&&c.params.operation==='settings'));await until(()=>!document.querySelector('progress'));await until(()=>document.querySelector('.apk-dialog')&&!button('Uninstall app',document.querySelector('.apk-dialog')!).disabled);
|
||||
button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>document.querySelector('[role=switch][aria-label="Delete app data"]'));
|
||||
const purgeSwitch=()=>document.querySelector<HTMLButtonElement>('[aria-label="Delete app data"]')!;
|
||||
const deleteSwitch=()=>document.querySelector<HTMLButtonElement>('[aria-label="Delete container"]')!;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {build} from 'esbuild';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
|
||||
globalThis.document={documentElement:{lang:''}};
|
||||
const bundle=await build({stdin:{contents:`import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {RecordingButton} from './ui/src/cast-recording';export const render=(recording,disabled=false)=>renderToStaticMarkup(<RecordingButton recording={recording} disabled={disabled} onClick={()=>{}}/>);`,resolveDir:process.cwd(),loader:'tsx'},bundle:true,platform:'node',format:'cjs',write:false});
|
||||
const module={exports:{}};new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
|
||||
const idle=module.exports.render({active:false});assert.ok(idle.includes('开始录制'));assert.ok(!idle.includes('停止录制'));
|
||||
const active=module.exports.render({active:true,elapsedSeconds:125,path:'/home/steamos/Videos/framely/test.mkv'});
|
||||
assert.ok(active.includes('停止录制'));assert.ok(active.includes('02:05'));assert.ok(active.includes('test.mkv'));assert.ok(active.includes(' recording'));
|
||||
assert.ok(module.exports.render({active:true},true).includes('disabled=""'));
|
||||
for(const locale of ['en-US','zh-CN']){
|
||||
const {messages}=JSON.parse(await readFile(`ui/locales/${locale}.json`,'utf8'));
|
||||
for(const key of ['开始录制','停止录制','录制保存到 /home/steamos/Videos/framely'])assert.ok(messages[key]);
|
||||
}
|
||||
console.log('PASS: recording button idle/active/disabled states, timer, path and translations');
|
||||
@@ -24,6 +24,13 @@ registerPlugin({QuickPage:Page});
|
||||
await native('X','x','keyboard-multiline-state');await native('ENTER','x\n','keyboard-multiline-state');
|
||||
await native('Y','x\ny','keyboard-multiline-state');
|
||||
await native('BACKSPACE','x\n','keyboard-multiline-state');await native('BACKSPACE','x','keyboard-multiline-state');await native('BACKSPACE','','keyboard-multiline-state');
|
||||
const editor=document.createElement('textarea'),edits:string[]=[];
|
||||
editor.addEventListener('framely.keyboard.edit',event=>{event.preventDefault();edits.push((event as CustomEvent<string>).detail);});
|
||||
document.body.appendChild(editor);editor.focus();editor.dispatchEvent(new FocusEvent('focusin',{bubbles:true}));
|
||||
console.log('FRAMELY_NATIVE_KEYBOARD_X');await until(()=>edits.length===1);
|
||||
console.log('FRAMELY_NATIVE_KEYBOARD_BACKSPACE');await until(()=>edits.length===2);
|
||||
if(edits[0]!=='x'||edits[1]!=='\b'||editor.value!=='')throw Error('custom keyboard editor did not consume native edits');
|
||||
editor.remove();
|
||||
console.log('FRAMELY_KEYBOARD_REACT_PASS');
|
||||
(window as any).__framelyKeyboardFixtureDone=true;
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}})();
|
||||
@@ -0,0 +1,23 @@
|
||||
#include "openvr.h"
|
||||
#include "recording_indicator.h"
|
||||
#include "launcher_input.h"
|
||||
#include <cassert>
|
||||
int main(){
|
||||
RecordingIndicatorClick click;
|
||||
assert(!click.update(true,true));assert(!click.update(false,true));
|
||||
assert(!click.update(true,false));assert(!click.update(false,true));
|
||||
assert(!click.update(true,true));assert(click.update(false,true));assert(!click.update(false,true));
|
||||
assert(!click.update(true,true));assert(!click.update(false,true,false));assert(!click.update(false,true));
|
||||
EntryAnalogTrigger trigger;RecordingIndicatorClick analog_click;
|
||||
assert(!analog_click.update(trigger.update(0.f),true));
|
||||
assert(!analog_click.update(trigger.update(.6f),true));
|
||||
assert(!analog_click.update(trigger.update(.46f),true));
|
||||
assert(analog_click.update(trigger.update(.44f),true));
|
||||
for(auto seconds:{0ULL,59ULL,60ULL,3600ULL,9999999ULL}){
|
||||
auto normal=recording_indicator_pixels(seconds,false),hover=recording_indicator_pixels(seconds,true);
|
||||
assert(normal.size()==320*80*4&&hover.size()==normal.size());
|
||||
assert(normal!=hover);assert(normal[3]==0);
|
||||
auto red=(40*320+32)*4;assert(normal[red]>200&&normal[red+1]<100);
|
||||
auto stop=(40*320+280)*4;assert(normal[stop]==245&&normal[stop+3]==255);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import pathlib
|
||||
import struct
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / 'tools/package-q2f.py'
|
||||
SPEC = importlib.util.spec_from_file_location('package_q2f', SCRIPT)
|
||||
PACKAGE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(PACKAGE)
|
||||
|
||||
|
||||
def elf(machine, elf_class):
|
||||
data = bytearray(1024)
|
||||
data[:6] = b'\x7fELF' + bytes((elf_class, 1))
|
||||
struct.pack_into('<H', data, 18, machine)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
class FrameBridgePackageTests(unittest.TestCase):
|
||||
def test_wraps_both_abis_and_keeps_corresponding_source(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = pathlib.Path(directory)
|
||||
payload = root / 'payload'
|
||||
adapters = {}
|
||||
for abi, machine in PACKAGE.FRAMEBRIDGE_ABIS.items():
|
||||
lib = payload / 'libraries' / PACKAGE.LIBRARIES_VERSION / 'lib' / abi
|
||||
lib.mkdir(parents=True)
|
||||
(lib / 'libopenxr_loader_generic.so').write_bytes(b'original ' + abi.encode())
|
||||
adapter = root / f'{abi}.so'
|
||||
adapter.write_bytes(elf(machine, 2 if abi == 'arm64-v8a' else 1))
|
||||
adapters[abi] = adapter
|
||||
source = root / 'frameport.tar.gz'
|
||||
prefix = f'frameport-{PACKAGE.FRAMEBRIDGE_COMMIT}/'
|
||||
with tarfile.open(source, 'w:gz') as archive:
|
||||
for name in ('LICENSE', 'THIRD_PARTY_NOTICES.md', 'native/README.md',
|
||||
'native/build.py', 'native/adapter/frame_adapter.c'):
|
||||
content = name.encode()
|
||||
entry = tarfile.TarInfo(prefix + name)
|
||||
entry.size = len(content)
|
||||
archive.addfile(entry, io.BytesIO(content))
|
||||
with patch.object(PACKAGE, 'FRAMEBRIDGE_SOURCE_SHA256', PACKAGE.digest(source)):
|
||||
PACKAGE.install_framebridge(payload, source, adapters)
|
||||
self.assertEqual((payload / 'FRAMEBRIDGE_LICENSE').read_bytes(), b'LICENSE')
|
||||
with tarfile.open(payload / 'FRAMEBRIDGE_SOURCE.tar.gz') as archive:
|
||||
self.assertIn('framebridge/native/adapter/frame_adapter.c', archive.getnames())
|
||||
self.assertIn('framebridge/native/build.py', archive.getnames())
|
||||
for abi in adapters:
|
||||
lib = payload / 'libraries' / PACKAGE.LIBRARIES_VERSION / 'lib' / abi
|
||||
self.assertEqual((lib / 'libopenxr_loader_original.so').read_bytes(), b'original ' + abi.encode())
|
||||
self.assertEqual((lib / 'libopenxr_loader_generic.so').read_bytes(), adapters[abi].read_bytes())
|
||||
self.assertIn(b'controller_fix=1', (lib / 'libframe_settings.so').read_bytes())
|
||||
|
||||
def test_rejects_wrong_adapter_architecture_before_replacing_loader(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = pathlib.Path(directory)
|
||||
payload = root / 'payload'
|
||||
source = root / 'source.tar.gz'
|
||||
prefix = f'frameport-{PACKAGE.FRAMEBRIDGE_COMMIT}/'
|
||||
with tarfile.open(source, 'w:gz') as archive:
|
||||
for name in ('LICENSE', 'THIRD_PARTY_NOTICES.md', 'native/README.md',
|
||||
'native/build.py', 'native/adapter/frame_adapter.c'):
|
||||
content = name.encode()
|
||||
entry = tarfile.TarInfo(prefix + name)
|
||||
entry.size = len(content)
|
||||
archive.addfile(entry, io.BytesIO(content))
|
||||
lib = payload / 'libraries' / PACKAGE.LIBRARIES_VERSION / 'lib/arm64-v8a'
|
||||
lib.mkdir(parents=True)
|
||||
(lib / 'libopenxr_loader_generic.so').write_bytes(b'original')
|
||||
adapter = root / 'wrong.so'
|
||||
adapter.write_bytes(elf(40, 1))
|
||||
with patch.object(PACKAGE, 'FRAMEBRIDGE_SOURCE_SHA256', PACKAGE.digest(source)):
|
||||
with self.assertRaisesRegex(ValueError, 'wrong architecture'):
|
||||
PACKAGE.install_framebridge(payload, source, {'arm64-v8a': adapter, 'armeabi-v7a': adapter})
|
||||
self.assertEqual((lib / 'libopenxr_loader_generic.so').read_bytes(), b'original')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,63 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / 'tools/q2f-runtime.py'
|
||||
SPEC = importlib.util.spec_from_file_location('q2f_runtime', SCRIPT)
|
||||
RUNTIME = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(RUNTIME)
|
||||
|
||||
|
||||
class Q2FRuntimeTests(unittest.TestCase):
|
||||
def test_exact_cache_is_reused_and_tampering_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = pathlib.Path(directory)
|
||||
identity = 'overport-test-123456789abc'
|
||||
payload = base / 'payload'
|
||||
(payload / 'java/bin').mkdir(parents=True)
|
||||
for name in ('java/bin/java', 'overportcli.jar', 'overport-installed.json', 'OVR_PORT_LICENSE'):
|
||||
(payload / name).write_text(name)
|
||||
files = sorted(p for p in payload.rglob('*') if p.is_file())
|
||||
sums = ''.join(f'{RUNTIME.digest(p)} {p.relative_to(payload)}\n' for p in files)
|
||||
(payload / 'VERSION').write_text(identity + '\n')
|
||||
(payload / 'SHA256SUMS').write_text(sums)
|
||||
archive = base / f'framely-q2f-{identity}-linux-arm64.tar.gz'
|
||||
with tarfile.open(archive, 'w:gz') as tar:
|
||||
tar.add(payload, arcname=f'framely-q2f-{identity}')
|
||||
descriptor = base / 'requirement.json'
|
||||
descriptor.write_text(json.dumps({'schemaVersion': 1, 'id': identity,
|
||||
'archive': archive.name, 'size': archive.stat().st_size,
|
||||
'sha256': RUNTIME.digest(archive), 'url': 'https://example.invalid/q2f'}))
|
||||
copies = []
|
||||
def copy(_requirement, destination):
|
||||
destination.write_bytes(archive.read_bytes())
|
||||
copies.append(destination)
|
||||
with patch.object(RUNTIME, 'download', copy):
|
||||
cached = RUNTIME.prepare(descriptor, base / 'storage')
|
||||
self.assertEqual(cached, RUNTIME.prepare(descriptor, base / 'storage'))
|
||||
self.assertEqual(len(copies), 1)
|
||||
(cached / 'overportcli.jar').write_text('changed')
|
||||
with self.assertRaisesRegex(ValueError, 'checksum mismatch'):
|
||||
RUNTIME.prepare(descriptor, base / 'storage')
|
||||
|
||||
def test_archive_rejects_link_and_escape(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
base = pathlib.Path(directory)
|
||||
identity = 'overport-test-123456789abc'
|
||||
archive = base / 'unsafe.tar.gz'
|
||||
for index, member in enumerate((f'framely-q2f-{identity}/../escape', f'framely-q2f-{identity}/link')):
|
||||
with tarfile.open(archive, 'w:gz') as tar:
|
||||
info = tarfile.TarInfo(member)
|
||||
info.type = tarfile.SYMTYPE if member.endswith('/link') else tarfile.REGTYPE
|
||||
info.size = 0
|
||||
tar.addfile(info)
|
||||
with self.assertRaisesRegex(ValueError, 'Unsafe'):
|
||||
RUNTIME.extract(archive, base / f'target-{index}', identity)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import com.android.apksig.ApkVerifier;
|
||||
import java.io.File;
|
||||
|
||||
/** Verify an OVRPort-produced APK before handing it to Framely's install review. */
|
||||
public final class FramelyQ2FVerify {
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length != 1) {
|
||||
throw new IllegalArgumentException("Expected one APK path");
|
||||
}
|
||||
ApkVerifier.Result result = new ApkVerifier.Builder(new File(args[0])).build().verify();
|
||||
if (!result.isVerified()) {
|
||||
for (ApkVerifier.IssueWithParams issue : result.getErrors()) {
|
||||
System.err.println(issue);
|
||||
}
|
||||
throw new IllegalStateException("Quest converter produced an invalid APK signature");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ cargo build --release --locked --manifest-path media/capture/Cargo.toml
|
||||
mkdir -p media/bin
|
||||
capture_target=$(cargo metadata --locked --no-deps --format-version 1 --manifest-path media/capture/Cargo.toml | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')
|
||||
cp "$capture_target/release/framely-capture" "$capture_target/release/framely-panel-grab" media/bin/
|
||||
# Incremental release builds may reuse unchanged AirPlay/MediaMTX artifacts.
|
||||
[[ ${FRAMELY_CAPTURE_ONLY:-0} != 1 ]] || exit 0
|
||||
# Native dependencies use the target system's GStreamer and GUPnP libraries.
|
||||
python3 tools/fetch-media.py
|
||||
bash tools/build-gst-libav.sh
|
||||
|
||||
@@ -109,6 +109,18 @@ def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_l
|
||||
else:
|
||||
steam_home = pathlib.Path(steam_home)
|
||||
apk = steam_home / '.local/share/framely/apk-manager'
|
||||
storage_file = steam_home / '.local/share/framely/storage.json'
|
||||
if storage_file.is_file() and not storage_file.is_symlink() and storage_file.stat().st_uid == steam_home.stat().st_uid:
|
||||
try:
|
||||
with storage_file.open('rb') as source:
|
||||
raw = source.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise ValueError('storage setting exceeds diagnostic size limit')
|
||||
configured = json.loads(raw).get('apk')
|
||||
if isinstance(configured, str) and pathlib.Path(configured).is_absolute():
|
||||
apk = pathlib.Path(configured)
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
report['warnings'].append(f'apk: invalid storage setting: {error}')
|
||||
try:
|
||||
with (apk / 'state.json').open('rb') as source:
|
||||
raw = source.read(16 * 1024 * 1024 + 1)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build an independently downloadable Quest conversion runtime."""
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
CLI_VERSION = '1.2.3'
|
||||
LIBRARIES_VERSION = '3.4.3-23204ea'
|
||||
CLI_SHA256 = '307d58a69a5c9722f912d1b6566b0dd1e0ebe1440c6d1e56cab6cb883d74fef2'
|
||||
LIBRARIES_SHA256 = '6cec6c176e1aeee8d9ec4f9b4e3982f71da7b44a36a293249620073a06f00e5e'
|
||||
SOURCE_SHA256 = '2db2c0f597a8be01d9742180019627b4c23fd0213f04f5034fbad53f0d296eb3'
|
||||
FRAMEBRIDGE_COMMIT = '80ef9ca6a77eed1ca6b2cc6f746d9897e4178b4e'
|
||||
FRAMEBRIDGE_SOURCE_SHA256 = 'ea90ea8dd2f0fe49d54d2df5cc61c1876c9562e55fb40fce2651b1d5594dab7c'
|
||||
FRAMEBRIDGE_ABIS = {'arm64-v8a': 183, 'armeabi-v7a': 40}
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open('rb') as source:
|
||||
return hashlib.file_digest(source, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def safe_zip_extract(source, destination):
|
||||
with zipfile.ZipFile(source) as archive:
|
||||
for entry in archive.infolist():
|
||||
path = pathlib.PurePosixPath(entry.filename)
|
||||
if (path.is_absolute() or '..' in path.parts or '\\' in entry.filename
|
||||
or (entry.external_attr >> 16) & 0o170000 == 0o120000):
|
||||
raise ValueError('Unsafe OVR Port library entry')
|
||||
target = destination.joinpath(*path.parts)
|
||||
if entry.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(entry) as data, target.open('xb') as output:
|
||||
shutil.copyfileobj(data, output)
|
||||
|
||||
|
||||
def framebridge_source(source, destination):
|
||||
"""Ship the exact adapter source and its build instructions with the runtime."""
|
||||
prefix = f'frameport-{FRAMEBRIDGE_COMMIT}/'
|
||||
fixed = {'LICENSE', 'THIRD_PARTY_NOTICES.md', 'native/README.md', 'native/build.py'}
|
||||
with tarfile.open(source, 'r:gz') as upstream, destination.open('wb') as output, \
|
||||
gzip.GzipFile(filename='', mode='wb', fileobj=output, mtime=0) as compressed, \
|
||||
tarfile.open(fileobj=compressed, mode='w') as selected:
|
||||
files = [m for m in upstream.getmembers() if m.name.startswith(prefix)
|
||||
and (m.name[len(prefix):] in fixed or m.name[len(prefix):].startswith('native/adapter/'))
|
||||
and m.isfile()]
|
||||
if not fixed.issubset({m.name[len(prefix):] for m in files}) or len(files) > 100:
|
||||
raise ValueError('Incomplete FrameBridge corresponding source')
|
||||
for member in sorted(files, key=lambda m: m.name):
|
||||
relative = member.name[len(prefix):]
|
||||
if member.size > 2 * 1024 * 1024 or '..' in pathlib.PurePosixPath(relative).parts:
|
||||
raise ValueError('Invalid FrameBridge source entry')
|
||||
copy = tarfile.TarInfo(f'framebridge/{relative}')
|
||||
copy.size, copy.mode, copy.mtime = member.size, 0o644, 0
|
||||
selected.addfile(copy, upstream.extractfile(member))
|
||||
|
||||
|
||||
def install_framebridge(payload, source, adapters):
|
||||
if digest(source) != FRAMEBRIDGE_SOURCE_SHA256:
|
||||
raise ValueError('Pinned FrameBridge source checksum mismatch')
|
||||
with tarfile.open(source, 'r:gz') as upstream:
|
||||
license_file = upstream.extractfile(f'frameport-{FRAMEBRIDGE_COMMIT}/LICENSE')
|
||||
if license_file is None:
|
||||
raise ValueError('FrameBridge license missing')
|
||||
(payload / 'FRAMEBRIDGE_LICENSE').write_bytes(license_file.read())
|
||||
framebridge_source(source, payload / 'FRAMEBRIDGE_SOURCE.tar.gz')
|
||||
(payload / 'FRAMEBRIDGE_SOURCE').write_text(
|
||||
f'https://github.com/spoopyghosty0/frameport/tree/{FRAMEBRIDGE_COMMIT}\n')
|
||||
for abi, machine in FRAMEBRIDGE_ABIS.items():
|
||||
adapter = pathlib.Path(adapters[abi])
|
||||
data = adapter.read_bytes()
|
||||
elf_class = 2 if abi == 'arm64-v8a' else 1
|
||||
if (len(data) < 1024 or data[:6] != b'\x7fELF' + bytes((elf_class, 1))
|
||||
or struct.unpack_from('<H', data, 18)[0] != machine):
|
||||
raise ValueError(f'FrameBridge {abi} binary has the wrong architecture')
|
||||
lib = payload / 'libraries' / LIBRARIES_VERSION / 'lib' / abi
|
||||
generic = lib / 'libopenxr_loader_generic.so'
|
||||
original = lib / 'libopenxr_loader_original.so'
|
||||
if not generic.is_file() or original.exists():
|
||||
raise ValueError(f'OVRPort {abi} generic loader cannot be wrapped')
|
||||
generic.rename(original)
|
||||
generic.write_bytes(data)
|
||||
(lib / 'libframe_settings.so').write_text('controller_fix=1\nprofile_remap=1\n')
|
||||
|
||||
|
||||
def package(args):
|
||||
cli, libraries, java, source = map(pathlib.Path, (args.cli_jar, args.libraries_zip, args.java_home, args.source_archive))
|
||||
if digest(cli) != CLI_SHA256 or digest(libraries) != LIBRARIES_SHA256 or digest(source) != SOURCE_SHA256:
|
||||
raise ValueError('Pinned OVR Port artifact checksum mismatch')
|
||||
if not (java / 'bin/java').is_file() or not (java / 'legal/java.base/LICENSE').is_file():
|
||||
raise ValueError('Incomplete Java runtime')
|
||||
output = pathlib.Path(args.output)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='q2f-package-', dir=output) as scratch:
|
||||
payload = pathlib.Path(scratch) / 'payload'
|
||||
shutil.copytree(java, payload / 'java', symlinks=False)
|
||||
shutil.copy2(cli, payload / 'overportcli.jar')
|
||||
verifier = pathlib.Path(__file__).with_name('FramelyQ2FVerify.java')
|
||||
subprocess.run(['javac', '-cp', str(cli), '-d', str(payload), str(verifier)], check=True)
|
||||
shutil.copy2(verifier, payload / verifier.name)
|
||||
safe_zip_extract(libraries, payload / 'libraries' / LIBRARIES_VERSION)
|
||||
install_framebridge(payload, pathlib.Path(args.framebridge_source), {
|
||||
'arm64-v8a': args.framebridge_arm64, 'armeabi-v7a': args.framebridge_arm32})
|
||||
(payload / 'overport-installed.json').write_text(json.dumps({'releases': [
|
||||
{'version': LIBRARIES_VERSION, 'app_required': '1.2.0', 'is_experimental': True}
|
||||
]}) + '\n')
|
||||
shutil.copy2(args.license, payload / 'OVR_PORT_LICENSE')
|
||||
shutil.copy2(source, payload / 'OVR_PORT_SOURCE.tar.gz')
|
||||
(payload / 'SOURCE').write_text('https://github.com/ovrport/app/tree/c790854\n')
|
||||
entries = sorted(p for p in payload.rglob('*') if p.is_file())
|
||||
checksums = ''.join(f'{digest(p)} {p.relative_to(payload).as_posix()}\n' for p in entries)
|
||||
identity = f'overport-{CLI_VERSION}-{LIBRARIES_VERSION}-framebridge-{FRAMEBRIDGE_COMMIT[:8]}-{hashlib.sha256(checksums.encode()).hexdigest()[:12]}'
|
||||
(payload / 'VERSION').write_text(identity + '\n')
|
||||
(payload / 'SHA256SUMS').write_text(checksums)
|
||||
archive = output / f'framely-q2f-{identity}-linux-arm64.tar.gz'
|
||||
with tarfile.open(archive, 'w:gz') as tar:
|
||||
tar.add(payload, arcname=f'framely-q2f-{identity}')
|
||||
requirement = {'schemaVersion': 1, 'id': identity,
|
||||
'cliVersion': CLI_VERSION, 'librariesVersion': LIBRARIES_VERSION,
|
||||
'framebridgeCommit': FRAMEBRIDGE_COMMIT,
|
||||
'archive': archive.name, 'size': archive.stat().st_size,
|
||||
'sha256': digest(archive),
|
||||
'url': f'https://github.com/{args.repo}/releases/download/{args.tag}/{archive.name}'}
|
||||
pathlib.Path(args.stage, 'Q2F_RUNTIME.json').write_text(json.dumps(requirement, indent=2) + '\n')
|
||||
pathlib.Path(output, 'framely-q2f.json').write_text(json.dumps(requirement, indent=2) + '\n')
|
||||
print(archive)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
for name in ('stage', 'output', 'java-home', 'cli-jar', 'libraries-zip', 'license', 'source-archive',
|
||||
'framebridge-source', 'framebridge-arm64', 'framebridge-arm32', 'repo', 'tag'):
|
||||
parser.add_argument('--' + name, required=True)
|
||||
package(parser.parse_args())
|
||||
@@ -9,10 +9,30 @@ cargo build --release --locked
|
||||
npm ci --no-audit --no-fund
|
||||
npm run build
|
||||
bash tools/build-native.sh "$cef"
|
||||
bash tools/build-media.sh
|
||||
if [[ ${FRAMELY_USE_PREBUILT_MEDIA:-0} == 1 ]]; then
|
||||
if [[ ${FRAMELY_REBUILD_CAPTURE_ONLY:-0} == 1 ]]; then
|
||||
[[ -z $(git status --porcelain -- media ':!media/capture') ]] || { echo 'Non-capture media sources changed; rebuild all media.' >&2; exit 1; }
|
||||
FRAMELY_CAPTURE_ONLY=1 bash tools/build-media.sh
|
||||
else
|
||||
[[ -z $(git status --porcelain -- media) ]] || { echo 'Media sources changed; rebuild media.' >&2; exit 1; }
|
||||
fi
|
||||
for file in media/bin/framely-panel-grab media/bin/framely-capture media/bin/mediamtx media/bin/uxplay media/bin/framely-receiver media/bin/libgstframely.so media/bin/libgstlibav.so media/bin/LICENSE media/bin/LICENSE.uxplay media/bin/LICENSE.gst-libav media/bin/LICENSE.llhttp; do
|
||||
[[ -s $file ]] || { echo "Missing prebuilt media: $file" >&2; exit 1; }
|
||||
done
|
||||
else
|
||||
bash tools/build-media.sh
|
||||
fi
|
||||
binary=${CARGO_TARGET_DIR:-target}/release/framely
|
||||
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
build_id="$version-$(find "$binary" target/native media/bin ui/dist sdk templates/plugin examples/showcase packaging tools docs assets/branding assets/search README.md README.zh-CN.md LICENSE -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)"
|
||||
q2f_java=${Q2F_JAVA_HOME:-target/quest-conversion-jre}
|
||||
q2f_cli=${Q2F_CLI_JAR:-target/overportcli-1.2.3-all.jar}
|
||||
q2f_libraries=${Q2F_LIBRARIES_ZIP:-target/overport-libraries-3.4.3-23204ea.zip}
|
||||
q2f_license=${Q2F_LICENSE:-target/overport-source/LICENSE}
|
||||
q2f_source=${Q2F_SOURCE_ARCHIVE:-target/q2f-source.tar.gz}
|
||||
framebridge_source=${Q2F_FRAMEBRIDGE_SOURCE:-target/q2f-framebridge/source.tar.gz}
|
||||
framebridge_arm64=${Q2F_FRAMEBRIDGE_ARM64:-target/q2f-framebridge/artifacts/arm64-v8a/libopenxr_loader_generic.so}
|
||||
framebridge_arm32=${Q2F_FRAMEBRIDGE_ARM32:-target/q2f-framebridge/artifacts/armeabi-v7a/libopenxr_loader_generic.so}
|
||||
build_id="$version-$(find "$binary" target/native media/bin ui/dist sdk templates/plugin examples/showcase packaging tools docs assets/branding assets/search README.md README.zh-CN.md LICENSE "$q2f_java" "$q2f_cli" "$q2f_libraries" "$q2f_license" "$q2f_source" "$framebridge_source" "$framebridge_arm64" "$framebridge_arm32" -type f ! -path '*/__pycache__/*' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)"
|
||||
stage="$base/release/framely-$build_id"
|
||||
[[ ! -e $stage ]] || { echo 'Release already exists.' >&2; exit 1; }
|
||||
mkdir -p "$stage"/{bin,lib/cef,lib/openvr,lib/media,share/ui,share/licenses,tools}
|
||||
@@ -67,10 +87,17 @@ cp -a assets/branding "$stage/tools/source/assets/"
|
||||
cp -a examples/showcase "$stage/tools/source/examples/"
|
||||
cp -a ui/src ui/locales ui/index.html ui/package.json "$stage/tools/source/ui/"
|
||||
cp tools/build-ui.mjs tools/build-branding.mjs tools/plugin-dev.mjs "$stage/tools/source/tools/"
|
||||
cp tools/extract-release.py tools/bootstrap.py tools/cef-runtime.py tools/export-diagnostics.py "$stage/tools/"
|
||||
cp tools/extract-release.py tools/bootstrap.py tools/cef-runtime.py tools/q2f-runtime.py tools/export-diagnostics.py "$stage/tools/"
|
||||
find "$stage/tools/source" -type d -name __pycache__ -prune -exec rm -rf -- {} +
|
||||
cp -a docs "$stage/share/"
|
||||
printf '%s\n' "$build_id" > "$stage/VERSION"
|
||||
chmod 755 "$stage"/*.sh "$stage/bin/"* "$stage/lib/cef/framely-vr"
|
||||
python3 tools/package-q2f.py --stage "$stage" --output release \
|
||||
--java-home "$q2f_java" --cli-jar "$q2f_cli" \
|
||||
--libraries-zip "$q2f_libraries" --license "$q2f_license" \
|
||||
--source-archive "$q2f_source" \
|
||||
--framebridge-source "$framebridge_source" \
|
||||
--framebridge-arm64 "$framebridge_arm64" --framebridge-arm32 "$framebridge_arm32" \
|
||||
--repo "${RELEASE_REPO:-SteamFramelyHomebrew/framely}" --tag "${RELEASE_TAG:-v$version}"
|
||||
python3 tools/package-runtime.py "$stage" "$cef" "${RELEASE_REPO:-SteamFramelyHomebrew/framely}" "${RELEASE_TAG:-v$version}"
|
||||
echo "Package: release/framely-$build_id-linux-arm64.tar.gz"
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download and verify the exact optional Quest conversion runtime."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
MAX_ARCHIVE = 512 * 1024 * 1024
|
||||
MAX_EXPANDED = 1024 * 1024 * 1024
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open('rb') as source:
|
||||
return hashlib.file_digest(source, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def validate(root, identity):
|
||||
if root.is_symlink() or not root.is_dir() or (root / 'VERSION').read_text().strip() != identity:
|
||||
raise ValueError('Invalid q2f runtime cache')
|
||||
lines = (root / 'SHA256SUMS').read_text().splitlines()
|
||||
expected = set()
|
||||
for line in lines:
|
||||
match = re.fullmatch(r'([a-f0-9]{64}) ([^\\]+)', line)
|
||||
if not match:
|
||||
raise ValueError('Invalid q2f checksum list')
|
||||
relative = pathlib.PurePosixPath(match[2])
|
||||
if relative.is_absolute() or '..' in relative.parts or not relative.parts:
|
||||
raise ValueError('Invalid q2f payload path')
|
||||
file = root.joinpath(*relative.parts)
|
||||
if file.is_symlink() or not file.is_file() or digest(file) != match[1]:
|
||||
raise ValueError('q2f payload checksum mismatch: ' + match[2])
|
||||
expected.add(relative.as_posix())
|
||||
actual = {p.relative_to(root).as_posix() for p in root.rglob('*') if p.is_file()}
|
||||
if actual != expected | {'VERSION', 'SHA256SUMS'}:
|
||||
raise ValueError('Unexpected q2f payload files')
|
||||
for required in ('java/bin/java', 'overportcli.jar', 'overport-installed.json', 'OVR_PORT_LICENSE'):
|
||||
if required not in expected:
|
||||
raise ValueError('Incomplete q2f runtime: ' + required)
|
||||
if '-framebridge-' in identity:
|
||||
for required in ('FRAMEBRIDGE_LICENSE', 'FRAMEBRIDGE_SOURCE', 'FRAMEBRIDGE_SOURCE.tar.gz',
|
||||
'FramelyQ2FVerify.java', 'FramelyQ2FVerify.class'):
|
||||
if required not in expected:
|
||||
raise ValueError('Incomplete q2f runtime: ' + required)
|
||||
releases = root / 'libraries'
|
||||
for abi in ('arm64-v8a', 'armeabi-v7a'):
|
||||
libs = list(releases.glob(f'*/lib/{abi}'))
|
||||
if len(libs) != 1 or not all((libs[0] / name).is_file() for name in (
|
||||
'libopenxr_loader_generic.so', 'libopenxr_loader_original.so', 'libframe_settings.so')):
|
||||
raise ValueError('Incomplete FrameBridge libraries: ' + abi)
|
||||
|
||||
|
||||
class HTTPSRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if urllib.parse.urlparse(newurl).scheme != 'https':
|
||||
raise ValueError('HTTPS redirect downgrade rejected')
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def download(requirement, destination):
|
||||
url = requirement['url']
|
||||
if urllib.parse.urlparse(url).scheme != 'https':
|
||||
raise ValueError('q2f URL must use HTTPS')
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'Framely-q2f'})
|
||||
with urllib.request.build_opener(HTTPSRedirect()).open(request, timeout=90) as response, destination.open('xb') as output:
|
||||
total = 0
|
||||
while block := response.read(256 * 1024):
|
||||
total += len(block)
|
||||
if total > requirement['size'] or total > MAX_ARCHIVE:
|
||||
raise ValueError('q2f download exceeds declared size')
|
||||
output.write(block)
|
||||
if total != requirement['size'] or digest(destination) != requirement['sha256']:
|
||||
raise ValueError('q2f archive checksum mismatch')
|
||||
|
||||
|
||||
def extract(archive, target, identity):
|
||||
prefix = f'framely-q2f-{identity}'
|
||||
target.mkdir(mode=0o700)
|
||||
with tarfile.open(archive, 'r:gz') as source:
|
||||
members = source.getmembers()
|
||||
if len(members) > 10000 or sum(m.size for m in members) > MAX_EXPANDED:
|
||||
raise ValueError('q2f archive is too large')
|
||||
seen = set()
|
||||
for member in members:
|
||||
path = pathlib.PurePosixPath(member.name)
|
||||
if (path.is_absolute() or '..' in path.parts or '\\' in member.name
|
||||
or not path.parts or path.parts[0] != prefix
|
||||
or member.name in seen or not (member.isfile() or member.isdir())):
|
||||
raise ValueError('Unsafe q2f archive member')
|
||||
seen.add(member.name)
|
||||
for member in members:
|
||||
parts = pathlib.PurePosixPath(member.name).parts[1:]
|
||||
if not parts:
|
||||
continue
|
||||
path = target.joinpath(*parts)
|
||||
if member.isdir():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with source.extractfile(member) as data, path.open('xb') as output:
|
||||
shutil.copyfileobj(data, output)
|
||||
path.chmod(0o755 if member.mode & 0o111 else 0o644)
|
||||
validate(target, identity)
|
||||
|
||||
|
||||
def prepare(descriptor, storage):
|
||||
spec = json.loads(pathlib.Path(descriptor).read_text())
|
||||
identity = spec['id']
|
||||
archive_name = f'framely-q2f-{identity}-linux-arm64.tar.gz'
|
||||
if (spec['schemaVersion'] != 1 or not re.fullmatch(r'[a-zA-Z0-9.+-]+', identity)
|
||||
or '..' in identity or spec['archive'] != archive_name
|
||||
or not 0 < spec['size'] <= MAX_ARCHIVE
|
||||
or not re.fullmatch(r'[a-f0-9]{64}', spec['sha256'])):
|
||||
raise ValueError('Invalid q2f requirement')
|
||||
parent = pathlib.Path(storage) / 'q2f' / 'runtimes'
|
||||
if parent.is_symlink():
|
||||
raise ValueError('q2f storage must not be a symlink')
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
cached = parent / identity
|
||||
if cached.exists() or cached.is_symlink():
|
||||
validate(cached, identity)
|
||||
return cached
|
||||
with tempfile.TemporaryDirectory(prefix='.download-', dir=parent) as scratch:
|
||||
scratch = pathlib.Path(scratch)
|
||||
archive = scratch / archive_name
|
||||
download(spec, archive)
|
||||
payload = scratch / 'payload'
|
||||
extract(archive, payload, identity)
|
||||
payload.rename(cached)
|
||||
return cached
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
if len(sys.argv) != 3:
|
||||
raise ValueError('Usage: q2f-runtime.py DESCRIPTOR APK_STORAGE')
|
||||
print(prepare(*sys.argv[1:]), flush=True)
|
||||
except (KeyError, ValueError, OSError, tarfile.TarError) as error:
|
||||
print('Framely q2f: ' + str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$base"
|
||||
mkdir -p target/tests
|
||||
for test in gamepad_mapping gamepad_steam gamepad_direct entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority; do
|
||||
for test in gamepad_mapping gamepad_steam gamepad_direct entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority recording_indicator; do
|
||||
"${CXX:-g++}" -std=c++17 -O2 -pthread -Inative -Inative/vendor/openvr "tests/$test.cpp" -ldl -o "target/tests/$test"
|
||||
"target/tests/$test"
|
||||
done
|
||||
|
||||
+88
-2
@@ -3,6 +3,20 @@
|
||||
"locale": "en-US",
|
||||
"name": "English",
|
||||
"messages": {
|
||||
"安卓权限请求": "Android permission request",
|
||||
"正在读取权限请求": "Reading permission request",
|
||||
"安卓权限选择": "Android permission selection",
|
||||
"安卓原生权限对话框": "Native Android permission dialog",
|
||||
"正在等待安卓权限画面…": "Waiting for the Android permission screen…",
|
||||
"使用手柄激光选择原生权限选项": "Use the controller laser to select a native permission option",
|
||||
"开始录制": "Start recording",
|
||||
"停止录制": "Stop recording",
|
||||
"录制保存到 /home/steamos/Videos/framely": "Recordings are saved to /home/steamos/Videos/framely",
|
||||
"免验证": "Password-free viewing",
|
||||
"持有此链接的人无需密码即可观看。": "Anyone with this link can watch without a password.",
|
||||
"观看链接已失效": "This viewing link has expired",
|
||||
"正在读取串流信息…": "Reading stream information…",
|
||||
"当前浏览器实际接收并解码的串流信息": "Actual stream received and decoded by this browser",
|
||||
"L2 / R2 按键触发行程": "L2 / R2 button activation threshold",
|
||||
"默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。": "Default: 80%. Buttons activate at this trigger travel. Analog axes retain their full range; saving applies immediately.",
|
||||
"更新应用并保留现有存档和设置。": "Update the app while keeping your saves and settings.",
|
||||
@@ -417,7 +431,7 @@
|
||||
"正式版": "Stable",
|
||||
"测试版": "Testing",
|
||||
"仅检查正式版。": "Check stable releases only.",
|
||||
"仅检查测试版(Preview、Beta、RC 等)。": "Check testing releases only (Preview, Beta, RC, etc.).",
|
||||
"检查正式版和测试版(Preview、Beta、RC 等),选择最新版本。": "Check stable and testing releases (Preview, Beta, RC, etc.) and select the newest version.",
|
||||
"所选渠道暂无可用发行版本。": "No release is available in the selected channel.",
|
||||
"所选版本早于当前版本,安装将切换到较旧版本。": "The selected release is older than your current version. Installing will switch to that older version.",
|
||||
"通知": "Notifications",
|
||||
@@ -1229,6 +1243,78 @@
|
||||
"使用头显硬件编码。浏览器不支持所选编码时使用兼容画面。": "Uses headset hardware encoding. Browsers that cannot decode the selected codec receive a compatible stream.",
|
||||
"SteamVR 使用软件编码,H.265 会增加处理负载。": "SteamVR uses software encoding. H.265 increases processing load.",
|
||||
"串流": "Streaming",
|
||||
"投屏设置": "Casting settings"
|
||||
"投屏设置": "Casting settings",
|
||||
"存储": "Storage",
|
||||
"管理插件、APK 文件和安卓容器的存放位置。": "Manage where plugins, APK files, and Android containers are stored.",
|
||||
"插件程序和运行数据": "Plugin files and runtime data",
|
||||
"安装包、上传文件和备份": "Installation packages, uploads, and backups",
|
||||
"Framely 创建的 Lepton 容器和应用数据": "Lepton containers and app data created by Framely",
|
||||
"准备迁移": "Preparing migration",
|
||||
"停止相关程序": "Stopping affected programs",
|
||||
"复制数据": "Copying data",
|
||||
"核对文件": "Verifying files",
|
||||
"切换目录": "Switching storage location",
|
||||
"恢复运行": "Restarting programs",
|
||||
"清理旧目录": "Cleaning up the previous location",
|
||||
"选择目标目录": "Choose a destination folder",
|
||||
"选择上级目录,Framely 会在其中创建专用文件夹。": "Choose a parent folder. Framely will create a dedicated folder inside it.",
|
||||
"此目录下没有文件夹": "No folders here",
|
||||
"选择此位置": "Choose this location",
|
||||
"迁移完成。旧目录未能清理,请检查原位置。": "Migration finished. The previous folder could not be removed; please check it.",
|
||||
"迁移完成": "Migration finished",
|
||||
"迁移失败": "Migration failed",
|
||||
"正在读取存储位置": "Loading storage locations",
|
||||
"部分目录无法读取,显示可统计的最小占用": "Some folders could not be read. This is the minimum measured usage.",
|
||||
"当前位置": "Current location",
|
||||
"取消更改": "Cancel change",
|
||||
"继续迁移": "Resume migration",
|
||||
"更改位置": "Change location",
|
||||
"当前目录不可用,请先连接存储设备。": "This folder is unavailable. Connect the storage device first.",
|
||||
"上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。": "The previous migration did not finish. The original folder is still in use. Retry the same destination or choose a new empty folder.",
|
||||
"新位置": "New location",
|
||||
"浏览": "Browse",
|
||||
"目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。": "The destination must be empty and support Linux file permissions. Affected programs will pause during migration.",
|
||||
"迁移到新位置": "Move to new location",
|
||||
"APK 文件": "APK files",
|
||||
"安卓容器": "Android containers",
|
||||
"存储占用": "Storage usage",
|
||||
"头显用户存储卷": "Headset user storage",
|
||||
"总容量": "Total capacity",
|
||||
"已使用": "Used",
|
||||
"系统及其他": "System and other",
|
||||
"空闲": "Free",
|
||||
"已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。": "Data moved to another drive appears in the details below and is excluded from this volume.",
|
||||
"部分目录无法读取,Framely 占用可能低于实际值。": "Some folders could not be read, so Framely usage may be understated.",
|
||||
"占用明细": "Usage details",
|
||||
"程序": "Program",
|
||||
"数据": "Data",
|
||||
"备份、上传文件及其他": "Backups, uploads and other files",
|
||||
"其他插件文件": "Other plugin files",
|
||||
"其他容器文件": "Other container files",
|
||||
"暂无数据": "No data yet",
|
||||
"其他磁盘": "Other drive",
|
||||
"{0} 项": "{0} items",
|
||||
"自定义 Steam 名称": "Custom Steam name",
|
||||
"Steam 显示名称": "Steam display name",
|
||||
"分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。": "Set each app’s name in Steam. Leave blank to use its original name. Names survive APK updates and sync when Steam is ready.",
|
||||
"Invalid Steam display name": "Invalid Steam display name",
|
||||
"Steam display name must be at most 128 characters without control characters": "Steam display name must be at most 128 characters without control characters",
|
||||
"Steam name update was not acknowledged": "Steam name update was not acknowledged",
|
||||
"Quest 兼容转换": "Quest compatibility conversion",
|
||||
"这是一个 Quest 游戏?": "Is this a Quest game?",
|
||||
"打开此选项将尝试进行兼容转换,但不保证一定成功": "Turn this on to try a compatibility conversion. Success is not guaranteed.",
|
||||
"此 APK 已完成兼容转换并重新签名。": "This APK has been converted and re-signed.",
|
||||
"可选:使用独立 q2f 工具包转换并重新签名 APK。首次使用会下载工具包,转换结果仍需在 Frame 上验证。": "Optional: convert and re-sign this APK with the separate q2f package. The package downloads on first use; the result still needs testing on Frame.",
|
||||
"已转换": "Converted",
|
||||
"转换此 APK": "Convert this APK",
|
||||
"准备 Quest 转换工具": "Preparing Quest converter",
|
||||
"正在转换 APK": "Converting APK",
|
||||
"校验转换结果": "Verifying converted APK",
|
||||
"转换完成,请确认安装。": "Conversion complete. Review the APK before installing.",
|
||||
"正在检查转换工具": "Checking converter",
|
||||
"工具包已缓存": "Converter cached",
|
||||
"首次使用需下载 {0} 工具包": "First use downloads the {0} converter",
|
||||
"Quest 转换": "Quest conversion",
|
||||
"要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "To use an updated converter, select the original Quest APK and install it again."
|
||||
}
|
||||
}
|
||||
+88
-2
@@ -3,6 +3,20 @@
|
||||
"locale": "zh-CN",
|
||||
"name": "简体中文",
|
||||
"messages": {
|
||||
"安卓权限请求": "安卓权限请求",
|
||||
"正在读取权限请求": "正在读取权限请求",
|
||||
"安卓权限选择": "安卓权限选择",
|
||||
"安卓原生权限对话框": "安卓原生权限对话框",
|
||||
"正在等待安卓权限画面…": "正在等待安卓权限画面…",
|
||||
"使用手柄激光选择原生权限选项": "使用手柄激光选择原生权限选项",
|
||||
"开始录制": "开始录制",
|
||||
"停止录制": "停止录制",
|
||||
"录制保存到 /home/steamos/Videos/framely": "录制保存到 /home/steamos/Videos/framely",
|
||||
"免验证": "免验证",
|
||||
"持有此链接的人无需密码即可观看。": "持有此链接的人无需密码即可观看。",
|
||||
"观看链接已失效": "观看链接已失效",
|
||||
"正在读取串流信息…": "正在读取串流信息…",
|
||||
"当前浏览器实际接收并解码的串流信息": "当前浏览器实际接收并解码的串流信息",
|
||||
"L2 / R2 按键触发行程": "L2 / R2 按键触发行程",
|
||||
"默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。": "默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。",
|
||||
"安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。": "安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。",
|
||||
@@ -419,7 +433,7 @@
|
||||
"正式版": "正式版",
|
||||
"测试版": "测试版",
|
||||
"仅检查正式版。": "仅检查正式版。",
|
||||
"仅检查测试版(Preview、Beta、RC 等)。": "仅检查测试版(Preview、Beta、RC 等)。",
|
||||
"检查正式版和测试版(Preview、Beta、RC 等),选择最新版本。": "检查正式版和测试版(Preview、Beta、RC 等),选择最新版本。",
|
||||
"所选渠道暂无可用发行版本。": "所选渠道暂无可用发行版本。",
|
||||
"所选版本早于当前版本,安装将切换到较旧版本。": "所选版本早于当前版本,安装将切换到较旧版本。",
|
||||
"通知": "通知",
|
||||
@@ -1229,6 +1243,78 @@
|
||||
"使用头显硬件编码。浏览器不支持所选编码时使用兼容画面。": "使用头显硬件编码。浏览器不支持所选编码时使用兼容画面。",
|
||||
"SteamVR 使用软件编码,H.265 会增加处理负载。": "SteamVR 使用软件编码,H.265 会增加处理负载。",
|
||||
"串流": "串流",
|
||||
"投屏设置": "投屏设置"
|
||||
"投屏设置": "投屏设置",
|
||||
"存储": "存储",
|
||||
"管理插件、APK 文件和安卓容器的存放位置。": "管理插件、APK 文件和安卓容器的存放位置。",
|
||||
"插件程序和运行数据": "插件程序和运行数据",
|
||||
"安装包、上传文件和备份": "安装包、上传文件和备份",
|
||||
"Framely 创建的 Lepton 容器和应用数据": "Framely 创建的 Lepton 容器和应用数据",
|
||||
"准备迁移": "准备迁移",
|
||||
"停止相关程序": "停止相关程序",
|
||||
"复制数据": "复制数据",
|
||||
"核对文件": "核对文件",
|
||||
"切换目录": "切换目录",
|
||||
"恢复运行": "恢复运行",
|
||||
"清理旧目录": "清理旧目录",
|
||||
"选择目标目录": "选择目标目录",
|
||||
"选择上级目录,Framely 会在其中创建专用文件夹。": "选择上级目录,Framely 会在其中创建专用文件夹。",
|
||||
"此目录下没有文件夹": "此目录下没有文件夹",
|
||||
"选择此位置": "选择此位置",
|
||||
"迁移完成。旧目录未能清理,请检查原位置。": "迁移完成。旧目录未能清理,请检查原位置。",
|
||||
"迁移完成": "迁移完成",
|
||||
"迁移失败": "迁移失败",
|
||||
"正在读取存储位置": "正在读取存储位置",
|
||||
"部分目录无法读取,显示可统计的最小占用": "部分目录无法读取,显示可统计的最小占用",
|
||||
"当前位置": "当前位置",
|
||||
"取消更改": "取消更改",
|
||||
"继续迁移": "继续迁移",
|
||||
"更改位置": "更改位置",
|
||||
"当前目录不可用,请先连接存储设备。": "当前目录不可用,请先连接存储设备。",
|
||||
"上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。": "上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。",
|
||||
"新位置": "新位置",
|
||||
"浏览": "浏览",
|
||||
"目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。": "目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。",
|
||||
"迁移到新位置": "迁移到新位置",
|
||||
"APK 文件": "APK 文件",
|
||||
"安卓容器": "安卓容器",
|
||||
"存储占用": "存储占用",
|
||||
"头显用户存储卷": "头显用户存储卷",
|
||||
"总容量": "总容量",
|
||||
"已使用": "已使用",
|
||||
"系统及其他": "系统及其他",
|
||||
"空闲": "空闲",
|
||||
"已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。": "已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。",
|
||||
"部分目录无法读取,Framely 占用可能低于实际值。": "部分目录无法读取,Framely 占用可能低于实际值。",
|
||||
"占用明细": "占用明细",
|
||||
"程序": "程序",
|
||||
"数据": "数据",
|
||||
"备份、上传文件及其他": "备份、上传文件及其他",
|
||||
"其他插件文件": "其他插件文件",
|
||||
"其他容器文件": "其他容器文件",
|
||||
"暂无数据": "暂无数据",
|
||||
"其他磁盘": "其他磁盘",
|
||||
"{0} 项": "{0} 项",
|
||||
"自定义 Steam 名称": "自定义 Steam 名称",
|
||||
"Steam 显示名称": "Steam 显示名称",
|
||||
"分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。": "分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。",
|
||||
"Invalid Steam display name": "无效的 Steam 显示名称",
|
||||
"Steam display name must be at most 128 characters without control characters": "Steam 显示名称最多 128 个字符,不能包含控制字符",
|
||||
"Steam name update was not acknowledged": "Steam 未确认名称更新",
|
||||
"Quest 兼容转换": "Quest 兼容转换",
|
||||
"这是一个 Quest 游戏?": "这是一个 Quest 游戏?",
|
||||
"打开此选项将尝试进行兼容转换,但不保证一定成功": "打开此选项将尝试进行兼容转换,但不保证一定成功",
|
||||
"此 APK 已完成兼容转换并重新签名。": "此 APK 已完成兼容转换并重新签名。",
|
||||
"可选:使用独立 q2f 工具包转换并重新签名 APK。首次使用会下载工具包,转换结果仍需在 Frame 上验证。": "可选:使用独立 q2f 工具包转换并重新签名 APK。首次使用会下载工具包,转换结果仍需在 Frame 上验证。",
|
||||
"已转换": "已转换",
|
||||
"转换此 APK": "转换此 APK",
|
||||
"准备 Quest 转换工具": "准备 Quest 转换工具",
|
||||
"正在转换 APK": "正在转换 APK",
|
||||
"校验转换结果": "校验转换结果",
|
||||
"转换完成,请确认安装。": "转换完成,请确认安装。",
|
||||
"正在检查转换工具": "正在检查转换工具",
|
||||
"工具包已缓存": "工具包已缓存",
|
||||
"首次使用需下载 {0} 工具包": "首次使用需下载 {0} 工具包",
|
||||
"Quest 转换": "Quest 转换",
|
||||
"要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "要使用新版转换运行包,请重新选择原始 Quest APK 安装。"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@ import {t} from './i18n';
|
||||
import {installKeyboard} from '../../sdk/src/keyboard';
|
||||
import {installHoverFeedback} from '../../sdk/src/hover';
|
||||
export async function api<T=any>(method:string,params:unknown={},signal?:AbortSignal):Promise<T>{const r=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method,params}),signal});if(r.status===401&&r.headers.get('X-Framely-Reauthenticate')==='1')location.replace('/manager');if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));return v.result;}
|
||||
export function viewKey(){const p=location.pathname.split('/');return p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='cast'&&p[2]==='window'?`framely.cast.${p[3]}`:p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';}
|
||||
export function viewKey(){const p=location.pathname.split('/');return p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='cast'&&p[2]==='window'?`framely.cast.${p[3]}`:p[1]==='apk'&&p[2]==='permission'?'framely.apk.permission':p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';}
|
||||
installKeyboard(params=>api('host.keyboard',{view:viewKey(),...params}));
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import React from 'react';
|
||||
import {IconFolder,IconInfoCircle} from '@tabler/icons-react';
|
||||
import {IconBrandMeta,IconFolder,IconInfoCircle} from '@tabler/icons-react';
|
||||
import {PluginImage} from './store';
|
||||
import {Select} from './localized-select';
|
||||
import {Switch} from './switch';
|
||||
import {t} from './i18n';
|
||||
|
||||
type Metadata={name:string;package:string;version:string;versionCode:number;minSdk:number;icon?:string;activities:string[];abis:string[]};
|
||||
type Props={metadata:Metadata;size:string;previous?:Metadata;updating:boolean;containerName:string;running:boolean;displayMode:string;busy:boolean;error:string;onDisplayMode:(value:string)=>void;onStop:()=>void;onCancel:()=>void;onConfirm:()=>void};
|
||||
export function ApkInstallReview({metadata,size,previous,updating,containerName,running,displayMode,busy,error,onDisplayMode,onStop,onCancel,onConfirm}:Props){
|
||||
type Props={metadata:Metadata;size:string;previous?:Metadata;updating:boolean;containerName:string;running:boolean;displayMode:string;busy:boolean;error:string;converted:boolean;questEnabled:boolean;q2f?:{cached:boolean;size:string};q2fError?:string;onDisplayMode:(value:string)=>void;onStop:()=>void;onCancel:()=>void;onQuestChange:(checked:boolean)=>void;onConfirm:()=>void};
|
||||
export function ApkInstallReview({metadata,size,previous,updating,containerName,running,displayMode,busy,error,converted,questEnabled,q2f,q2fError,onDisplayMode,onStop,onCancel,onQuestChange,onConfirm}:Props){
|
||||
const mismatch=!!previous&&previous.package!==metadata.package;
|
||||
return <div className="apk-install-review">
|
||||
<h2>{t(updating?'确认更新 APK':'确认安装 APK')}</h2>
|
||||
<p className="apk-install-intro">{t(updating?'更新应用并保留现有存档和设置。':'核对应用信息,选择合适的显示方式。')}</p>
|
||||
<header className="apk-install-app"><PluginImage kind="lepton" name={metadata.name} src={metadata.icon} size={60}/><div><h3>{metadata.name}</h3><code>{metadata.package}</code><p>{t('版本')} {previous&&`${previous.version} → `}{metadata.version||t('未声明')} <span>·</span> {size}</p></div></header>
|
||||
<section className="apk-install-location" aria-label={t('目标容器')}><IconFolder size={23} aria-hidden="true"/><div><b>{t(updating?'保留原容器':'创建独立容器')}</b>{updating&&<code>{containerName}</code>}<small>{t('每个新应用使用独立容器,更新保留原容器。')}</small></div></section>
|
||||
<section className="apk-install-option apk-quest-option"><IconBrandMeta size={25} stroke={1.7} aria-hidden="true"/><div><b>{t('这是一个 Quest 游戏?')}</b><small>{t('打开此选项将尝试进行兼容转换,但不保证一定成功')}</small>{questEnabled&&!converted&&<small>{q2fError?q2fError:!q2f?t('正在检查转换工具'):q2f.cached?t('工具包已缓存'):t('首次使用需下载 {0} 工具包',{0:q2f.size})}</small>}</div><Switch label={t('这是一个 Quest 游戏?')} checked={questEnabled||converted} disabled={busy} onChange={onQuestChange}/></section>
|
||||
<section className="apk-install-option apk-install-location" aria-label={t('目标容器')}><IconFolder size={23} aria-hidden="true"/><div><b>{t(updating?'保留原容器':'创建独立容器')}</b>{updating&&<code>{containerName}</code>}<small>{t('每个新应用使用独立容器,更新保留原容器。')}</small></div><Switch label={t(updating?'保留原容器':'创建独立容器')} checked disabled onChange={()=>{}}/></section>
|
||||
<div className="apk-install-display"><div><b>{t('显示方式')}</b><small>{t(displayMode==='vr'?'隐藏二维窗口,应用本身需要支持 VR。':'通过二维窗口显示并操作应用。')}</small></div><Select label={t('显示方式')} value={displayMode} disabled={busy} onChange={onDisplayMode} options={[{value:'flat',label:t('平面窗口')},{value:'vr',label:t('VR 应用')}]}/></div>
|
||||
<details className="apk-install-technical"><summary>{t('查看 APK 详情')}</summary><dl className="apk-meta"><dt>{t('版本代码')}</dt><dd>{metadata.versionCode}</dd><dt>{t('最低 Android SDK')}</dt><dd>{metadata.minSdk||t('未声明')}</dd><dt>{t('架构')}</dt><dd>{metadata.abis.join(', ')||t('未声明')}</dd><dt>{t('启动入口')}</dt><dd>{metadata.activities.length||t('未发现启动入口')}</dd></dl></details>
|
||||
{updating&&<div className="apk-install-update-note"><IconInfoCircle size={21} aria-hidden="true"/><div><p>{t('同包名更新保留数据;签名不匹配或降级不会自动卸载重装。')}</p><small>{t('共享容器的备份需手动恢复;独立侧载容器支持一键恢复。')}</small></div></div>}
|
||||
|
||||
@@ -9,5 +9,20 @@
|
||||
.apk-uninstall>h2{margin-bottom:8px}.apk-uninstall-intro{font-size:17px;line-height:1.6;color:var(--muted);margin-bottom:24px}.apk-uninstall-app{display:flex;align-items:center;gap:16px;margin:0 0 24px}.apk-uninstall-app>div{min-width:0}.apk-uninstall-app h3{margin:0 0 5px;font-size:23px}.apk-uninstall-app code{display:block;background:none;padding:0;font-size:15px;color:var(--muted);overflow-wrap:anywhere}.apk-uninstall-app small{display:block;margin-top:6px;font-size:15px}.apk-uninstall-options{border-top:1px solid var(--line);padding:8px 0}.apk-uninstall-options .switch-row{padding:16px 0;gap:20px}.apk-uninstall-options .switch-row small{font-size:16px;line-height:1.55}.apk-uninstall-result{display:flex;align-items:flex-start;gap:10px;padding:14px 16px;background:var(--control-hover);border-radius:5px;font-size:17px;line-height:1.55}.apk-uninstall-result p{margin:0}.apk-uninstall-result svg{margin-top:2px}.apk-uninstall-result small{display:block;font-size:15px;margin-top:4px}.apk-uninstall-result.destructive{color:#efa9a8}.apk-uninstall-result.destructive small{color:inherit}.apk-uninstall footer button{white-space:nowrap}.apk-uninstall footer .danger{font-weight:600;background:#362627;border-color:#895b5b}.apk-uninstall footer .danger:hover:not(:disabled){background:#493032}@media(max-width:700px){.apk-uninstall-app{align-items:flex-start}.apk-uninstall footer{display:grid;grid-template-columns:1fr}.apk-uninstall footer .danger{grid-row:1}.apk-uninstall-options .switch-row{gap:12px}}
|
||||
|
||||
.apk-install-review>h2{margin-bottom:8px}.apk-install-intro{font-size:17px;line-height:1.6;color:var(--muted);margin-bottom:24px}.apk-install-app{display:flex;align-items:center;gap:16px;margin-bottom:24px}.apk-install-app>div{min-width:0}.apk-install-app h3{font-size:23px;margin:0 0 5px}.apk-install-app code,.apk-install-location code{background:none;padding:0;font-size:15px;color:var(--muted)}.apk-install-app p{margin:6px 0 0;font-size:16px;color:var(--muted)}.apk-install-app p span{margin:0 6px}.apk-install-location{display:flex;gap:12px;padding:18px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.apk-install-location svg{flex:none;margin-top:3px;color:var(--muted)}.apk-install-location b,.apk-install-display b{font-size:19px;font-weight:500}.apk-install-location small,.apk-install-display small{display:block;font-size:16px;line-height:1.55;color:var(--muted);margin-top:5px}.apk-install-location code{margin-top:6px;overflow-wrap:anywhere}.apk-install-display{display:flex;gap:20px;align-items:center;padding:22px 0}.apk-install-display>div:first-child{flex:1;min-width:0}.apk-install-display>.framely-select{flex:0 0 200px}.apk-install-technical{font-size:17px;color:var(--muted)}.apk-install-technical summary{cursor:pointer;padding:8px 0}.apk-install-technical .apk-meta{font-size:16px;margin:16px 0}.apk-install-technical dd{color:var(--text)}.apk-install-update-note{display:flex;gap:10px;margin-top:20px;font-size:16px;line-height:1.55;color:var(--muted)}.apk-install-update-note svg{flex:none;margin-top:3px}.apk-install-update-note p{margin:0}.apk-install-update-note small{display:block;font-size:15px;margin-top:6px}.apk-install-review footer button{white-space:nowrap}@media(max-width:700px){.apk-install-app{align-items:flex-start}.apk-install-display{flex-wrap:wrap;gap:12px}.apk-install-display>.framely-select{flex:1 1 100%}.apk-install-review footer{display:grid;grid-template-columns:1fr}.apk-install-review footer .primary{grid-row:1}}
|
||||
.apk-quest-option{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px 0;border-top:1px solid var(--line)}.apk-quest-option>div{flex:1;min-width:0}.apk-quest-option b{display:block;font-size:19px;font-weight:500}.apk-quest-option small{display:block;margin-top:5px;color:var(--muted);font-size:15px;line-height:1.5}.apk-quest-option button{flex:none;margin:0}.apk-quest-option .badge{white-space:nowrap}@media(max-width:700px){.apk-quest-option{align-items:flex-start;flex-wrap:wrap}.apk-quest-option button{width:100%}}
|
||||
|
||||
.apk-install-option{display:flex;align-items:center;gap:14px;padding:18px 0;border-top:1px solid var(--line)}
|
||||
.apk-install-option>svg{flex:none;color:var(--muted)}
|
||||
.apk-install-option>div{flex:1;min-width:0}
|
||||
.apk-install-option b{display:block;font-size:19px;font-weight:500}
|
||||
.apk-install-option small{display:block;margin-top:5px;color:var(--muted);font-size:15px;line-height:1.5}
|
||||
.apk-install-option .ui-switch{flex:none;width:60px;min-width:60px;margin:0}
|
||||
.apk-install-location.apk-install-option{border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
|
||||
.apk-install-location.apk-install-option .ui-switch:disabled{opacity:1;cursor:default}
|
||||
@media(max-width:700px){.apk-quest-option.apk-install-option{align-items:center;flex-wrap:nowrap}.apk-quest-option.apk-install-option .ui-switch{width:60px}}
|
||||
|
||||
.apk-trigger-setting{display:flex;align-items:center;gap:24px;padding:18px 0}.apk-trigger-setting>div:first-child{flex:1;min-width:0}.apk-trigger-setting label{margin:0}.apk-trigger-setting b{font-size:19px}.apk-trigger-setting small{display:block;font-size:17px;color:var(--muted);line-height:1.55;margin-top:5px}.apk-trigger-controls{display:flex;align-items:center;gap:16px;flex:0 0 390px}.apk-trigger-controls>div{display:flex;align-items:center;gap:12px;flex:1;min-width:0}.apk-trigger-controls input[type=range]{width:100%;min-width:0;accent-color:var(--blue);padding:0;margin:0;min-height:44px;cursor:pointer}.apk-trigger-controls output{font-size:18px;min-width:48px;text-align:right;font-variant-numeric:tabular-nums}.apk-trigger-controls button{margin:0}@media(max-width:700px){.apk-trigger-setting{flex-wrap:wrap;gap:12px}.apk-trigger-setting>div:first-child{flex-basis:100%}.apk-trigger-controls{flex:1 1 100%}}
|
||||
|
||||
.apk-steam-names{width:100%;margin-bottom:24px}.apk-steam-names>p{color:var(--muted);line-height:1.55}.apk-steam-name{display:flex;align-items:center;gap:24px;padding:18px 0;border-bottom:1px solid var(--line);width:100%}.apk-steam-name .apk-copy code{display:block;margin-top:6px;color:var(--muted)}.apk-steam-name-controls{display:flex;align-items:center;gap:12px;flex:0 1 50%;min-width:0}.apk-steam-name-controls input{flex:1;min-width:0;margin:0}.apk-steam-name-controls button{flex:none;margin:0}@media(max-width:700px){.apk-steam-name{flex-wrap:wrap;gap:12px}.apk-steam-name-controls{flex-basis:100%}}
|
||||
|
||||
.apk-meta dd small{display:block;margin-top:6px;color:var(--muted);line-height:1.5}
|
||||
+22
-10
@@ -13,32 +13,44 @@ import {ApkUninstall} from './apk-uninstall';
|
||||
import {ApkInstallReview} from './apk-install-review';
|
||||
import {GamepadTriggerSetting} from './gamepad-trigger-setting';
|
||||
type Metadata={package:string;name:string;version:string;versionCode:number;minSdk:number;activities:string[];declaredActivities:string[];icon?:string;abis:string[];vr:boolean};
|
||||
type App={steamLaunch?:boolean;steamRegistered?:boolean;steamRegistrationError?:string;stateKnown?:boolean;id:string;context:string;metadata:Metadata;installed:boolean;running:boolean;managed:boolean;steam:boolean;size:number;pending?:string;activity?:string;showWindow?:boolean|null;orientation?:string|null;framebufferCompatibility?:boolean};
|
||||
type App={steamName?:string|null;steamLaunch?:boolean;steamRegistered?:boolean;steamRegistrationError?:string;stateKnown?:boolean;id:string;context:string;metadata:Metadata;installed:boolean;running:boolean;managed:boolean;steam:boolean;size:number;pending?:string;activity?:string;showWindow?:boolean|null;orientation?:string|null;framebufferCompatibility?:boolean;q2fVersion?:string|null};
|
||||
type Container={acceptsAdditionalApps?:boolean;id:string;name:string;baked:string;running:boolean;managed:boolean;steam:boolean};
|
||||
type Data={apps:App[];containers:Container[];warnings:string[];roots:string[];available:boolean;retainSteamEntries?:boolean;gamepadEnabled?:boolean;gamepadSource?:string;gamepadRumble?:boolean;gamepadTriggerThreshold?:number;autoStopContainer?:boolean;stopContainerOnClose?:boolean};
|
||||
type Cleanup={id:string;kind:string;name:string;bytes:number;sensitive:boolean;app?:string;context?:string;restorable?:boolean;unknown?:boolean};
|
||||
type Review={ticket:string;metadata:Metadata;bytes:number};
|
||||
type Review={ticket:string;metadata:Metadata;bytes:number;q2fVersion?:string};
|
||||
type Q2fStatus={id:string;bytes:number;cached:boolean};
|
||||
type Confirm={operation:string;app?:App;container?:Container;backup?:string;items?:string[];title:string;description:string;purge?:boolean;deleteContainer?:boolean};
|
||||
function bytes(n:number){return n<1024?`${n} B`:n<1024*1024?`${(n/1024).toFixed(1)} KiB`:n<1024**3?`${(n/1024**2).toFixed(1)} MiB`:`${(n/1024**3).toFixed(2)} GiB`;}
|
||||
const phases:Record<string,string>={loading:'正在读取',preparing:'准备中',uploading:'上传 APK',inspecting:'校验 APK',starting:'启动容器',launching:'启动应用',started:'应用已启动,正在检查',installing:'安装 APK',verifying:'核对安装结果','backing-up':'备份容器数据',uninstalling:'卸载应用',done:'完成',failed:'失败',cancelled:'操作已取消'};
|
||||
const phases:Record<string,string>={loading:'正在读取',preparing:'准备中',uploading:'上传 APK',inspecting:'校验 APK',starting:'启动容器',launching:'启动应用',started:'应用已启动,正在检查',installing:'安装 APK',verifying:'核对安装结果','backing-up':'备份容器数据',uninstalling:'卸载应用','q2f-preparing':'准备 Quest 转换工具','q2f-converting':'正在转换 APK','q2f-verifying':'校验转换结果',done:'完成',failed:'失败',cancelled:'操作已取消'};
|
||||
const operationLabels:Record<string,string>={'steam.settings':'保存 Steam 启动设置','steam.retention.settings':'保存 Steam 入口设置','lifecycle.settings':'保存容器退出设置','gamepad.settings':'保存手柄设置',install:'安装 APK',launch:'启动应用',close:'关闭应用',uninstall:'卸载应用',purge:'清除保留数据',clear:'清除应用数据',reconcile:'核对应用状态',settings:'保存设置','container.start':'启动容器','container.stop':'停止容器','container.delete':'删除容器',restore:'恢复备份',cleanup:'数据清理','root.add':'添加数据位置','root.remove':'移除数据位置'};
|
||||
function SteamNameSetting({app,busy,onSave}:{app:App;busy:boolean;onSave:(name:string)=>void}){
|
||||
const [name,setName]=useState(app.steamName??'');
|
||||
useEffect(()=>setName(app.steamName??''),[app.id,app.steamName]);
|
||||
return <form className="apk-steam-name" onSubmit={e=>{e.preventDefault();onSave(name);}}>
|
||||
<div className="apk-copy"><b>{t('Steam 显示名称')}</b>{app.steamRegistrationError&&<p className="error">{app.steamRegistrationError}</p>}</div>
|
||||
<div className="apk-steam-name-controls"><input aria-label={`${t('Steam 显示名称')} · ${app.metadata.name}`} placeholder={app.metadata.name} value={name} maxLength={128} disabled={busy} onChange={e=>setName(e.target.value)}/><button type="submit" disabled={busy||name.trim()===(app.steamName??'')}>{t('保存')}</button></div>
|
||||
</form>;
|
||||
}
|
||||
export function ApkManager({view:externalView,onViewChange}:{view?:string;onViewChange?:(view:string)=>void}={}){
|
||||
const[data,setData]=useState<Data>({apps:[],containers:[],warnings:[],roots:[],available:true}),[loading,setLoading]=useState(true),[localView,setLocalView]=useState('apps'),[filter,setFilter]=useState('installed'),[query,setQuery]=useState(''),[error,setError]=useState(''),[review,setReview]=useState<Review|null>(null),[target,setTarget]=useState(''),[displayMode,setDisplayMode]=useState('flat'),[updating,setUpdating]=useState<App|null>(null),[details,setDetails]=useState<App|null>(null),[confirm,setConfirm]=useState<Confirm|null>(null),[job,setJob]=useState<(Job&{cancellable?:boolean;operation?:string})|null>(null),[busy,setBusy]=useState(false),[cleanup,setCleanup]=useState<Cleanup[]>([]),[selected,setSelected]=useState<string[]>([]),[log,setLog]=useState<{name:string;text:string}|null>(null),[path,setPath]=useState(''),[contextName,setContextName]=useState(''),[notice,setNotice]=useState('');
|
||||
const[data,setData]=useState<Data>({apps:[],containers:[],warnings:[],roots:[],available:true}),[loading,setLoading]=useState(true),[localView,setLocalView]=useState('apps'),[filter,setFilter]=useState('installed'),[query,setQuery]=useState(''),[error,setError]=useState(''),[review,setReview]=useState<Review|null>(null),[originalReview,setOriginalReview]=useState<Review|null>(null),[questEnabled,setQuestEnabled]=useState(false),[q2f,setQ2f]=useState<Q2fStatus|null>(null),[q2fError,setQ2fError]=useState(''),[target,setTarget]=useState(''),[displayMode,setDisplayMode]=useState('flat'),[updating,setUpdating]=useState<App|null>(null),[details,setDetails]=useState<App|null>(null),[confirm,setConfirm]=useState<Confirm|null>(null),[job,setJob]=useState<(Job&{cancellable?:boolean;operation?:string})|null>(null),[busy,setBusy]=useState(false),[cleanup,setCleanup]=useState<Cleanup[]>([]),[selected,setSelected]=useState<string[]>([]),[log,setLog]=useState<{name:string;text:string}|null>(null),[path,setPath]=useState(''),[contextName,setContextName]=useState(''),[notice,setNotice]=useState('');
|
||||
const view=externalView??localView;const setView=(value:string)=>{setLocalView(value);onViewChange?.(value);};
|
||||
useEffect(()=>{if(view==='cleanup')void loadCleanup();},[view]);
|
||||
useEffect(()=>{if(!review||review.q2fVersion){setQ2f(null);setQ2fError('');return;}let active=true;setQ2f(null);setQ2fError('');void api<Q2fStatus>('apk.q2f.status').then(value=>{if(active)setQ2f(value);}).catch(e=>{if(active)setQ2fError(String(e));});return()=>{active=false;};},[review?.ticket]);
|
||||
const input=useRef<HTMLInputElement>(null),controller=useRef<AbortController|null>(null),selection=useRef<{app:string;uninstall:boolean}|null>(null),alive=useRef(true),busyRef=useRef(false);
|
||||
const updateJob=(v:Job)=>{if(alive.current)setJob(v);};
|
||||
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');if(!active.job||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean;operation?:string}>('job.status',{job:active.job});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(value.phase==='failed')setError(t(value.error??'操作失败'));else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(String(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}}
|
||||
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');let saved:{job:string;app:App|null;context:string;review?:Review;displayMode?:string}|null=null;try{saved=JSON.parse(sessionStorage.getItem('framely.apk.q2f.job')||'null');}catch{}const id=active.job||saved?.job;if(!id||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean;operation?:string}>('job.status',{job:id});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(saved?.job===id)sessionStorage.removeItem('framely.apk.q2f.job');if(value.phase==='failed'||value.phase==='cancelled'){if(saved?.review){setReview(saved.review);setQuestEnabled(true);setDisplayMode(saved.displayMode??'vr');setUpdating(saved.app);setTarget(saved.context);}setError(t(value.phase==='cancelled'?'操作已取消':value.error??'操作失败'));}else if(value.result?.q2fVersion){setReview(value.result);setOriginalReview(saved?.review??null);setQuestEnabled(true);setDisplayMode(saved?.displayMode??'vr');setUpdating(saved?.app??null);setTarget(saved?.context??'');if(saved?.review)await perform('install',{ticket:value.result.ticket,context:saved.context,app:saved.app?.id,showWindow:saved.displayMode==='flat',approve:true},saved.review.ticket);else setNotice(t('转换完成,请确认安装。'));}else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(String(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}}
|
||||
useEffect(()=>{if(data.retainSteamEntries===false||!data.apps.some(a=>a.installed&&!a.steam&&a.stateKnown!==false&&!a.pending&&(a.metadata.activities.length||a.activity)&&(a.steamLaunch??true)&&!a.steamRegistered))return;const timer=setInterval(()=>{if(!busyRef.current)void refresh();},20000);return()=>clearInterval(timer);},[data.apps,data.retainSteamEntries]);
|
||||
async function refresh(){setLoading(true);try{const r=await runJob('apk.list.start');if(alive.current){setData(r.result);setDetails(old=>old?r.result.apps.find((a:App)=>a.id===old.id)??null:null);}}catch(e){if(alive.current)setError(String(e));}finally{if(alive.current)setLoading(false);}}
|
||||
useEffect(()=>{alive.current=true;void refresh();void resumeOperation();const select=(e:Event)=>{const v=(e as CustomEvent).detail;if(v?.kind==='apk.selection'){selection.current={app:v.app,uninstall:!!v.uninstall};void refresh();}};window.addEventListener('framely.event',select);return()=>{alive.current=false;window.removeEventListener('framely.event',select);controller.current?.abort();};},[]);
|
||||
useEffect(()=>{if(selection.current){const a=data.apps.find(a=>a.id===selection.current!.app);if(a){if(a.steam){setError(t('请通过 Steam 管理此应用。'));selection.current=null;return;}if(selection.current.uninstall)askUninstall(a);else setDetails(a);selection.current=null;}}},[data]);
|
||||
useEffect(()=>{const close=(e:Event)=>{if(e instanceof KeyboardEvent&&e.key!=='Escape')return;if(busyRef.current)return;if(confirm)setConfirm(null);else if(log)setLog(null);else if(review)void discard();else setDetails(null);};window.addEventListener('keydown',close);window.addEventListener('framely.back',close);return()=>{window.removeEventListener('keydown',close);window.removeEventListener('framely.back',close);};},[confirm,log,review]);
|
||||
useEffect(()=>{const dialog=document.querySelector<HTMLElement>('.apk-dialog');if(!dialog)return;const prev=document.activeElement as HTMLElement;const controls=()=>Array.from(dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input,select,a[href]')).filter(e=>e.getClientRects().length);controls()[0]?.focus();const key=(e:KeyboardEvent)=>{if(e.key!=='Tab')return;const a=controls();if(!a.length)return;if(e.shiftKey&&(document.activeElement===a[0]||!dialog.contains(document.activeElement))){e.preventDefault();a.at(-1)?.focus();}else if(!e.shiftKey&&(document.activeElement===a.at(-1)||!dialog.contains(document.activeElement))){e.preventDefault();a[0].focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);prev?.focus();};},[review,details,confirm,log,job?.id]);
|
||||
async function discard(){if(review)await api('apk.review.drop',{ticket:review.ticket}).catch(()=>{});setReview(null);setUpdating(null);}
|
||||
async function inspect(file:File|string,selectedApp:App|null=updating){if(!(typeof file==='string'?file:file.name).toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob(typeof file==='string'?{id:'local',kind:'apk',phase:'inspecting',received:0,cancellable:false}:{id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{let result;if(typeof file==='string'){const response=await fetch('/host/files/apk-inspect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[file]}),signal:c.signal});if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));const data=await response.json();if(data.error)throw new Error(t(data.error));result=await waitJob(data.result.job,updateJob);}else{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});result=await runJob('apk.inspect.start',{upload},updateJob);}if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setReview(result.result);setDisplayMode((selectedApp?.showWindow??!result.result.metadata.vr)?'flat':'vr');setTarget(selectedApp?.context??'');setJob(null);}catch(e){setJob(null);setError(String(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}}
|
||||
async function discard(){if(review)await api('apk.review.drop',{ticket:review.ticket}).catch(()=>{});if(originalReview)await api('apk.review.drop',{ticket:originalReview.ticket}).catch(()=>{});setReview(null);setOriginalReview(null);setQuestEnabled(false);setUpdating(null);}
|
||||
async function installReviewed(){if(!review)return;const original=review;if(!questEnabled||review.q2fVersion){await perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true});return;}setBusy(true);busyRef.current=true;setError('');setNotice('');try{const started=await api<{job:string}>('apk.q2f.start',{ticket:original.ticket});sessionStorage.setItem('framely.apk.q2f.job',JSON.stringify({job:started.job,app:updating,context:target,review:original,displayMode}));const result=await waitJob(started.job,updateJob);sessionStorage.removeItem('framely.apk.q2f.job');const converted=result.result as Review;setOriginalReview(original);setReview(converted);await perform('install',{ticket:converted.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true},original.ticket);}catch(e){sessionStorage.removeItem('framely.apk.q2f.job');setError(String(e));}finally{setJob(null);setBusy(false);busyRef.current=false;}}
|
||||
async function revertConversion(){if(!review?.q2fVersion||!originalReview)return;setBusy(true);try{await api('apk.review.drop',{ticket:review.ticket});setReview(originalReview);setOriginalReview(null);setQuestEnabled(false);setDisplayMode((updating?.showWindow??!originalReview.metadata.vr)?'flat':'vr');setError('');}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
async function inspect(file:File|string,selectedApp:App|null=updating){if(!(typeof file==='string'?file:file.name).toLowerCase().endsWith('.apk')){setError(t('第一版仅支持单个 APK 文件。'));return;}setBusy(true);busyRef.current=true;setError('');const c=new AbortController();controller.current=c;setJob(typeof file==='string'?{id:'local',kind:'apk',phase:'inspecting',received:0,cancellable:false}:{id:'upload',kind:'apk',phase:'uploading',received:0,total:file.size});try{let result;if(typeof file==='string'){const response=await fetch('/host/files/apk-inspect',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:[file]}),signal:c.signal});if(!response.ok)throw new Error(t('服务请求失败:{0}',{0:response.status}));const data=await response.json();if(data.error)throw new Error(t(data.error));result=await waitJob(data.result.job,updateJob);}else{const upload=await uploadFile(file,c.signal,percent=>setJob(j=>j?{...j,received:Math.round(file.size*percent/100)}:null),{start:size=>api('apk.upload.start',{size}),chunk:uploadChunk,abort:upload=>api('upload.abort',{upload})});result=await runJob('apk.inspect.start',{upload},updateJob);}if(!alive.current){await api('apk.review.drop',{ticket:result.result.ticket}).catch(()=>{});return;}setOriginalReview(null);setQuestEnabled(false);setReview(result.result);setDisplayMode((selectedApp?.showWindow??!result.result.metadata.vr)?'flat':'vr');setTarget(selectedApp?.context??'');setJob(null);}catch(e){setJob(null);setError(String(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}}
|
||||
function choose(a?:App){setUpdating(a??null);if(input.current)openLocalFileInput(input.current,path=>{if(alive.current)void inspect(path,a??null);});}
|
||||
async function perform(operation:string,params:Record<string,unknown>={}){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(String(e));await refresh();}finally{setBusy(false);busyRef.current=false;}}
|
||||
async function perform(operation:string,params:Record<string,unknown>={},originalTicket=originalReview?.ticket){setBusy(true);busyRef.current=true;setError('');setNotice('');try{const r=await runJob('apk.operation.start',{operation,...params},updateJob);setJob(null);setConfirm(null);if(operation==='install'){if(originalTicket)await api('apk.review.drop',{ticket:originalTicket}).catch(()=>{});setOriginalReview(null);setQuestEnabled(false);setReview(null);setUpdating(null);setNotice(t('APK 已安装。应用数据和图标排序会保留。'));}else if(operation==='cleanup'){setNotice(t('已释放 {0},失败 {1} 项。',{0:bytes(r.result.freedBytes),1:r.result.failed.length}));if(r.result.failed.length)setError(r.result.failed.map((v:any)=>`${v.id}: ${v.error}`).join('\n'));await loadCleanup();}else if(operation==='restore')setNotice(t('备份已恢复,请启动应用验证存档;恢复前状态已另行备份。'));else setNotice(t('操作完成'));await refresh();}catch(e){setJob(null);setError(String(e));await refresh();}finally{setBusy(false);busyRef.current=false;}}
|
||||
function canDeleteContainer(a:App){const members=data.apps.filter(other=>other.context===a.context);const container=data.containers.find(c=>c.id===a.context);return !!container&&!container.steam&&members.length===1&&members[0].id===a.id&&a.stateKnown!==false&&!a.pending;}
|
||||
function askUninstall(a:App){setError('');setConfirm({operation:a.installed?'uninstall':'purge',app:a,title:a.installed?t('卸载应用'):t('清除保留数据'),description:'',purge:!a.installed,deleteContainer:!a.installed&&canDeleteContainer(a)});}
|
||||
async function loadCleanup(){try{const items=await api<Cleanup[]>('apk.cleanup.list');setCleanup(items);setSelected([]);}catch(e){setError(String(e));}}
|
||||
@@ -62,8 +74,8 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
|
||||
{view==='containers'&&<><div className="apk-list">{data.containers.map(c=><article className="apk-container" key={c.id}><div className="apk-copy"><h2><IconFolder size={24}/>{containerTitle(c)}</h2><code>{c.baked}</code><p>{t(c.steam?'Steam 管理':c.managed?'Framely 管理':'已有侧载容器')} · {t(c.running?'运行中':'已停止')}</p>{!data.apps.some(a=>a.context===c.id&&a.installed)&&<p>{t('未发现已安装的应用,启动容器不会安装 APK。')}</p>}</div><div className="apk-actions"><button disabled={busy} onClick={()=>void showLog(c.id)}><IconFileText size={20}/>{t('日志')}</button>{!c.steam&&<><button disabled={busy} onClick={()=>containerAsk(c,c.running?'container.stop':'container.start')}>{t(c.running?'停止容器':'启动容器')}</button><button className="danger" disabled={busy} onClick={()=>containerAsk(c,'container.delete')}>{t('删除容器')}</button></>}</div></article>)}</div><section className="apk-locations"><h2>{t('其他数据位置')}</h2><p>{t('添加其他工具使用的 baked 数据目录,不移动原有应用。')}</p><div className="apk-filters"><input value={path} placeholder="/path/to/compatdata/baked" aria-label={t('Lepton 数据目录')} onChange={e=>setPath(e.target.value)}/><input value={contextName} placeholder={t('原容器名称(可选)')} aria-label={t('原容器名称(可选)')} onChange={e=>setContextName(e.target.value)}/><button disabled={busy||!path.trim()} onClick={()=>void perform('root.add',{path,context:contextName}).then(()=>setPath(''))}>{t('添加')}</button></div>{data.roots.map(p=><div className="apk-location" key={p}><code>{p}</code><button disabled={busy} onClick={()=>void perform('root.remove',{path:p})}>{t('移除扫描位置')}</button></div>)}</section></>}
|
||||
{view==='cleanup'&&<><div className="apk-cleanup-heading"><div><h2>{t('数据清理')}</h2><p>{t('存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。')}</p></div><button className="danger" disabled={busy||!selected.length} onClick={()=>setConfirm({operation:'cleanup',items:selected,title:t('确认数据清理'),description:cleanup.filter(i=>selected.includes(i.id)).map(i=>`${i.name} · ${bytes(i.bytes)}`).join('\n')+`\n${t('选中的文件将永久删除,无法撤销。')}`})}><IconTrash size={22}/>{t('清理所选')}</button></div>{cleanup.map(i=><article className="apk-cleanup-row" key={i.id}><Switch checked={selected.includes(i.id)} label={`${t('选择')} ${i.name}`} disabled={busy||i.unknown} onChange={checked=>setSelected(s=>checked?[...s,i.id]:s.filter(v=>v!==i.id))}/><div className="apk-copy"><b>{i.name}</b><p>{t(i.kind==='unknown'?'未知归属的数据':i.kind==='data'?'保留的应用数据':i.kind==='backups'?'恢复备份':(i.kind==='reviews'||i.kind==='uploads')?'过期上传文件':'旧 APK')} · {bytes(i.bytes)}</p>{i.unknown?<small>{t('无法确认归属,不提供自动删除。')}</small>:i.sensitive&&<small>{t('删除后不可恢复')}</small>}</div>{i.restorable&&<button disabled={busy} onClick={()=>setConfirm({operation:'restore',container:contextFor(i.context!),backup:i.name,title:t('恢复容器备份'),description:t('恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。')})}>{t('恢复')}</button>}</article>)}{!cleanup.length&&<p className="empty">{t('没有可清理的数据。')}</p>}</>}
|
||||
{!!data.warnings.length&&<details className="apk-warnings"><summary>{t('部分容器无法读取')}</summary>{data.warnings.map((w,i)=><p key={i}>{w}</p>)}</details>}
|
||||
{details&&!confirm&&!review&&!log&&!job&&dialog(<><header className="apk-dialog-heading"><PluginImage kind="lepton" name={details.metadata.name} src={details.metadata.icon} size={64}/><div><h2>{details.metadata.name}</h2><code>{details.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{details.metadata.version} ({details.metadata.versionCode})</dd><dt>{t('容器')}</dt><dd>{contextFor(details.context)?.name??details.context}</dd><dt>{t('空间占用')}</dt><dd>{bytes(details.size)}</dd></dl>{details.pending&&<><p className="error">{t(details.pending)}</p><button disabled={busy} onClick={()=>void perform('reconcile',{app:details.id})}>{t('核对实际安装状态')}</button></>}<div className="apk-actions">{details.installed&&details.stateKnown!==false&&!details.steam&&<><button disabled={busy||!details.metadata.activities.length&&!details.activity} onClick={()=>void perform('launch',{app:details.id})}>{t('打开')}</button><button disabled={busy} onClick={()=>void perform('close',{app:details.id})}><IconPlayerStop size={20}/>{t('关闭应用')}</button><button disabled={busy} onClick={()=>choose(details)}>{t('安装新版 APK')}</button><button className="danger" disabled={busy} onClick={()=>setConfirm({operation:'clear',app:details,title:t('清除应用数据'),description:t('将永久删除该应用的存档和设置,无法撤销。')})}>{t('清除应用数据')}</button></>}{!details.installed&&details.stateKnown!==false&&!details.steam&&<button disabled={busy} onClick={()=>choose(details)}>{t('重新安装 APK')}</button>}<button disabled={busy} onClick={()=>void showLog(details)}><IconFileText size={20}/>{t('日志')}</button><button className="danger" disabled={busy||!!details.pending||details.stateKnown===false||details.steam} onClick={()=>askUninstall(details)}>{t(details.installed?'卸载应用':'清除保留数据')}</button></div>{details.installed&&details.stateKnown!==false&&!details.steam&&<details className="apk-advanced"><summary>{t('高级设置')}</summary><SwitchRow label={t('通过 Steam 启动')} description={t('通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。')} checked={details.steamLaunch??true} disabled={busy} onChange={enabled=>void perform('steam.settings',{app:details.id,enabled})}/>{data.retainSteamEntries!==false&&(details.steamLaunch??true)&&!details.steamRegistered&&<p role="status">{t('等待 Steam 注册,Steam 就绪后会自动重试。')}</p>}{details.steamRegistrationError&&<p className="error">{details.steamRegistrationError}</p>}<p>{t('默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。')}</p><SwitchRow label={t('自动处理窗口显示')} checked={details.showWindow==null} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked?null:true})}/>{details.showWindow!=null&&<SwitchRow label={t('显示安卓窗口')} checked={details.showWindow} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked})}/>}<p>{t('窗口设置将在下次打开应用时应用;必要时自动重启容器。')}</p><SwitchRow label={t('帧缓冲兼容修复')} description={t('仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。')} checked={details.framebufferCompatibility??false} disabled={busy} onChange={enabled=>void perform('settings',{app:details.id,framebufferCompatibility:enabled})}/><label>{t('窗口方向')}<Select label={t('窗口方向')} value={details.orientation??'auto'} disabled={busy} onChange={orientation=>void perform('settings',{app:details.id,orientation})} options={[{value:'auto',label:t('自动选择')},{value:'landscape',label:t('横屏')},{value:'portrait',label:t('竖屏')}]}/></label><label>{t('启动入口')}<Select label={t('启动入口')} value={details.activity??''} disabled={busy} onChange={activity=>void perform('settings',{app:details.id,activity})} options={[{value:'',label:t('自动选择')},...details.metadata.declaredActivities.map(a=>({value:a,label:a}))]}/></label></details>}<footer><button onClick={()=>setDetails(null)}>{t('完成')}</button></footer></>,t('管理应用'))}
|
||||
{review&&!confirm&&!job&&dialog(<ApkInstallReview metadata={review.metadata} size={bytes(review.bytes)} previous={updating?.metadata} displayMode={displayMode} onDisplayMode={setDisplayMode} containerName={installContainer?containerTitle(installContainer):target} updating={!!updating} running={!!installContainer?.running&&!installContainer.steam} busy={busy} error={error} onStop={()=>installContainer&&containerAsk(installContainer,'container.stop')} onCancel={()=>void discard()} onConfirm={()=>void perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true})}/>,t('安装 APK'))}
|
||||
{details&&!confirm&&!review&&!log&&!job&&dialog(<><header className="apk-dialog-heading"><PluginImage kind="lepton" name={details.metadata.name} src={details.metadata.icon} size={64}/><div><h2>{details.metadata.name}</h2><code>{details.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{details.metadata.version} ({details.metadata.versionCode})</dd><dt>{t('容器')}</dt><dd>{contextFor(details.context)?.name??details.context}</dd><dt>{t('空间占用')}</dt><dd>{bytes(details.size)}</dd>{details.installed&&details.q2fVersion&&<><dt>{t('Quest 转换')}</dt><dd><code>{details.q2fVersion}</code><small>{t('要使用新版转换运行包,请重新选择原始 Quest APK 安装。')}</small></dd></>}</dl>{details.installed&&details.stateKnown!==false&&!details.steam&&<section className="apk-steam-names"><h3>{t('自定义 Steam 名称')}</h3><p>{t('分别设置应用在 Steam 中显示的名称。留空恢复应用原名;更新 APK 后保留。Steam 不可用时将在就绪后同步。')}</p><SteamNameSetting app={details} busy={busy} onSave={steamName=>void perform('settings',{app:details.id,steamName})}/></section>}{details.pending&&<><p className="error">{t(details.pending)}</p><button disabled={busy} onClick={()=>void perform('reconcile',{app:details.id})}>{t('核对实际安装状态')}</button></>}<div className="apk-actions">{details.installed&&details.stateKnown!==false&&!details.steam&&<><button disabled={busy||!details.metadata.activities.length&&!details.activity} onClick={()=>void perform('launch',{app:details.id})}>{t('打开')}</button><button disabled={busy} onClick={()=>void perform('close',{app:details.id})}><IconPlayerStop size={20}/>{t('关闭应用')}</button><button disabled={busy} onClick={()=>choose(details)}>{t('安装新版 APK')}</button><button className="danger" disabled={busy} onClick={()=>setConfirm({operation:'clear',app:details,title:t('清除应用数据'),description:t('将永久删除该应用的存档和设置,无法撤销。')})}>{t('清除应用数据')}</button></>}{!details.installed&&details.stateKnown!==false&&!details.steam&&<button disabled={busy} onClick={()=>choose(details)}>{t('重新安装 APK')}</button>}<button disabled={busy} onClick={()=>void showLog(details)}><IconFileText size={20}/>{t('日志')}</button><button className="danger" disabled={busy||!!details.pending||details.stateKnown===false||details.steam} onClick={()=>askUninstall(details)}>{t(details.installed?'卸载应用':'清除保留数据')}</button></div>{details.installed&&details.stateKnown!==false&&!details.steam&&<details className="apk-advanced"><summary>{t('高级设置')}</summary><SwitchRow label={t('通过 Steam 启动')} description={t('通过 Steam 启动,可由 Steam 原生关闭窗口。入口是否常驻遵循 APK 设置页的选项。')} checked={details.steamLaunch??true} disabled={busy} onChange={enabled=>void perform('steam.settings',{app:details.id,enabled})}/>{data.retainSteamEntries!==false&&(details.steamLaunch??true)&&!details.steamRegistered&&<p role="status">{t('等待 Steam 注册,Steam 就绪后会自动重试。')}</p>}{details.steamRegistrationError&&<p className="error">{details.steamRegistrationError}</p>}<p>{t('默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。')}</p><SwitchRow label={t('自动处理窗口显示')} checked={details.showWindow==null} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked?null:true})}/>{details.showWindow!=null&&<SwitchRow label={t('显示安卓窗口')} checked={details.showWindow} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked})}/>}<p>{t('窗口设置将在下次打开应用时应用;必要时自动重启容器。')}</p><SwitchRow label={t('帧缓冲兼容修复')} description={t('仅用于部分游戏有声音但黑屏的问题,默认关闭。修改后需重启应用生效,不改变系统驱动或游戏文件。')} checked={details.framebufferCompatibility??false} disabled={busy} onChange={enabled=>void perform('settings',{app:details.id,framebufferCompatibility:enabled})}/><label>{t('窗口方向')}<Select label={t('窗口方向')} value={details.orientation??'auto'} disabled={busy} onChange={orientation=>void perform('settings',{app:details.id,orientation})} options={[{value:'auto',label:t('自动选择')},{value:'landscape',label:t('横屏')},{value:'portrait',label:t('竖屏')}]}/></label><label>{t('启动入口')}<Select label={t('启动入口')} value={details.activity??''} disabled={busy} onChange={activity=>void perform('settings',{app:details.id,activity})} options={[{value:'',label:t('自动选择')},...details.metadata.declaredActivities.map(a=>({value:a,label:a}))]}/></label></details>}<footer><button onClick={()=>setDetails(null)}>{t('完成')}</button></footer></>,t('管理应用'))}
|
||||
{review&&!confirm&&!job&&dialog(<ApkInstallReview metadata={review.metadata} size={bytes(review.bytes)} previous={updating?.metadata} displayMode={displayMode} onDisplayMode={setDisplayMode} containerName={installContainer?containerTitle(installContainer):target} updating={!!updating} running={!!installContainer?.running&&!installContainer.steam} busy={busy} error={error} converted={!!review.q2fVersion} questEnabled={questEnabled} q2f={q2f?{cached:q2f.cached,size:bytes(q2f.bytes)}:undefined} q2fError={q2fError} onStop={()=>installContainer&&containerAsk(installContainer,'container.stop')} onCancel={()=>void discard()} onQuestChange={checked=>{if(!checked&&review.q2fVersion)void revertConversion();else setQuestEnabled(checked);}} onConfirm={()=>void installReviewed()}/>,t('安装 APK'))}
|
||||
{confirm?.app&&!job&&['uninstall','purge'].includes(confirm.operation)&&dialog(<ApkUninstall app={confirm.app} size={bytes(confirm.app.size)} purge={!!confirm.purge} deleteContainer={!!confirm.deleteContainer&&!!confirm.purge&&canDeleteContainer(confirm.app)} exclusive={canDeleteContainer(confirm.app)} busy={busy} error={error} onPurge={purge=>setConfirm(c=>c?.app?{...c,purge,deleteContainer:purge&&canDeleteContainer(c.app)}:c)} onDeleteContainer={deleteContainer=>setConfirm(c=>c?{...c,deleteContainer}:c)} onCancel={()=>setConfirm(null)} onConfirm={()=>void perform(confirm.operation,{app:confirm.app!.id,purge:confirm.purge,deleteContainer:!!confirm.deleteContainer&&!!confirm.purge&&canDeleteContainer(confirm.app!),approve:true})}/>,confirm.title)}
|
||||
{confirm&&!job&&!(confirm.app&&['uninstall','purge'].includes(confirm.operation))&&dialog(<><h2>{confirm.title}</h2><p className="apk-confirm-copy">{confirm.description}</p>{confirm.app&&<p><b>{confirm.app.metadata.name}</b><br/><code>{confirm.app.metadata.package}</code></p>}{deletesData&&<p>{t('永久删除的数据无法撤销。')}</p>}{error&&<p className="error">{error}</p>}<footer><button disabled={busy} onClick={()=>setConfirm(null)}>{t('取消')}</button><button className={deletesData||confirm.operation==='uninstall'?'danger':'primary'} disabled={busy} onClick={()=>void perform(confirm.operation,{app:confirm.app?.id,context:confirm.container?.id,backup:confirm.backup,items:confirm.items,purge:confirm.purge,approve:true})}>{t('确认')}</button></footer></>,confirm.title)}
|
||||
{job&&dialog(<><h2>{t(job.phase==='preparing'&&job.operation?operationLabels[job.operation]??phases[job.phase]:phases[job.phase]??job.phase)}</h2><p>{t('操作进行中,请等待实际结果。')}</p><progress value={job.phase==='uploading'?job.received:undefined} max={job.phase==='uploading'?job.total:undefined}/>{job.phase==='uploading'&&<p>{bytes(job.received)} / {bytes(job.total??0)}</p>}{job.cancellable===false?<p>{t('操作已开始,请等待完成;此阶段无法取消。')}</p>:<button onClick={()=>{if(job.id==='upload')controller.current?.abort();else void api('job.cancel',{job:job.id}).catch(e=>setError(String(e)));}}>{t('取消')}</button>}</>,t('APK 操作进度'))}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
html:has(.apk-permission-view),body:has(.apk-permission-view),#root:has(.apk-permission-view){margin:0;width:100%;height:100%;background:transparent}
|
||||
.apk-permission-view{position:relative;box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;overflow:hidden;border:1px solid #35383d;border-radius:18px;background:#111316;color:#f3f4f6;font-family:system-ui,sans-serif;box-shadow:0 18px 60px #0008}
|
||||
.apk-permission-view header{display:flex;align-items:center;gap:14px;padding:19px 26px 13px}
|
||||
.apk-permission-mark{width:9px;height:9px;border-radius:50%;background:#87c8ff;box-shadow:0 0 16px #87c8ff88}
|
||||
.apk-permission-view header div{display:flex;align-items:baseline;gap:13px}
|
||||
.apk-permission-view header strong{font-size:17px;font-weight:650;letter-spacing:.01em}
|
||||
.apk-permission-view header small{font-size:14px;color:#aeb5be}
|
||||
.apk-permission-display{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;padding:0 22px;background:#08090b}
|
||||
.apk-permission-display img{display:block;max-width:100%;max-height:100%;width:auto;height:auto;user-select:none}
|
||||
.apk-permission-display p{color:#aeb5be}
|
||||
.apk-permission-view footer{height:42px;box-sizing:border-box;padding:10px 26px;color:#aeb5be;font-size:13px}
|
||||
.apk-permission-view footer [role=alert]{color:#ffaaa5}
|
||||
.apk-permission-pointer{position:absolute;z-index:5;width:12px;height:12px;border:2px solid #fff;border-radius:50%;background:#81c8ff;box-shadow:0 0 0 5px #08131e99,0 0 16px #81c8ff;transform:translate(-50%,-50%);pointer-events:none}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {api} from './api';
|
||||
import {t} from './i18n';
|
||||
import './apk-permission.css';
|
||||
|
||||
type Pending={id:string;appName:string;package:string};
|
||||
type Frame={id:string;appName:string;package:string;image:string;left:number;top:number;width:number;height:number};
|
||||
|
||||
export function ApkPermission(){
|
||||
const [frame,setFrame]=useState<Frame|null>(null);
|
||||
const [error,setError]=useState('');
|
||||
const [busy,setBusy]=useState(false);
|
||||
const [pointer,setPointer]=useState<{x:number;y:number}|null>(null);
|
||||
const active=useRef(true);
|
||||
const loading=useRef(false);
|
||||
const choosing=useRef(false);
|
||||
async function refresh(){
|
||||
if(loading.current||choosing.current)return;
|
||||
loading.current=true;
|
||||
try{
|
||||
const pending=await api<Pending|null>('apk.permission.pending');
|
||||
if(!pending){if(active.current)setFrame(null);return;}
|
||||
const next=await api<Frame>('apk.permission.frame',{id:pending.id});
|
||||
if(active.current){setFrame(next);setError('');}
|
||||
}catch(e){if(active.current)setError(String(e));}
|
||||
finally{loading.current=false;}
|
||||
}
|
||||
useEffect(()=>{
|
||||
active.current=true;
|
||||
void refresh();
|
||||
const timer=window.setInterval(()=>void refresh(),2000);
|
||||
return()=>{active.current=false;window.clearInterval(timer);};
|
||||
},[]);
|
||||
async function choose(event:React.MouseEvent<HTMLImageElement>){
|
||||
if(event.button!==0||!frame||choosing.current)return;
|
||||
event.preventDefault();
|
||||
const box=event.currentTarget.getBoundingClientRect();
|
||||
const x=Math.floor(frame.left+(event.clientX-box.left)/box.width*frame.width);
|
||||
const y=Math.floor(frame.top+(event.clientY-box.top)/box.height*frame.height);
|
||||
choosing.current=true;setBusy(true);setError('');
|
||||
try{await api('apk.permission.click',{id:frame.id,x,y});setFrame(null);}
|
||||
catch(e){setError(String(e));}
|
||||
finally{choosing.current=false;setBusy(false);window.setTimeout(()=>void refresh(),350);}
|
||||
}
|
||||
return <main className="apk-permission-view" onMouseMove={event=>{const box=event.currentTarget.getBoundingClientRect();setPointer({x:event.clientX-box.left,y:event.clientY-box.top});}} onMouseLeave={()=>setPointer(null)}>
|
||||
<header><span className="apk-permission-mark" aria-hidden="true"/><div><strong>{t('安卓权限请求')}</strong><small>{frame?.appName??t('正在读取权限请求')}</small></div></header>
|
||||
<section className="apk-permission-display" aria-label={t('安卓权限选择')}>
|
||||
{frame?<img src={frame.image} alt={t('安卓原生权限对话框')} onMouseDown={choose} draggable={false} style={{cursor:busy?'wait':'pointer'}}/>:<p>{t('正在等待安卓权限画面…')}</p>}
|
||||
</section>
|
||||
<footer>{error?<span role="alert">{error}</span>:<span>{t('使用手柄激光选择原生权限选项')}</span>}</footer>
|
||||
{pointer&&<span className="apk-permission-pointer" style={{left:pointer.x,top:pointer.y}} aria-hidden="true"/>}
|
||||
</main>;
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import {api} from './api';
|
||||
import {t} from './i18n';
|
||||
import {Switch} from './switch';
|
||||
import type {CastSettings} from './casting';
|
||||
import {RecordingButton,type RecordingStatus} from './cast-recording';
|
||||
|
||||
type Runtime={running:boolean;error?:string;receiverError?:string;watchAddresses?:string[]};
|
||||
type Runtime={running:boolean;recording?:RecordingStatus;preview?:boolean;error?:string;receiverError?:string;watchAddresses?:string[]};
|
||||
export function CastQuick({config,refresh}:{config:CastSettings;refresh:()=>Promise<void>}){
|
||||
const [runtime,setRuntime]=useState<Runtime|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState('');
|
||||
useEffect(()=>{let alive=true;const poll=()=>void api<Runtime>('cast.status').then(v=>{if(alive)setRuntime(v);}).catch(e=>{if(alive)setError(e.message);});poll();const timer=setInterval(poll,1000);return()=>{alive=false;clearInterval(timer);};},[]);
|
||||
@@ -13,7 +14,8 @@ export function CastQuick({config,refresh}:{config:CastSettings;refresh:()=>Prom
|
||||
const running=!!runtime?.running;
|
||||
return <section className="cast-quick">
|
||||
<header><IconCast size={25} stroke={1.8}/><h1>{t('串流')}</h1><button aria-label={t('投屏设置')} onClick={()=>void run(()=>api('host.manager.open',{page:'casting'}))}><IconArrowUpRight size={22}/></button></header>
|
||||
<div className="cast-quick-stream"><IconDeviceVisionPro size={40} stroke={1.5}/><h2>{running?t('正在串流'):t('头显画面')}</h2><p>{runtime?`${config.width} × ${config.height} / ${config.fps} FPS`:t('正在连接')}</p><button className={running?'':'primary'} disabled={busy||!runtime} onClick={()=>void run(()=>api(running?'cast.stop':'cast.start'))}>{running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{running?t('停止串流'):t('开始串流')}</span></button></div>
|
||||
<div className="cast-quick-stream"><IconDeviceVisionPro size={40} stroke={1.5}/><h2>{running?t('正在串流'):t('头显画面')}</h2><p>{runtime?`${config.width} × ${config.height} / ${config.fps} FPS`:t('正在连接')}</p><div className="cast-stream-actions"><button className={running?'':'primary'} disabled={busy||!runtime} onClick={()=>void run(()=>api(running?'cast.stop':'cast.start'))}>{running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void run(()=>api(runtime?.recording?.active?'cast.record.stop':'cast.record.start'))}/></div><p className="cast-record-path">{t('录制保存到 /home/steamos/Videos/framely')}</p></div>
|
||||
{runtime?.recording?.error&&<p className="error" role="alert">{runtime.recording.error}</p>}
|
||||
{running&&runtime?.watchAddresses?.[0]&&<div className="cast-quick-address"><span>{t('网页观看')}</span><code>{runtime.watchAddresses[0]}</code></div>}
|
||||
<div className="cast-quick-receive"><h2>{t('接收投屏')}</h2>{(['airplay','dlna'] as const).map(protocol=><label key={protocol}><span>{protocol==='airplay'?'AirPlay':'DLNA'}</span><Switch label={protocol==='airplay'?'AirPlay':'DLNA'} checked={config[protocol]} disabled={busy} onChange={value=>void run(()=>api('cast.settings.save',{...config,[protocol]:value}))}/></label>)}</div>
|
||||
{(error||runtime?.error||runtime?.receiverError)&&<p className="error" role="alert">{error||runtime?.error||runtime?.receiverError}</p>}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import {IconCircleFilled,IconPlayerStop} from '@tabler/icons-react';
|
||||
import {t} from './i18n';
|
||||
export type RecordingStatus={active:boolean;elapsedSeconds?:number;path?:string|null;error?:string|null};
|
||||
export function RecordingButton({recording,disabled,onClick}:{recording?:RecordingStatus;disabled:boolean;onClick:()=>void}){
|
||||
const seconds=recording?.elapsedSeconds??0;
|
||||
const elapsed=`${Math.floor(seconds/60).toString().padStart(2,'0')}:${(seconds%60).toString().padStart(2,'0')}`;
|
||||
return <button className={`cast-record-button${recording?.active?' recording':''}`} disabled={disabled} onClick={onClick} title={recording?.path??'/home/steamos/Videos/framely'}>{recording?.active?<IconPlayerStop size={21}/>:<IconCircleFilled size={18}/>}<span>{t(recording?.active?'停止录制':'开始录制')}{recording?.active&&` · ${elapsed}`}</span></button>;
|
||||
}
|
||||
+46
-5
@@ -3,21 +3,60 @@ import {IconArrowLeft,IconPlayerPause,IconPlayerPlay,IconVolume,IconVolumeOff,Ic
|
||||
import {CastRange} from './cast-range';
|
||||
import {t} from './i18n';
|
||||
|
||||
export function CastVideo({active,onError,controls=true,aspectRatio,videoRef,onState,showStatus=true}:{active:boolean;onError?:(error:string)=>void;controls?:boolean;aspectRatio?:number;videoRef?:React.RefObject<HTMLVideoElement|null>;onState?:(state:'connecting'|'playing'|'failed')=>void;showStatus?:boolean}){
|
||||
export type CastStreamInfo={width:number|null;height:number|null;codec:string|null;fps:number|null};
|
||||
export function CastVideo({active,onError,controls=true,aspectRatio,videoRef,onState,onStreamInfo,showStatus=true}:{active:boolean;onError?:(error:string)=>void;controls?:boolean;aspectRatio?:number;videoRef?:React.RefObject<HTMLVideoElement|null>;onState?:(state:'connecting'|'playing'|'failed')=>void;onStreamInfo?:(info:CastStreamInfo|null)=>void;showStatus?:boolean}){
|
||||
const localVideo=useRef<HTMLVideoElement>(null),video=videoRef??localVideo,failure=useRef(onError);failure.current=onError;
|
||||
const streamInfo=useRef(onStreamInfo);streamInfo.current=onStreamInfo;
|
||||
const [state,setState]=useState<'connecting'|'playing'|'failed'>('connecting');
|
||||
useEffect(()=>{onState?.(state);},[state,onState]);
|
||||
const [ratio,setRatio]=useState(16/9),[retry,setRetry]=useState(0);
|
||||
useEffect(()=>{
|
||||
if(!active)return;
|
||||
const peer=new RTCPeerConnection(),abort=new AbortController();let resource:string|undefined,closed=false;
|
||||
let previous:{id:string;frames:number;timestamp:number}|undefined,statsBusy=false;
|
||||
streamInfo.current?.(null);
|
||||
async function reportStreamInfo(){
|
||||
if(closed||statsBusy||!streamInfo.current)return;
|
||||
if(peer.connectionState!=='connected'){previous=undefined;streamInfo.current?.(null);return;}
|
||||
statsBusy=true;
|
||||
try{
|
||||
const stats=await peer.getStats();if(closed)return;
|
||||
let inbound:RTCInboundRtpStreamStats|undefined;
|
||||
stats.forEach(report=>{if(report.type==='inbound-rtp'&&(report.kind==='video'||report.mediaType==='video'))inbound=report;});
|
||||
if(!inbound){previous=undefined;streamInfo.current?.(null);return;}
|
||||
const {id,framesDecoded,timestamp,frameWidth,frameHeight,codecId}=inbound;
|
||||
let fps:number|null=null;
|
||||
if(typeof framesDecoded==='number'){
|
||||
if(previous?.id===id&&framesDecoded>=previous.frames&×tamp>previous.timestamp)fps=(framesDecoded-previous.frames)*1000/(timestamp-previous.timestamp);
|
||||
previous={id,frames:framesDecoded,timestamp};
|
||||
}
|
||||
let mime:string|undefined;
|
||||
stats.forEach(report=>{if(report.id===codecId&&report.type==='codec')mime=report.mimeType;});
|
||||
const name=typeof mime==='string'?mime.replace(/^video\//i,''):null;
|
||||
const codec=name?/^(H265|HEVC)$/i.test(name)?'H.265':/^H264$/i.test(name)?'H.264':name.toUpperCase():null;
|
||||
streamInfo.current?.({width:typeof frameWidth==='number'&&frameWidth>0?frameWidth:video.current?.videoWidth||null,height:typeof frameHeight==='number'&&frameHeight>0?frameHeight:video.current?.videoHeight||null,codec,fps});
|
||||
}catch{if(!closed){previous=undefined;streamInfo.current?.(null);}}
|
||||
finally{statsBusy=false;}
|
||||
}
|
||||
const statsTimer=setInterval(()=>void reportStreamInfo(),1000);
|
||||
const guest=!!document.querySelector('meta[name="framely-cast-guest"]');
|
||||
let accessBusy=false;
|
||||
const accessTimer=guest?setInterval(()=>{
|
||||
if(closed||accessBusy)return;accessBusy=true;
|
||||
void fetch('/cast/access',{cache:'no-store',signal:abort.signal}).then(response=>{
|
||||
if(!closed&&response.headers.get('X-Framely-Watch-Expired')==='1'){
|
||||
closed=true;abort.abort();peer.close();if(video.current)video.current.srcObject=null;
|
||||
setState('failed');failure.current?.(t('观看链接已失效'));
|
||||
}
|
||||
}).catch(()=>{}).finally(()=>{accessBusy=false;});
|
||||
},2000):undefined;
|
||||
setState('connecting');
|
||||
for(const kind of ['video','audio']){
|
||||
const receiver=peer.addTransceiver(kind,{direction:'recvonly'}).receiver;
|
||||
if('jitterBufferTarget' in receiver){try{(receiver as RTCRtpReceiver&{jitterBufferTarget:number|null}).jitterBufferTarget=0;}catch{}}
|
||||
}
|
||||
peer.ontrack=event=>{if(video.current){const stream=video.current.srcObject as MediaStream|null??new MediaStream();stream.addTrack(event.track);video.current.srcObject=stream;void video.current.play().catch(()=>{});}};
|
||||
peer.onconnectionstatechange=()=>{if(peer.connectionState==='failed'){setState('failed');failure.current?.(t('观看连接失败,请重新连接。'));}};
|
||||
peer.onconnectionstatechange=()=>{if(peer.connectionState==='failed'){previous=undefined;streamInfo.current?.(null);setState('failed');failure.current?.(t('观看连接失败,请重新连接。'));}};
|
||||
void(async()=>{
|
||||
try{
|
||||
await peer.setLocalDescription(await peer.createOffer());
|
||||
@@ -28,13 +67,14 @@ export function CastVideo({active,onError,controls=true,aspectRatio,videoRef,onS
|
||||
peer.addEventListener('icegatheringstatechange',check);abort.signal.addEventListener('abort',cancel,{once:true});check();
|
||||
});
|
||||
const response=await fetch('/cast/whep',{method:'POST',headers:{'Content-Type':'application/sdp'},body:peer.localDescription?.sdp,signal:abort.signal});
|
||||
if(response.headers.get('X-Framely-Watch-Expired')==='1')throw Error(t('观看链接已失效'));
|
||||
if(!response.ok)throw Error(await response.text()||t('串流尚未就绪,请稍后重试。'));
|
||||
resource=response.headers.get('Location')??undefined;
|
||||
if(closed){if(resource)void fetch(resource,{method:'DELETE'});return;}
|
||||
await peer.setRemoteDescription({type:'answer',sdp:await response.text()});
|
||||
}catch(error){if(!abort.signal.aborted){setState('failed');failure.current?.(String(error));}}
|
||||
})();
|
||||
return()=>{closed=true;abort.abort();peer.close();if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});if(video.current)video.current.srcObject=null;};
|
||||
return()=>{closed=true;clearInterval(statsTimer);clearInterval(accessTimer);streamInfo.current?.(null);abort.abort();peer.close();if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});if(video.current)video.current.srcObject=null;};
|
||||
},[active,retry]);
|
||||
return <div className="cast-video-stage" style={{aspectRatio:aspectRatio??ratio}}><video ref={video} autoPlay playsInline muted controls={controls} onLoadedMetadata={()=>{if(video.current?.videoHeight)setRatio(video.current.videoWidth/video.current.videoHeight);}} onPlaying={()=>setState('playing')}/>{showStatus&&active&&state==='connecting'&&<span role="status">{t('正在连接画面…')}</span>}{showStatus&&active&&state==='failed'&&<div className="cast-video-failure"><p>{t('观看连接失败,请重新连接。')}</p><button onClick={()=>setRetry(v=>v+1)}>{t('重新连接')}</button></div>}{!active&&<span>{t('开始串流后显示头显画面。')}</span>}</div>;
|
||||
}
|
||||
@@ -46,13 +86,14 @@ export function CastViewer(){
|
||||
useEffect(()=>{const changed=()=>setFullscreen(!!document.fullscreenElement);document.addEventListener('fullscreenchange',changed);return()=>document.removeEventListener('fullscreenchange',changed);},[]);
|
||||
useEffect(()=>{const el=video.current;if(!el)return;const play=()=>setPaused(false),pause=()=>setPaused(true);el.addEventListener('play',play);el.addEventListener('pause',pause);return()=>{el.removeEventListener('play',play);el.removeEventListener('pause',pause);};},[key]);
|
||||
useEffect(()=>{if(video.current){video.current.muted=muted;video.current.volume=volume;}},[muted,volume,key,state]);
|
||||
function reconnect(){setError('');setPaused(false);setKey(v=>v+1);reveal();}
|
||||
const expired=error.includes(t('观看链接已失效'));
|
||||
function reconnect(){if(expired)return;setError('');setPaused(false);setKey(v=>v+1);reveal();}
|
||||
function togglePlay(){const el=video.current;if(!el)return;if(el.paused)void el.play().catch(()=>setError(t('无法播放,请重新连接。')));else el.pause();reveal();}
|
||||
async function toggleFullscreen(){try{if(document.fullscreenElement)await document.exitFullscreen();else if(root.current?.requestFullscreen)await root.current.requestFullscreen();else (video.current as HTMLVideoElement&{webkitEnterFullscreen?:()=>void})?.webkitEnterFullscreen?.();}catch{setError(t('此浏览器暂不支持全屏。'));}reveal();}
|
||||
return <main ref={root} tabIndex={-1} className={`cast-watch ${visible||state!=='playing'||paused?'controls-visible':''}`} onPointerMove={e=>{if(e.pointerType==='mouse')reveal();}} onClick={e=>{if(!(e.target as HTMLElement).closest('button,a,input')){reveal();}}} onFocusCapture={reveal} onBlurCapture={reveal} onKeyDown={e=>{if((e.target as HTMLElement).closest('button,a,input'))return;if(e.code==='Space'){e.preventDefault();togglePlay();}else if(e.key==='Escape')reveal();}}>
|
||||
<CastVideo key={key} active controls={false} videoRef={video} onState={setState} onError={setError} showStatus={false}/>
|
||||
<header className="cast-watch-top cast-watch-overlay"><a href="/manager#casting" className="cast-watch-icon" aria-label={t('返回投屏设置')} title={t('返回投屏设置')}><IconArrowLeft size={22}/></a><strong className="cast-watch-title">{t('头显画面')}</strong><span className="cast-watch-connection" role="status">{state==='playing'?t('实时画面'):state==='connecting'?t('正在连接'):t('连接已断开')}</span></header>
|
||||
{state!=='playing'&&<div className="cast-watch-message" role={state==='failed'?'alert':'status'}>{state==='connecting'?<><span className="cast-spinner"/><h1>{t('正在连接头显')}</h1><p>{t('画面即将开始,请稍候。')}</p></>:<><h1>{t('暂时无法观看')}</h1><p>{error||t('确认头显已开始串流,然后重新连接。')}</p><button className="primary" onClick={reconnect}><IconRefresh size={20}/>{t('重新连接')}</button></>}</div>}
|
||||
{state!=='playing'&&<div className="cast-watch-message" role={state==='failed'?'alert':'status'}>{state==='connecting'?<><span className="cast-spinner"/><h1>{t('正在连接头显')}</h1><p>{t('画面即将开始,请稍候。')}</p></>:<><h1>{t(expired?'观看链接已失效':'暂时无法观看')}</h1><p>{error||t('确认头显已开始串流,然后重新连接。')}</p>{!expired&&<button className="primary" onClick={reconnect}><IconRefresh size={20}/>{t('重新连接')}</button>}</>}</div>}
|
||||
{error&&state==='playing'&&<p className="cast-watch-toast" role="alert">{error}</p>}
|
||||
<footer className="cast-watch-bottom cast-watch-overlay"><div className="cast-watch-controls"><button className="cast-watch-icon" aria-label={t(paused?'播放':'暂停')} title={t(paused?'播放':'暂停')} disabled={state!=='playing'} onClick={togglePlay}>{paused?<IconPlayerPlay size={24}/>:<IconPlayerPause size={24}/>}</button><div className="cast-watch-sound"><button className="cast-watch-icon" aria-label={t(muted?'开启声音':'静音')} title={t(muted?'开启声音':'静音')} aria-pressed={!muted} onClick={()=>{setMuted(v=>!v);reveal();}}>{muted||volume===0?<IconVolumeOff size={23}/>:<IconVolume size={23}/>}</button><CastRange aria-label={t('音量')} min={0} max={1} step={.05} value={muted?0:volume} onChange={e=>{setVolume(+e.target.value);setMuted(false);reveal();}}/></div><span className="cast-watch-live">{t('实时')}</span><div className="cast-watch-tools"><button className="cast-watch-icon" aria-label={t('重新连接')} title={t('重新连接')} onClick={reconnect}><IconRefresh size={22}/></button><button className="cast-watch-icon" aria-label={t(fullscreen?'退出全屏':'全屏')} title={t(fullscreen?'退出全屏':'全屏')} onClick={()=>void toggleFullscreen()}>{fullscreen?<IconMinimize size={22}/>:<IconMaximize size={22}/>}</button></div></div></footer>
|
||||
</main>;
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
.cast-choice-group small{white-space:normal;line-height:1.45;max-width:100%;text-align:center}.cast-framing-section{scroll-margin-top:16px}.cast-framing-stage{max-width:min(640px,calc(100dvh - 330px))}@media(max-width:767px){.cast-choice-group button{min-width:0}.cast-framing-stage{max-width:100%}.cast-watch-icon{min-height:44px}}
|
||||
.cast-framing-stage{max-width:min(640px,calc(100dvh - 400px))}.cast-editor-actions{position:sticky;bottom:-24px;background:transparent;padding:14px 0;z-index:6}@media(max-width:767px){.cast-choice-group button>span{white-space:normal}.cast-framing-stage{max-width:100%}.cast-editor-actions{bottom:-16px}}
|
||||
.cast-protocol-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:6px}.cast-protocol-heading h2{margin:0}.cast-protocol-heading .ui-switch{flex-shrink:0}.cast-range{--range-fill:0%;appearance:none;-webkit-appearance:none;display:block;min-height:32px!important;height:32px!important;padding:0!important;margin:0;background:transparent!important;cursor:pointer;border:0!important;outline-offset:3px;touch-action:pan-y}.cast-range::-webkit-slider-runnable-track{height:6px;border-radius:999px;background:linear-gradient(to right,var(--blue) var(--range-fill),#484848 var(--range-fill))}.cast-range::-webkit-slider-thumb{appearance:none;-webkit-appearance:none;width:28px;height:16px;margin-top:-5px;border:0;border-radius:999px;background:var(--blue);box-shadow:0 1px 3px #0005}.cast-range::-moz-range-track{height:6px;border-radius:999px;background:#484848}.cast-range::-moz-range-progress{height:6px;border-radius:999px;background:var(--blue)}.cast-range::-moz-range-thumb{width:28px;height:16px;border:0;border-radius:999px;background:var(--blue)}.cast-range:disabled{opacity:.4;cursor:default}
|
||||
.cast-page-heading{flex-wrap:wrap}.cast-status{font-variant-numeric:tabular-nums;white-space:normal;text-align:right;margin-left:auto}
|
||||
.cast-stream-bar>.cast-stream-actions,.cast-stream-actions{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;gap:10px}.cast-quick-stream .cast-stream-actions{justify-content:center}.cast-record-button>svg{color:#ee565e}.cast-record-button.recording{border-color:#ee565e;color:#ffb4b8}.cast-record-path{font-size:12px;color:var(--muted);overflow-wrap:anywhere;margin:0 0 18px}.cast-record-button span{font-variant-numeric:tabular-nums}@media(max-width:767px){.cast-stream-bar{flex-wrap:wrap}.cast-stream-bar>.cast-stream-actions{width:100%}.cast-stream-actions>button{flex:1;white-space:nowrap}}
|
||||
.cast-stream-bar>.cast-stream-actions span{color:inherit;font-size:inherit;display:initial}.cast-quick-stream .cast-record-path{font-size:12px;margin:16px 0 0}
|
||||
/* Viewer: one restrained floating toolbar; the video always remains complete. */
|
||||
.cast-watch video{object-fit:contain!important}.cast-watch-top{padding-bottom:40px;gap:12px;background:linear-gradient(rgb(0 0 0/.55),transparent)}.cast-watch-title{font-size:14px;font-weight:500;color:#e9e9e9}.cast-watch-connection{font-size:12px;color:#c6c6c6}.cast-watch-bottom{padding-top:32px;background:transparent;display:flex;justify-content:center}.cast-watch-controls{width:auto;max-width:100%;padding:6px 12px;gap:8px;background:rgb(18 18 18/.88);border:1px solid rgb(255 255 255/.12);border-radius:999px;box-shadow:0 8px 28px #0005;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px)}.cast-watch .cast-watch-icon{width:40px;height:40px;min-height:40px;border:0;border-radius:50%;padding:8px;background:transparent;color:#ededed}.cast-watch .cast-watch-icon:hover{background:#ffffff12}.cast-watch-top .cast-watch-icon{background:transparent}.cast-watch-top .cast-watch-icon:hover{background:#ffffff12}.cast-watch-sound{gap:6px}.cast-watch-sound input{width:80px}.cast-watch-live{margin:0 12px 0 8px;font-size:11px;color:#bcbcbc;letter-spacing:.02em}.cast-watch-tools{gap:4px;padding-left:8px;border-left:1px solid #ffffff1f}.cast-watch .cast-range{--blue:#e7e7e7}.cast-watch .cast-range::-webkit-slider-thumb{width:20px;height:12px;margin-top:-3px}.cast-watch .cast-range::-moz-range-thumb{width:20px;height:12px}.cast-watch-message h1{font-size:22px;font-weight:500}.cast-watch-message p{font-size:13px}.cast-watch-message button{border-radius:999px;min-height:40px;font-size:13px;padding:10px 18px}@media(max-width:767px){.cast-watch-bottom{padding-bottom:calc(16px + env(safe-area-inset-bottom))}.cast-watch-controls{gap:4px;padding:5px 9px}.cast-watch-top{padding-top:calc(12px + env(safe-area-inset-top));padding-bottom:32px}.cast-watch-live{display:inline;margin:0 10px 0 4px}.cast-watch-title{font-size:13px}.cast-watch-tools{padding-left:6px;gap:2px}}@media(prefers-reduced-transparency:reduce){.cast-watch-controls{background:#181818;backdrop-filter:none;-webkit-backdrop-filter:none}}
|
||||
+12
-7
@@ -5,21 +5,23 @@ import {api} from './api';
|
||||
import {Switch,SwitchRow} from './switch';
|
||||
import {CastRange} from './cast-range';
|
||||
import {t} from './i18n';
|
||||
import {CastVideo} from './cast-viewer';
|
||||
import {CastVideo,type CastStreamInfo} from './cast-viewer';
|
||||
import {FramingEditor} from './cast-framing-editor';
|
||||
import {framePresets,frameRatios,type ReferenceView} from './cast-framing';
|
||||
import './casting.css';
|
||||
import {RecordingButton,type RecordingStatus} from './cast-recording';
|
||||
|
||||
export type CastSettings={source:'screen'|'steamVR';codec:'h264'|'h265';output:'eye'|'raw';eye:'steamVR'|'left'|'right';width:number;height:number;fps:number;bitrateMbps:number;horizontalFov:number|null;centerX:number;centerY:number;systemAudio:boolean;microphone:boolean;airplay:boolean;dlna:boolean;receiverName:string};
|
||||
type CastSession={id:string;protocol:string;accepted:boolean;width?:number;height?:number};
|
||||
type Device={id:string;name:string};
|
||||
type Runtime={running:boolean;error:string|null;dominantEye:number|null;watchPath:string|null;elapsedSeconds:number|null;preview?:boolean;view?:ReferenceView|null;receiverError?:string|null;sessions?:CastSession[];watchAddresses?:string[]};
|
||||
type Runtime={running:boolean;recording?:RecordingStatus;error:string|null;dominantEye:number|null;watchPath:string|null;watchAccessEnabled?:boolean;elapsedSeconds:number|null;preview?:boolean;view?:ReferenceView|null;receiverError?:string|null;sessions?:CastSession[];watchAddresses?:string[]};
|
||||
export type CastPage='casting'|'casting-picture'|'casting-receive'|'casting-devices';
|
||||
const titles:Record<CastPage,[string,string]>={casting:['网页观看','把头显画面分享给同一网络中的手机或电脑。'],'casting-picture':['画面与声音','选择画面来源,直接调整取景和输出画质。'],'casting-receive':['接收投屏','接收其他设备的音频和视频,在头显中打开。'],'casting-devices':['投到设备','选择同一网络中的设备,播放当前头显画面。']};
|
||||
function Choices<T extends string|number>({label,value,options,onChange,disabled=false}:{label:string;value:T;options:{value:T;label:string;description?:string}[];onChange:(value:T)=>void;disabled?:boolean}){
|
||||
return <div className="cast-choice-group" role="radiogroup" aria-label={label}>{options.map((option,i)=><button type="button" role="radio" aria-checked={value===option.value} className={value===option.value?'selected':''} disabled={disabled} key={option.value} onClick={()=>onChange(option.value)} onKeyDown={e=>{if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();const next=(i+(['ArrowLeft','ArrowUp'].includes(e.key)?-1:1)+options.length)%options.length;onChange(options[next].value);(e.currentTarget.parentElement?.children[next] as HTMLElement)?.focus();}}}><span>{option.label}</span>{option.description&&<small>{option.description}</small>}</button>)}</div>;
|
||||
}
|
||||
export function Casting({config,refresh,network,page='casting'}:{config:CastSettings;refresh:()=>Promise<void>;network:NetworkPanel;page?:CastPage}){
|
||||
const [streamInfo,setStreamInfo]=useState<CastStreamInfo|null>(null);
|
||||
const [draft,setDraft]=useState(config),[runtime,setRuntime]=useState<Runtime|null>(null),[pending,setPending]=useState(''),[error,setError]=useState(''),[saved,setSaved]=useState(false),[epoch,setEpoch]=useState(0),[devices,setDevices]=useState<Device[]>([]),[sending,setSending]=useState<string>(''),[copied,setCopied]=useState(false),[editing,setEditing]=useState(false),[searched,setSearched]=useState(false);
|
||||
const beforeFraming=useRef<Pick<CastSettings,'horizontalFov'|'centerX'|'centerY'>|null>(null),editorActive=useRef(false),deviceScan=useRef(0),alive=useRef(true);
|
||||
const captureSettings=({airplay,dlna,receiverName,...capture}:CastSettings)=>capture;
|
||||
@@ -37,17 +39,20 @@ export function Casting({config,refresh,network,page='casting'}:{config:CastSett
|
||||
useEffect(()=>{if(page!=='casting-picture'&&editorActive.current)void action('preview',cancelFraming);},[page]);
|
||||
async function scan(){const token=++deviceScan.current;setSearched(true);for(let i=0;i<3;i++){const list=await api<Device[]>('cast.devices');if(token!==deviceScan.current||!alive.current)return;setDevices(list);if(i<2)await new Promise(resolve=>setTimeout(resolve,1000));}}
|
||||
useEffect(()=>{if(page==='casting-devices')void action('scan',scan);},[page]);
|
||||
async function copy(){const address=['localhost','127.0.0.1','[::1]'].includes(location.hostname)?runtime?.watchAddresses?.[0]??new URL('/cast/watch',location.origin).href:new URL('/cast/watch',location.origin).href;if(navigator.clipboard)await navigator.clipboard.writeText(address);else{const input=document.createElement('textarea');input.value=address;document.body.append(input);input.select();document.execCommand('copy');input.remove();}setCopied(true);setTimeout(()=>setCopied(false),2500);}
|
||||
const watchAddress=['localhost','127.0.0.1','[::1]'].includes(location.hostname)?runtime?.watchAddresses?.[0]??new URL(runtime?.watchPath??'/cast/watch',location.origin).href:new URL(runtime?.watchPath??'/cast/watch',location.origin).href;
|
||||
async function copy(){if(navigator.clipboard)await navigator.clipboard.writeText(watchAddress);else{const input=document.createElement('textarea');input.value=watchAddress;document.body.append(input);input.select();document.execCommand('copy');input.remove();}setCopied(true);setTimeout(()=>setCopied(false),2500);}
|
||||
const errorMessage=error||(page==='casting-receive'||page==='casting-devices'?runtime?.receiverError:runtime?.error);
|
||||
const presets=framePresets(aspect);
|
||||
function chooseAspect(value:number){const edge=Math.max(draft.width,draft.height);const size=value>=1?{width:edge,height:Math.round(edge/value/2)*2}:{width:Math.round(edge*value/2)*2,height:edge};change(size);}
|
||||
return <div className="casting cast-workspace">
|
||||
<header className="cast-page-heading"><div><h1>{t(titles[page][0])}</h1><p>{t(titles[page][1])}</p></div>{page==='casting'&&<span className={`cast-status ${runtime?.running?'live':''}`} role="status">{pending==='start'?t('正在启动…'):runtime?.running?t('正在串流'):t('未开始串流')}</span>}</header>
|
||||
<header className="cast-page-heading"><div><h1>{t(titles[page][0])}</h1><p>{t(titles[page][1])}</p></div>{page==='casting'&&<span className={`cast-status ${runtime?.running?'live':''}`} title={t('当前浏览器实际接收并解码的串流信息')} role="status">{pending==='start'?t('正在启动…'):runtime?.running?streamInfo?<>{streamInfo.width&&streamInfo.height?`${streamInfo.width} × ${streamInfo.height}`:'—'} · {streamInfo.codec??'—'} · {streamInfo.fps===null?'—':streamInfo.fps.toFixed(1)} fps</>:t('正在读取串流信息…'):t('未开始串流')}</span>}</header>
|
||||
{errorMessage&&<div className="cast-error" role="alert"><strong>{t('操作未完成')}</strong><p>{errorMessage}</p></div>}
|
||||
{page==='casting'&&<>
|
||||
<div className="cast-monitor"><CastVideo key={epoch} active={!!runtime?.running&&!runtime.preview} onError={setError}/>{!runtime?.running&&<div className="cast-monitor-empty"><IconDeviceVisionPro size={44} stroke={1.5}/><h2>{t('分享你看到的画面')}</h2><p>{t('启动后,手机和电脑可以通过网页实时观看。')}</p></div>}</div>
|
||||
<div className="cast-stream-bar"><div><strong>{t(config.source==='screen'?'屏幕采集':'SteamVR')}</strong><span>{config.width} × {config.height}<i/> {config.fps} fps</span></div><button className={runtime?.running?'':'primary'} disabled={busy||!runtime} onClick={()=>void action('start',async()=>{if(runtime?.running)await api('cast.stop');else{if(dirty)await apply();await api('cast.start');}setEpoch(v=>v+1);})}>{runtime?.running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{pending==='start'?t('正在启动…'):runtime?.running?t('停止串流'):t('开始串流')}</span></button></div>
|
||||
{runtime?.running&&<section className="cast-share"><div><IconWifi size={24}/><div><h2>{t('在其他设备上观看')}</h2><p>{t('连接同一网络,打开观看地址。')}</p></div></div><div className="cast-share-link"><code>{(!['localhost','127.0.0.1','[::1]'].includes(location.hostname)?new URL('/cast/watch',location.origin).href:runtime.watchAddresses?.[0])??new URL('/cast/watch',location.origin).href}</code><button onClick={()=>void action('copy',copy)} aria-label={t('复制观看地址')}>{copied?<IconCheck size={20}/>:<IconCopy size={20}/>}<span>{copied?t('已复制'):t('复制')}</span></button><a className="cast-icon-link" href="/cast/watch" aria-label={t('打开观看网页')}><IconExternalLink size={21}/></a></div></section>}
|
||||
<div className="cast-monitor"><CastVideo key={epoch} active={!!runtime?.running&&!runtime.preview} onError={setError} onStreamInfo={setStreamInfo}/>{!runtime?.running&&<div className="cast-monitor-empty"><IconDeviceVisionPro size={44} stroke={1.5}/><h2>{t('分享你看到的画面')}</h2><p>{t('启动后,手机和电脑可以通过网页实时观看。')}</p></div>}</div>
|
||||
<div className="cast-stream-bar"><div><strong>{t(config.source==='screen'?'屏幕采集':'SteamVR')}</strong><span>{config.width} × {config.height}<i/> {config.fps} fps</span></div><div className="cast-stream-actions"><button className={runtime?.running?'':'primary'} disabled={busy||!runtime} onClick={()=>void action('start',async()=>{if(runtime?.running)await api('cast.stop');else{if(dirty)await apply();await api('cast.start');}setEpoch(v=>v+1);})}>{runtime?.running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{pending==='start'?t('正在启动…'):runtime?.running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void action('record',async()=>{if(!runtime?.recording?.active&&!runtime?.running&&dirty)await apply();await api(runtime?.recording?.active?'cast.record.stop':'cast.record.start');setEpoch(v=>v+1);})}/></div></div>
|
||||
<p className="cast-record-path">{t('录制保存到 /home/steamos/Videos/framely')}{runtime?.recording?.path&&<> · <span>{runtime.recording.path.split('/').pop()}</span></>}</p>
|
||||
{runtime?.recording?.error&&<p className="error" role="alert">{runtime.recording.error}</p>}
|
||||
{runtime?.running&&<section className="cast-share"><div><IconWifi size={24}/><div><h2>{t('在其他设备上观看')}</h2><p>{t('连接同一网络,打开观看地址。')}</p></div></div><SwitchRow label={t('免验证')} checked={runtime.watchAccessEnabled??false} disabled={busy} onChange={enabled=>void action('watch-access',async()=>{await api('cast.watchAccess.save',{enabled});setCopied(false);})}/><p>{t('持有此链接的人无需密码即可观看。')}</p><div className="cast-share-link"><code>{watchAddress}</code><button disabled={busy} onClick={()=>void action('copy',copy)} aria-label={t('复制观看地址')}>{copied?<IconCheck size={20}/>:<IconCopy size={20}/>}<span>{copied?t('已复制'):t('复制')}</span></button><a className="cast-icon-link" href={watchAddress} aria-label={t('打开观看网页')}><IconExternalLink size={21}/></a></div></section>}
|
||||
|
||||
</>}
|
||||
{page==='casting-picture'&&<>
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
export type IconName='star'|'plugins'|'settings'|'arrow'|'close'|'more'|'download'|'search'|'shield'|'link'|'refresh'|'info'|'power'|'connection'|'edit'|'trash'|'store';
|
||||
export type IconName='star'|'plugins'|'settings'|'folder'|'arrow'|'close'|'more'|'download'|'search'|'shield'|'link'|'refresh'|'info'|'power'|'connection'|'edit'|'trash'|'store';
|
||||
const paths:Record<IconName,React.ReactNode>={
|
||||
folder:<path d="M3 6h7l2 2h9v12H3Z"/>,
|
||||
store:<><path d="M4 10v11h16V10M3 10l2-7h14l2 7M3 10a3 3 0 0 0 6 0 3 3 0 0 0 6 0 3 3 0 0 0 6 0M9 21v-6h6v6"/></>,
|
||||
power:<><path d="M12 3v9M7 5a8 8 0 1 0 10 0"/></>,
|
||||
connection:<><path d="M7 3v4m6-4v4M5 7h10v3a5 5 0 0 1-5 5v5M17 16l2 2 3-4"/></>,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {TerminalManager} from './terminal';
|
||||
import {FrameFilePicker} from './file-picker';
|
||||
import {ManagerSwitcher,managerTab,sectionFor,type ManagerSection} from './manager-switcher';
|
||||
import {ApkManager} from './apk-manager';
|
||||
import {ApkPermission} from './apk-permission';
|
||||
import {Switch,SwitchRow} from './switch';
|
||||
import {PluginFrame,pluginLanguagePreference,setPluginLanguagePreference} from './plugin-frame';
|
||||
import {Launcher,LauncherSettings,type LauncherConfig} from './launcher';
|
||||
@@ -21,6 +22,7 @@ import {configureLanguage,currentLanguage,LanguagePack} from './i18n';
|
||||
import {AgreementGate,AgreementState} from './agreement';
|
||||
import {ProxySettings,ProxyConfig} from './proxy';
|
||||
import {NetworkSettings,NetworkPanel} from './network';
|
||||
import {StorageSettings} from './storage-settings';
|
||||
import {Subscriptions,Subscription} from './subscriptions';
|
||||
import {RelationsInfo} from './relations';
|
||||
import {InstallReview} from './install-review';
|
||||
@@ -36,6 +38,7 @@ const managerMode=location.pathname==='/manager';
|
||||
const empty:Status={version:'0.4.0',database:{plugins:{},sources:[],safeMode:false},notifications:[],running:[]};
|
||||
|
||||
function App(){
|
||||
if(location.pathname==='/apk/permission')return <ApkPermission/>;
|
||||
if(location.pathname==='/cast/watch')return <CastViewer/>;
|
||||
if(location.pathname.startsWith('/cast/window/'))return <CastPlayer/>;
|
||||
const mobile=useMobileManager();useMobileViewport(managerMode&&mobile);
|
||||
@@ -113,6 +116,7 @@ function App(){
|
||||
{managerMode&&(filesVisited||tab==='files')&&<div className="file-workspace-frame" hidden={tab!=='files'}><FileManager active={tab==='files'}/></div>}
|
||||
{tab==='terminal'&&managerMode&&<TerminalManager/>}
|
||||
{managerSection==='casting'&&managerMode&&status.database.casting&&<Casting page={tab as CastPage} config={status.database.casting} refresh={refresh} network={status.database.networkPanel??{enabled:true,port:15915}}/>}
|
||||
{tab==='storage'&&managerMode&&<StorageSettings/>}
|
||||
{apkPanel&&<ApkManager view={tab==='apk-containers'?'containers':tab==='apk-cleanup'?'cleanup':tab==='apk-settings'?'settings':'apps'} onViewChange={view=>setTab(view==='apps'?'apk':'apk-'+view)}/>}
|
||||
{tab==='launcher-settings'&&managerMode&&<><div className="list-heading"><div><h1>{t('启动台')}</h1><p>{t('启动方式、菜单与分类。')}</p></div></div><LauncherSettings config={status.database.launcher} refresh={refresh}/></>}
|
||||
{tab==='notification-settings'&&managerMode&&<NotificationSettings config={status.database.notificationSettings??defaultNotificationConfig} plugins={status.database.plugins} refresh={refresh}/>}
|
||||
|
||||
@@ -6,9 +6,9 @@ export function sectionFor(tab:string):ManagerSection {
|
||||
if(['casting','casting-picture','casting-receive','casting-devices'].includes(tab))return 'casting';
|
||||
if(tab.startsWith('apk'))return 'apk';
|
||||
if(tab==='terminal'||tab==='files')return tab;
|
||||
return ['settings','launcher-settings','about','updates'].includes(tab)?'settings':'plugins';
|
||||
return ['settings','storage','launcher-settings','about','updates'].includes(tab)?'settings':'plugins';
|
||||
}
|
||||
export function managerTab(hash:string){const key=hash.slice(1);return ['casting','casting-picture','casting-receive','casting-devices','installed','catalog','sources','notification-settings','settings','launcher-settings','about','updates','terminal','files','apk','apk-containers','apk-cleanup','apk-settings'].includes(key)?key:'installed';}
|
||||
export function managerTab(hash:string){const key=hash.slice(1);return ['casting','casting-picture','casting-receive','casting-devices','installed','catalog','sources','notification-settings','settings','storage','launcher-settings','about','updates','terminal','files','apk','apk-containers','apk-cleanup','apk-settings'].includes(key)?key:'installed';}
|
||||
export function ManagerSwitcher({section,onChange}:{section:ManagerSection;onChange:(section:ManagerSection)=>void}) {
|
||||
return <div className="manager-switcher" role="group" aria-label={t('切换管理面板')}>
|
||||
{([['plugins','插件'],['apk','APK'],['terminal','终端'],['files','文件'],['casting','投屏'],['settings','设置']] as const).map(([key,label])=><button key={key} className={section===key?'active':''} aria-pressed={section===key} onClick={()=>onChange(key)}><span className="manager-section-icon">{React.createElement(({plugins:IconPuzzle,apk:IconBrandAndroid,terminal:IconTerminal2,files:IconFolder,casting:IconCast,settings:IconSettings})[key],{size:22})}</span><span>{t(label)}</span></button>)}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function MobileSheet({title,onClose,children}:{title:string;onClose:()=>v
|
||||
const key=(e:KeyboardEvent)=>{if(e.key==='Escape'){if((e.target as HTMLElement)?.closest('.framely-select')?.querySelector('[aria-expanded="true"]'))return;e.preventDefault();e.stopPropagation();closeRef.current();}if(e.key==='Tab'){const list=items(),first=list[0],last=list.at(-1);if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus();}}};document.addEventListener('keydown',key,true);return()=>{document.removeEventListener('keydown',key,true);if(previous?.isConnected&&!document.querySelector('.modal[aria-modal="true"]'))previous.focus();};},[]);
|
||||
return <div className="mobile-sheet-backdrop" onClick={e=>{if(e.target===e.currentTarget)onClose();}}><div className="mobile-sheet" ref={ref} role="dialog" aria-modal="true" aria-label={title}><header><h2>{title}</h2><button aria-label={t('关闭')} onClick={onClose}><IconX size={22}/></button></header><div className="mobile-sheet-body">{children}</div></div></div>;
|
||||
}
|
||||
export const managerPages=(section:ManagerSection):[string,string,string][]=>section==='settings'?[['settings',t('通用'),'settings'],['launcher-settings',t('启动台'),'launcher'],['updates',t('更新'),'refresh'],['about',t('关于'),'info']]:section==='apk'?[['apk',t('应用'),'download'],['apk-containers',t('容器'),'plugins'],['apk-cleanup',t('数据清理'),'settings'],['apk-settings',t('设置'),'settings']]:section==='casting'?[['casting',t('网页观看'),'connection'],['casting-picture',t('画面与声音'),'settings'],['casting-receive',t('接收投屏'),'download'],['casting-devices',t('投到设备'),'connection']]:section==='plugins'?[['installed',t('已安装'),'plugins'],['catalog',t('插件库'),'download'],['sources',t('插件源导航'),'link'],['notification-settings',t('通知设置导航'),'bell']]:[];
|
||||
export const managerPages=(section:ManagerSection):[string,string,string][]=>section==='settings'?[['settings',t('通用'),'settings'],['storage',t('存储'),'folder'],['launcher-settings',t('启动台'),'launcher'],['updates',t('更新'),'refresh'],['about',t('关于'),'info']]:section==='apk'?[['apk',t('应用'),'download'],['apk-containers',t('容器'),'plugins'],['apk-cleanup',t('数据清理'),'settings'],['apk-settings',t('设置'),'settings']]:section==='casting'?[['casting',t('网页观看'),'connection'],['casting-picture',t('画面与声音'),'settings'],['casting-receive',t('接收投屏'),'download'],['casting-devices',t('投到设备'),'connection']]:section==='plugins'?[['installed',t('已安装'),'plugins'],['catalog',t('插件库'),'download'],['sources',t('插件源导航'),'link'],['notification-settings',t('通知设置导航'),'bell']]:[];
|
||||
export function MobilePagePicker({section,tab,onChange}:{section:ManagerSection;tab:string;onChange:(tab:string)=>void}){
|
||||
const [open,setOpen]=useState(false),pages=managerPages(section),label=pages.find(p=>p[0]===tab)?.[1]??t(section==='terminal'?'终端':section==='casting'?'投屏':'文件');
|
||||
useEffect(()=>setOpen(false),[section,tab]);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
.storage-settings{width:100%;min-width:0}
|
||||
.storage-settings>.list-heading{margin-bottom:28px}
|
||||
.storage-section{margin-bottom:0}
|
||||
.storage-overview{margin-bottom:16px}
|
||||
.storage-overview-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}
|
||||
.storage-overview-heading h2{margin:0 0 4px}
|
||||
.storage-overview-heading p{margin:0}
|
||||
.storage-overview-total{display:grid;text-align:right;line-height:1.2}
|
||||
.storage-overview-total strong{font-size:30px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.04em}
|
||||
.storage-overview-total span{margin-top:6px;color:var(--muted);font-size:15px}
|
||||
.storage-meter{display:flex;width:100%;height:15px;margin-top:28px;border-radius:100px;overflow:hidden;background:#34383e}
|
||||
.storage-meter span{height:100%;flex:none;min-width:0}
|
||||
.storage-meter-plugin,.storage-dot-plugin{background:#93c5ed}
|
||||
.storage-meter-apk,.storage-dot-apk{background:#79aaa4}
|
||||
.storage-meter-contexts,.storage-dot-contexts{background:#c1a879}
|
||||
.storage-meter-other,.storage-dot-other{background:#626871}
|
||||
.storage-meter-free,.storage-dot-free{background:#34383e}
|
||||
.storage-overview-usage{display:flex;justify-content:space-between;gap:16px;margin:11px 0 25px;color:var(--muted);font-size:15px}
|
||||
.storage-overview-usage strong{color:var(--text);font-weight:550;font-variant-numeric:tabular-nums}
|
||||
.storage-legend{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:0 22px;border-top:1px solid var(--line)}
|
||||
.storage-legend-item{display:flex;align-items:center;gap:11px;min-width:0;padding:13px 0;border-bottom:1px solid var(--line);font-size:16px}
|
||||
.storage-legend-item i{width:9px;height:9px;border-radius:50%;flex:none}
|
||||
.storage-legend-item span{min-width:0;flex:1;color:var(--muted)}
|
||||
.storage-legend-item small{font-size:13px;color:var(--muted)}
|
||||
.storage-legend-item strong{font-weight:550;white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
.storage-overview-note{margin:15px 0 0;color:var(--muted);font-size:15px;line-height:1.5}
|
||||
/* Category identity stays in the left rail; paths and drill-down data live together on the right. */
|
||||
.storage-section{padding:32px 0 36px;margin:0}
|
||||
.storage-section-layout{display:grid;grid-template-columns:minmax(210px,27%) minmax(0,1fr);column-gap:clamp(28px,5vw,88px)}
|
||||
.storage-section-heading{display:flex;flex-direction:column;align-items:flex-start;min-width:0}
|
||||
.storage-kind-icon{display:grid;place-items:center;width:46px;height:46px;margin-bottom:17px;border:1px solid #4a5560;border-radius:5px;color:var(--blue);background:#93c5ed10}
|
||||
.storage-kind-apk{color:#9ac6c0;border-color:#445953;background:#79aaa410}
|
||||
.storage-kind-contexts{color:#dbc294;border-color:#605543;background:#c1a87910}
|
||||
.storage-section-heading h2{margin:0 0 5px;font-size:25px;line-height:1.2}
|
||||
.storage-section-heading .sub{max-width:260px;margin:0;color:var(--muted);font-size:16px;line-height:1.45}
|
||||
.storage-size{margin-top:24px;color:var(--text);font-size:29px;font-weight:600;line-height:1.15;letter-spacing:-.035em;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.storage-section-content{min-width:0}
|
||||
.storage-location{display:grid;grid-template-columns:22px minmax(0,1fr) auto;align-items:center;gap:15px;min-width:0;min-height:84px;padding:15px 0;border-bottom:1px solid var(--line)}
|
||||
.storage-location>svg{align-self:start;margin-top:8px;color:var(--muted)}
|
||||
.storage-location>div{display:grid;gap:5px;min-width:0}
|
||||
.storage-location span{color:var(--muted);font-size:14px;line-height:1.2}
|
||||
.storage-location code,.storage-warning code{font:16px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere;word-break:break-word}
|
||||
.storage-location button{min-height:44px;padding:9px 14px;flex:none;white-space:nowrap;font-size:16px}
|
||||
.storage-section .storage-details{margin:0;padding:0;border:0}
|
||||
.storage-section .storage-details summary{display:flex;align-items:center;gap:12px;min-height:58px;padding:10px 0;color:var(--text);font-size:16px;font-weight:550;list-style:none;cursor:pointer}
|
||||
.storage-section .storage-details summary::-webkit-details-marker{display:none}
|
||||
.storage-section .storage-details summary:hover{color:var(--blue)}
|
||||
.storage-section .storage-details summary small{margin-left:auto;color:var(--muted);font-size:14px;font-weight:400;font-variant-numeric:tabular-nums}
|
||||
.storage-section .storage-details summary svg{color:var(--muted);transition:transform .15s ease}
|
||||
.storage-section .storage-details[open] summary svg{transform:rotate(90deg);color:var(--blue)}
|
||||
.storage-detail-list{margin:0 0 8px 22px;padding:3px 18px 3px 20px;border-left:2px solid #536273;background:#ffffff07}
|
||||
.storage-detail-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:20px;min-height:61px;padding:11px 0}
|
||||
.storage-detail-row+.storage-detail-row{border-top:1px solid #ffffff14}
|
||||
.storage-detail-row>div{display:flex;flex-direction:column;min-width:0;gap:3px}
|
||||
.storage-detail-row strong{font-size:16px;font-weight:550;line-height:1.4;overflow-wrap:anywhere}
|
||||
.storage-detail-row small{font-size:13px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.storage-detail-row>span{flex:none;font-size:16px;font-weight:550;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.storage-detail-other strong{color:var(--muted)}
|
||||
.storage-empty{margin:0;padding:18px 0;color:var(--muted);font-size:16px}
|
||||
.storage-warning{margin:14px 0 0;padding:12px 16px;border-left:3px solid #c6a779;background:#2d2923;color:#ecd7b8;font-size:16px;line-height:1.5}
|
||||
.storage-warning code{display:block;margin-top:6px;color:inherit}
|
||||
.storage-change{margin:0;padding:20px 0 22px;border-bottom:1px solid var(--line)}
|
||||
.storage-change label{margin:0 0 10px;font-size:16px;font-weight:550}
|
||||
.storage-target{display:flex;align-items:stretch;gap:10px}
|
||||
.storage-target input{min-width:0;flex:1;width:auto!important;margin:0!important;font:16px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
.storage-target button{flex:none;white-space:nowrap;font-size:16px}
|
||||
.storage-change>.sub{margin:10px 0 0;font-size:14px;line-height:1.5}
|
||||
.storage-actions{display:flex;justify-content:flex-end;margin-top:17px}
|
||||
.storage-actions button{white-space:nowrap;font-size:16px}
|
||||
.storage-progress{display:flex;align-items:center;gap:10px;margin:14px 0;padding:12px 0;color:var(--blue);font-size:16px}
|
||||
.storage-location button:active:not(:disabled),.storage-target button:active:not(:disabled),.storage-actions button:active:not(:disabled){transform:scale(.98)}
|
||||
@media(prefers-reduced-motion:no-preference){.storage-progress svg{animation:storage-turn 1.4s linear infinite}}
|
||||
@keyframes storage-turn{to{transform:rotate(360deg)}}
|
||||
.storage-loading{display:grid}
|
||||
.storage-loading>div{display:grid;gap:14px;padding:29px 0;border-top:1px solid var(--line)}
|
||||
.storage-loading i,.storage-loading span,.storage-loading small{display:block;height:19px;border-radius:3px;background:#ffffff12}
|
||||
.storage-loading i{width:150px;height:26px}.storage-loading span{width:min(310px,65%)}.storage-loading small{width:min(460px,80%)}
|
||||
.storage-picker-backdrop{position:fixed;inset:0;z-index:30;display:grid;place-items:center;padding:16px;background:#000a}
|
||||
.storage-picker{display:flex;flex-direction:column;gap:16px;width:min(720px,100%);max-height:min(760px,calc(100dvh - 32px));padding:30px;background:#202020;border:1px solid #626974;border-radius:6px;box-shadow:0 24px 70px #0008;overflow:hidden}
|
||||
.storage-picker header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
|
||||
.storage-picker header h2{font-size:28px;margin:0 0 4px}
|
||||
.storage-picker header p{margin:0;color:var(--muted);font-size:17px}
|
||||
.storage-picker header .icon-button{margin:-8px -8px 0 0}
|
||||
.storage-picker-location{display:flex;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:4px;background:#171717}
|
||||
.storage-picker-location .icon-button{min-width:42px;min-height:42px;padding:8px}
|
||||
.storage-picker-location code{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:16px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
.storage-picker-roots{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.storage-picker-roots button{min-height:42px;padding:8px 12px;font-size:16px}
|
||||
.storage-picker-folders{min-height:150px;overflow:auto;border-top:1px solid var(--line)}
|
||||
.storage-picker-folders button{display:flex;justify-content:flex-start;width:100%;min-height:54px;padding:10px 12px;border:0;border-bottom:1px solid var(--line);border-radius:0;background:transparent;text-align:left}
|
||||
.storage-picker-folders button:hover:not(:disabled){background:var(--control-hover)}
|
||||
.storage-picker-folders p{padding:22px 10px}
|
||||
.storage-picker footer{display:flex;justify-content:flex-end;gap:10px;padding-top:14px;border-top:1px solid var(--line)}
|
||||
.storage-picker .error{margin:0}
|
||||
@media(max-width:900px){.storage-section-layout{grid-template-columns:1fr;gap:20px}.storage-section-heading{display:grid;grid-template-columns:46px minmax(0,1fr) auto;column-gap:14px;align-items:center}.storage-kind-icon{margin:0;grid-row:1 / span 2}.storage-section-heading h2{font-size:22px}.storage-section-heading .sub{font-size:15px;max-width:none}.storage-size{margin:0;grid-column:3;grid-row:1 / span 2;font-size:22px}.storage-section-content{padding-left:60px}}
|
||||
@media(max-width:700px){.storage-overview-total strong{font-size:24px}.storage-legend{grid-template-columns:1fr 1fr;gap:0 14px}.storage-legend-item{font-size:14px;gap:7px}.storage-legend-item strong{font-size:14px}.storage-section{padding:27px 0 31px}.storage-section-layout{gap:16px}.storage-section-content{padding-left:0}.storage-location{grid-template-columns:20px minmax(0,1fr);gap:12px}.storage-location>svg{margin-top:4px}.storage-location button{grid-column:2;justify-self:start;margin-top:4px}.storage-detail-list{margin-left:8px;padding-left:16px;padding-right:8px}.storage-target{flex-wrap:wrap}.storage-target input{flex-basis:100%;width:100%!important}.storage-target button{width:100%}.storage-actions button{width:100%}.storage-picker{max-height:calc(100dvh - 24px);padding:20px}.storage-picker footer button{flex:1;min-width:0;white-space:nowrap}}
|
||||
@media(prefers-reduced-motion:reduce){.storage-section .storage-details summary svg{transition:none}}
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {IconArrowLeft, IconChevronRight, IconDeviceMobile, IconFolder, IconFolderOpen, IconLoader2, IconPackage, IconPuzzle, IconX} from '@tabler/icons-react';
|
||||
import {api} from './api';
|
||||
import {managerApi} from './manager-api';
|
||||
import {t} from './i18n';
|
||||
import './storage-settings.css';
|
||||
|
||||
type Kind = 'plugin' | 'apk' | 'contexts';
|
||||
type Detail = {id: string; name: string; bytes: number; programBytes?: number; dataBytes?: number; sizeIncomplete?: boolean};
|
||||
type Location = {path: string; bytes: number; device?: number | null; details?: Detail[]; sizeIncomplete?: boolean; available?: boolean; interrupted?: string | null};
|
||||
type Places = Record<Kind, Location>;
|
||||
type Volume = {total: number; free: number; device: number};
|
||||
type Job = {phase: string; step?: string; error?: string; result?: {path: string; cleanupWarnings?: string[]}};
|
||||
type Listing = {path: string; parent: string | null; roots: {name: string; path: string}[]; entries: {name: string; path: string; directory: boolean}[]};
|
||||
|
||||
const kinds: Kind[] = ['plugin', 'apk', 'contexts'];
|
||||
const kindIcons = {plugin: IconPuzzle, apk: IconPackage, contexts: IconDeviceMobile};
|
||||
const labels: Record<Kind, {title: string; description: string; folder: string}> = {
|
||||
plugin: {title: '插件', description: '插件程序和运行数据', folder: 'Framely-Plugins'},
|
||||
apk: {title: 'APK 文件', description: '安装包、上传文件和备份', folder: 'Framely-APK'},
|
||||
contexts: {title: '安卓容器', description: 'Framely 创建的 Lepton 容器和应用数据', folder: 'Framely-Android'},
|
||||
};
|
||||
const steps: Record<string, string> = {
|
||||
queued: '准备迁移', loading: '准备迁移', stopping: '停止相关程序',
|
||||
copying: '复制数据', verifying: '核对文件', switching: '切换目录',
|
||||
restarting: '恢复运行', cleaning: '清理旧目录',
|
||||
};
|
||||
function size(n: number) {
|
||||
return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KiB`
|
||||
: n < 1073741824 ? `${(n / 1048576).toFixed(1)} MiB` : `${(n / 1073741824).toFixed(1)} GiB`;
|
||||
}
|
||||
async function browse(path: string): Promise<Listing> {
|
||||
return managerApi<Listing>('files', {operation: 'list', path: path || undefined, hidden: false});
|
||||
}
|
||||
|
||||
function FolderBrowser({close, choose}: {close: () => void; choose: (path: string) => void}) {
|
||||
const [listing, setListing] = useState<Listing | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
async function open(path: string) {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try { setListing(await browse(path)); }
|
||||
catch (e) { setError(String(e)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
useEffect(() => { void open(''); }, []);
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') close(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [close]);
|
||||
return <div className="storage-picker-backdrop" onClick={event => { if (event.target === event.currentTarget) close(); }}>
|
||||
<div className="storage-picker" role="dialog" aria-modal="true" aria-label={t('选择目标目录')}>
|
||||
<header><div><h2>{t('选择目标目录')}</h2><p>{t('选择上级目录,Framely 会在其中创建专用文件夹。')}</p></div><button className="icon-button" aria-label={t('关闭')} onClick={close}><IconX size={22}/></button></header>
|
||||
<div className="storage-picker-location"><button className="icon-button" disabled={!listing?.parent || loading} onClick={() => listing?.parent && void open(listing.parent)} aria-label={t('上级目录')}><IconArrowLeft size={22}/></button><code title={listing?.path}>{listing?.path ?? t('正在读取目录…')}</code></div>
|
||||
{!!listing?.roots.length && <div className="storage-picker-roots">{listing.roots.map(root => <button key={root.path} disabled={loading} onClick={() => void open(root.path)}>{root.name}</button>)}</div>}
|
||||
<div className="storage-picker-folders">{listing?.entries.filter(entry => entry.directory).map(entry => <button key={entry.path} disabled={loading} onClick={() => void open(entry.path)}><IconFolder size={21}/><span>{entry.name}</span></button>)}{!loading && listing && !listing.entries.some(entry => entry.directory) && <p className="sub">{t('此目录下没有文件夹')}</p>}{loading && <p className="sub" role="status">{t('正在读取目录…')}</p>}</div>
|
||||
{error && <p role="alert" className="error">{error}</p>}
|
||||
<footer><button onClick={close}>{t('取消')}</button><button className="primary" disabled={!listing || loading} onClick={() => { choose(listing!.path); close(); }}>{t('选择此位置')}</button></footer>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function StorageSettings() {
|
||||
const [places, setPlaces] = useState<Places | null>(null);
|
||||
const [volume, setVolume] = useState<Volume | null>(null);
|
||||
const [paths, setPaths] = useState<Record<Kind, string>>({plugin: '', apk: '', contexts: ''});
|
||||
const [editing, setEditing] = useState<Kind | null>(null);
|
||||
const [picker, setPicker] = useState<Kind | null>(null);
|
||||
const [active, setActive] = useState<Kind | null>(null);
|
||||
const [job, setJob] = useState<{kind: Kind; id: string} | null>(null);
|
||||
const [step, setStep] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function refresh() {
|
||||
const [plugin, other] = await Promise.all([
|
||||
api<Location>('storage.plugin.status'),
|
||||
api<{apk: Location; contexts: Location; volume: Volume; interrupted?: {kind: Kind; target: string} | null}>('apk.storage.status'),
|
||||
]);
|
||||
const next: Places = {
|
||||
plugin,
|
||||
apk: {...other.apk, interrupted: other.interrupted?.kind === 'apk' ? other.interrupted.target : null},
|
||||
contexts: {...other.contexts, interrupted: other.interrupted?.kind === 'contexts' ? other.interrupted.target : null},
|
||||
};
|
||||
setPlaces(next);
|
||||
setVolume(other.volume);
|
||||
setPaths(previous => ({
|
||||
plugin: previous.plugin || plugin.interrupted || plugin.path,
|
||||
apk: previous.apk || next.apk.interrupted || next.apk.path,
|
||||
contexts: previous.contexts || next.contexts.interrupted || next.contexts.path,
|
||||
}));
|
||||
}
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try { await refresh(); }
|
||||
catch (e) { setError(String(e)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
useEffect(() => { void reload(); }, []);
|
||||
useEffect(() => {
|
||||
if (!job) return;
|
||||
let alive = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const value = await api<Job>(job.kind === 'plugin' ? 'storage.plugin.job' : 'job.status', {job: job.id});
|
||||
if (!alive) return;
|
||||
setStep(value.step ?? value.phase);
|
||||
if (value.phase === 'done') {
|
||||
setJob(null); setActive(null); setEditing(null);
|
||||
setNotice(value.result?.cleanupWarnings?.length ? t('迁移完成。旧目录未能清理,请检查原位置。') : t('迁移完成'));
|
||||
setPaths(previous => ({...previous, [job.kind]: value.result?.path ?? previous[job.kind]}));
|
||||
await refresh();
|
||||
} else if (value.phase === 'failed' || value.phase === 'cancelled') {
|
||||
setJob(null); setActive(null);
|
||||
setError(value.error ?? t('迁移失败'));
|
||||
await refresh();
|
||||
}
|
||||
} catch (e) { if (alive) setError(String(e)); }
|
||||
};
|
||||
void poll();
|
||||
const timer = window.setInterval(() => void poll(), 750);
|
||||
return () => { alive = false; window.clearInterval(timer); };
|
||||
}, [job]);
|
||||
async function migrate(kind: Kind) {
|
||||
setError(''); setNotice(''); setActive(kind);
|
||||
try {
|
||||
const result = await api<{job: string}>(kind === 'plugin' ? 'storage.plugin.start' : 'apk.storage.start', kind === 'plugin' ? {path: paths[kind]} : {kind, path: paths[kind]});
|
||||
setJob({kind, id: result.job}); setStep('queued');
|
||||
} catch (e) { setError(String(e)); setActive(null); }
|
||||
}
|
||||
function edit(kind: Kind) {
|
||||
setError(''); setNotice(''); setEditing(kind);
|
||||
if (places) setPaths(previous => ({...previous, [kind]: places[kind].interrupted || places[kind].path}));
|
||||
}
|
||||
return <div className="storage-settings">
|
||||
<div className="list-heading"><div><h1>{t('存储')}</h1><p>{t('管理插件、APK 文件和安卓容器的存放位置。')}</p></div></div>
|
||||
{notice && <p className="banner" role="status">{notice}</p>}
|
||||
{error && <p className="error" role="alert">{error}{!places && <button onClick={() => void reload()}>{t('重试')}</button>}</p>}
|
||||
{loading && !places && <div className="storage-loading" role="status" aria-label={t('正在读取存储位置')}>{kinds.map(kind => <div key={kind}><i/><span/><small/></div>)}</div>}
|
||||
{places && volume && (() => {
|
||||
const onVolume = (kind: Kind) => places[kind].bytes === 0 || places[kind].device === volume.device;
|
||||
const managed = kinds.reduce((sum, kind) => sum + (onVolume(kind) ? places[kind].bytes : 0), 0);
|
||||
const free = Math.min(volume.free, volume.total);
|
||||
const used = Math.max(0, volume.total - free);
|
||||
const other = Math.max(0, used - managed);
|
||||
const segments = [
|
||||
...kinds.map(kind => ({key: kind, label: t(labels[kind].title), bytes: onVolume(kind) ? places[kind].bytes : 0, displayBytes: places[kind].bytes, moved: !onVolume(kind)})),
|
||||
{key: 'other', label: t('系统及其他'), bytes: other, displayBytes: other, moved: false},
|
||||
{key: 'free', label: t('空闲'), bytes: free, displayBytes: free, moved: false},
|
||||
];
|
||||
return <section className="settings-card storage-overview" aria-labelledby="storage-overview-title">
|
||||
<div className="storage-overview-heading"><div><h2 id="storage-overview-title">{t('存储占用')}</h2><p className="sub">{t('头显用户存储卷')}</p></div><div className="storage-overview-total"><strong>{size(volume.total)}</strong><span>{t('总容量')}</span></div></div>
|
||||
<div className="storage-meter" role="img" aria-label={`${t('已使用')} ${size(used)},${t('空闲')} ${size(free)}`}>{segments.map(segment => <span key={segment.key} className={`storage-meter-${segment.key}`} style={{width: `${volume.total ? segment.bytes / volume.total * 100 : 0}%`}} title={`${segment.label} ${size(segment.bytes)}`}/>)}</div>
|
||||
<div className="storage-overview-usage"><span>{t('已使用')} <strong>{size(used)}</strong></span><span>{t('空闲')} <strong>{size(free)}</strong></span></div>
|
||||
<div className="storage-legend">{segments.map(segment => <div key={segment.key} className="storage-legend-item"><i className={`storage-dot-${segment.key}`}/><span>{segment.label}{segment.moved && <small> · {t('其他磁盘')}</small>}</span><strong>{size(segment.displayBytes)}</strong></div>)}</div>
|
||||
{kinds.some(kind => !onVolume(kind)) && <p className="storage-overview-note">{t('已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。')}</p>}
|
||||
{kinds.some(kind => places[kind].sizeIncomplete) && <p className="storage-overview-note">{t('部分目录无法读取,Framely 占用可能低于实际值。')}</p>}
|
||||
</section>;
|
||||
})()}
|
||||
{places && kinds.map(kind => {
|
||||
const place = places[kind];
|
||||
const busy = !!job || !!active;
|
||||
const changing = editing === kind;
|
||||
const migrating = active === kind;
|
||||
const unchanged = !paths[kind] || paths[kind] === place.path;
|
||||
const details = [...(place.details ?? [])].sort((a, b) => b.bytes - a.bytes);
|
||||
const remainder = Math.max(0, place.bytes - details.reduce((sum, detail) => sum + detail.bytes, 0));
|
||||
const detailCount = details.length + (remainder > 0 ? 1 : 0);
|
||||
const KindIcon = kindIcons[kind];
|
||||
return <section className="settings-card storage-section" key={kind} aria-labelledby={`storage-${kind}`}>
|
||||
<div className="storage-section-layout">
|
||||
<div className="storage-section-heading">
|
||||
<span className={`storage-kind-icon storage-kind-${kind}`} aria-hidden="true"><KindIcon size={26} stroke={1.7}/></span>
|
||||
<div><h2 id={`storage-${kind}`}>{t(labels[kind].title)}</h2><p className="sub">{t(labels[kind].description)}</p></div>
|
||||
<span className="storage-size" title={place.sizeIncomplete ? t('部分目录无法读取,显示可统计的最小占用') : undefined}>{place.sizeIncomplete ? '≥ ' : ''}{size(place.bytes)}</span>
|
||||
</div>
|
||||
<div className="storage-section-content">
|
||||
<div className="storage-location">
|
||||
<IconFolder size={22} stroke={1.7} aria-hidden="true"/>
|
||||
<div><span>{t('当前位置')}</span><code title={place.path}>{place.path}</code></div>
|
||||
<button disabled={busy || place.available === false} onClick={() => changing ? setEditing(null) : edit(kind)}>{changing ? t('取消更改') : place.interrupted ? t('继续迁移') : t('更改位置')}</button>
|
||||
</div>
|
||||
{place.available === false && <p className="storage-warning" role="status">{t('当前目录不可用,请先连接存储设备。')}</p>}
|
||||
{place.interrupted && <p className="storage-warning" role="status">{t('上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。')}<code>{place.interrupted}</code></p>}
|
||||
{changing && <div className="storage-change">
|
||||
<label htmlFor={`storage-target-${kind}`}>{t('新位置')}</label>
|
||||
<div className="storage-target"><input id={`storage-target-${kind}`} value={paths[kind]} disabled={busy} onChange={event => setPaths(previous => ({...previous, [kind]: event.target.value}))} spellCheck={false}/><button disabled={busy} onClick={() => setPicker(kind)}><IconFolderOpen size={21}/>{t('浏览')}</button></div>
|
||||
<p className="sub">{t('目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。')}</p>
|
||||
<div className="storage-actions"><button className="primary" disabled={busy || place.available === false || unchanged} onClick={() => void migrate(kind)}>{t('迁移到新位置')}</button></div>
|
||||
</div>}
|
||||
{migrating && <div className="storage-progress" role="status"><IconLoader2 size={20}/><span>{t(steps[step] ?? step)}</span></div>}
|
||||
<details className="storage-details">
|
||||
<summary><span>{t('占用明细')}</span><small>{t('{0} 项', {'0': detailCount})}</small><IconChevronRight size={19} aria-hidden="true"/></summary>
|
||||
<div className="storage-detail-list">
|
||||
{details.map(detail => <div className="storage-detail-row" key={detail.id}><div><strong>{detail.name || detail.id}</strong>{detail.id !== detail.name && <small title={detail.id}>{detail.id}</small>}{kind === 'plugin' && <small>{t('程序')} {size(detail.programBytes ?? 0)} · {t('数据')} {size(detail.dataBytes ?? 0)}</small>}</div><span title={detail.sizeIncomplete ? t('部分目录无法读取,显示可统计的最小占用') : undefined}>{detail.sizeIncomplete ? '≥ ' : ''}{size(detail.bytes)}</span></div>)}
|
||||
{remainder > 0 && <div className="storage-detail-row storage-detail-other"><div><strong>{t(kind === 'apk' ? '备份、上传文件及其他' : kind === 'plugin' ? '其他插件文件' : '其他容器文件')}</strong></div><span>{size(remainder)}</span></div>}
|
||||
{detailCount === 0 && <p className="storage-empty">{t('暂无数据')}</p>}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
})}
|
||||
{picker && <FolderBrowser close={() => setPicker(null)} choose={parent => setPaths(previous => ({...previous, [picker]: `${parent.replace(/\/$/, '')}/${labels[picker].folder}`}))}/>}
|
||||
</div>;
|
||||
}
|
||||
+8
-1
@@ -24,6 +24,13 @@ export function TerminalManager(){
|
||||
useEffect(()=>{void refresh();const timer=setInterval(()=>void refresh(),3000);return()=>clearInterval(timer);},[]);
|
||||
async function create(){if(busy)return;setBusy(true);try{const s=await managerApi<Session>('terminal',{operation:'create',name:t('终端')});setSelected(s.id);setName(s.name);await refresh();term.current?.focus();}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
useEffect(()=>{if(!host.current||!selected)return;const terminal=new Terminal({fontSize:matchMedia('(max-width: 767px)').matches?13:16,scrollback:10000,allowTransparency:true,cursorBlink:!matchMedia('(prefers-reduced-motion: reduce)').matches,disableStdin:true,theme:{background:'#00000000',foreground:'#e8e8ed',selectionBackground:'#555555'}}),fitter=new FitAddon();terminal.loadAddon(fitter);terminal.open(host.current);term.current=terminal;fit.current=fitter;terminal.parser.registerOscHandler(52,()=>true);
|
||||
const keyboardInput=(event:Event)=>{
|
||||
event.preventDefault();
|
||||
if(terminal.options.disableStdin)return;
|
||||
const edit=(event as CustomEvent<string>).detail;
|
||||
terminal.input(edit==='\b'?'\x7f':edit==='\n'?'\r':edit,true);
|
||||
};
|
||||
terminal.textarea?.addEventListener('framely.keyboard.edit',keyboardInput);
|
||||
terminal.attachCustomKeyEventHandler(event=>{
|
||||
if(event.type!=='keydown'||!event.ctrlKey||!event.shiftKey||event.altKey||event.metaKey)return true;
|
||||
if(event.code==='KeyV')return false; // Native paste supplies the clipboard to xterm; do not emit Ctrl-V.
|
||||
@@ -44,7 +51,7 @@ export function TerminalManager(){
|
||||
ws.onmessage=e=>{if(!alive||socket.current!==ws)return;lastSeen=performance.now();try{const v=JSON.parse(e.data);setWritable(v.writable);terminal.options.disableStdin=!v.writable||v.exited;setExited(v.exited);if(v.truncated)setTruncated(true);if(v.data){const raw=atob(v.data);terminal.write(Uint8Array.from(raw,c=>c.charCodeAt(0)));}}catch(err){setError(String(err));}};
|
||||
ws.onclose=()=>{if(!alive||socket.current!==ws)return;if(timer)clearInterval(timer);setConnected(false);setWritable(false);terminal.options.disableStdin=true;retry=setTimeout(connect,2000);};ws.onerror=()=>setError(t('终端连接中断,正在重连。'));
|
||||
}
|
||||
const offline=()=>{if(localPanel)return;terminal.options.disableStdin=true;setConnected(false);setWritable(false);socket.current?.close();};window.addEventListener('offline',offline);window.addEventListener('online',connect);reconnect.current=connect;connect();return()=>{window.removeEventListener('offline',offline);window.removeEventListener('online',connect);alive=false;if(timer)clearInterval(timer);if(retry)clearTimeout(retry);observer.disconnect();input.dispose();socket.current?.close();socket.current=null;terminal.dispose();term.current=null;};
|
||||
const offline=()=>{if(localPanel)return;terminal.options.disableStdin=true;setConnected(false);setWritable(false);socket.current?.close();};window.addEventListener('offline',offline);window.addEventListener('online',connect);reconnect.current=connect;connect();return()=>{window.removeEventListener('offline',offline);window.removeEventListener('online',connect);terminal.textarea?.removeEventListener('framely.keyboard.edit',keyboardInput);alive=false;if(timer)clearInterval(timer);if(retry)clearTimeout(retry);observer.disconnect();input.dispose();socket.current?.close();socket.current=null;terminal.dispose();term.current=null;};
|
||||
},[selected]);
|
||||
const current=sessions.find(session=>session.id===selected);
|
||||
async function rename(){if(!name.trim()||busy)return;setBusy(true);try{await managerApi('terminal',{operation:'rename',id:selected,name:name.trim()});await refresh();setRenaming(false);}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ export function SystemUpdates({status,refresh}:{status:any;refresh:()=>Promise<v
|
||||
<p className="sub">{t('安装更新会保留插件和数据,Framely 界面会暂时关闭并重新启动。')}</p>
|
||||
{!source&&<p className="banner">{t('此版本尚未配置更新服务,请通过发行安装包更新。')}</p>}
|
||||
<Select label={t('更新渠道')} value={channel} disabled={busy||installing||!source} onChange={value=>void switchChannel(value)} options={[{value:'stable',label:t('正式版')},{value:'testing',label:t('测试版')}]}/>
|
||||
<p className="sub">{channel==='stable'?t('仅检查正式版。'):t('仅检查测试版(Preview、Beta、RC 等)。')}</p>
|
||||
<p className="sub">{channel==='stable'?t('仅检查正式版。'):t('检查正式版和测试版(Preview、Beta、RC 等),选择最新版本。')}</p>
|
||||
<div className="row">
|
||||
<button className="primary" disabled={busy||installing||!source} onClick={()=>{reset();void run('system.check.start',{},v=>{setRelease(v.release?{...v.release,targetVersion:v.version}:null);setReady(false);setChecked(true);setShowRelease(!!v.release&&!sameSystemVersion(v.release.version,status.build??status.version));});}}>{busy?t('处理中…'):t('检查更新')}</button>
|
||||
<button disabled={busy||installing||!status.previousRelease} onClick={()=>setConfirm('rollback')}>{t('回滚上一版本')}</button>
|
||||
|
||||
Reference in new issue
Block a user