Compare commits

...
16 Commits
Author SHA1 Message Date
toorux c2272168c5 Release v0.8.5-preview.2 2026-10-26 01:01:44 +08:00
toorux fc381c2676 Fix AirPlay startup and service discovery on Frame 2026-10-26 01:00:39 +08:00
toorux f7ae2e8e2c Preserve casting failure logs in diagnostics 2026-10-26 01:00:21 +08:00
toorux d4af27d63b Add in-headset review for app-requested APK updates
Intercept content, file and PackageInstaller requests in managed Lepton containers and route retained APKs through the normal review, backup and installation flow. Persist presentation receipts, retry delivery, and reopen repeated requests without duplicating packages.

Use authenticated localized update windows with visible laser cursors, and switch native Steam runtime aliases back to stable context names for installation. Verified all three request paths and a live Steam-runtime reinstall on Frame with backup creation and unchanged save data.
2026-10-24 22:05:47 +08:00
toorux 3b32f7102a Release v0.8.5-preview.1 2026-10-24 14:09:08 +08:00
toorux 9f99ecdfa8 Wait for the user manager before starting the UI session 2026-10-24 14:07:46 +08:00
toorux 9a72a50f1a Fix initial native visibility polling and legacy report validation 2026-10-24 14:07:37 +08:00
toorux 1d6f5a0e3f Fix APK backups of native OverlayFS work directories 2026-10-24 14:07:25 +08:00
toorux 9d2de8dc6d Release v0.8.4 2026-10-24 01:21:32 +08:00
toorux 5886198f4b Release v0.8.4-preview.1 2026-10-24 00:17:07 +08:00
toorux f6e7cc4674 Resolve explicitly tagged installs without GitHub API 2026-10-24 00:16:46 +08:00
toorux 58fcbb851e Retry direct GitHub when configured mirror is rate limited 2026-10-24 00:16:34 +08:00
toorux 7f12cdb7d7 Fix release manifest selecting q2f archive 2026-10-24 00:04:09 +08:00
toorux da0bb44a98 Handle interrupted release lookup in installer wizard 2026-10-20 21:34:26 +08:00
toorux 4e6e538b23 Support interactive installation from a staged release bundle 2026-10-20 21:30:17 +08:00
toorux 4b06a59995 Add interactive installer wizard and clearer download diagnostics 2026-10-20 20:53:22 +08:00
57 changed files with 2786 additions and 127 deletions

No files matched your search

+8 -3
View File
@@ -59,8 +59,8 @@ jobs:
sudo apt-get update
sudo apt-get install -y g++ cmake pkg-config libgl1-mesa-dev libx11-dev \
libdrm-dev libvulkan-dev glslc libclang-dev libssl-dev libplist-dev \
libavahi-compat-libdnssd-dev libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev libgupnp-1.6-dev \
libavahi-compat-libdnssd-dev libavahi-core-dev libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev libgupnp-1.6-dev libpulse-dev libsoup-3.0-dev \
meson ninja-build libavfilter-dev libavformat-dev libavcodec-dev libavutil-dev
- name: Check release version
env:
@@ -135,7 +135,12 @@ jobs:
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)
mapfile -t core_archives < <(find release -maxdepth 1 -type f -name 'framely-[0-9]*-linux-arm64.tar.gz' ! -name '*-offline-linux-arm64.tar.gz' -print)
if [[ ${#core_archives[@]} != 1 ]]; then
printf 'Expected exactly one Framely core archive, found %s\n' "${#core_archives[@]}" >&2
exit 1
fi
archive=${core_archives[0]}
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
Generated
+1 -1
View File
@@ -425,7 +425,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.8.3"
version = "0.8.5-preview.2"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.8.3"
version = "0.8.5-preview.2"
edition = "2021"
license = "AGPL-3.0-only"
+2 -2
View File
@@ -30,7 +30,7 @@ Framecorder 原插件的名称是“面板画面”和“SteamVR 备用画面”
## 接收与发送
AirPlay 由固定版本 UxPlay 处理 RAOP 音频和屏幕镜像;只允许一个发送设备,不实现 AirPlay 发送。自定义 GStreamer sink 在用户确认前丢弃音频和画面。断开、拒绝或停止后清除同意状态,新连接重新询问。
AirPlay 由固定版本 UxPlay 处理 RAOP 音频和屏幕镜像;只允许一个发送设备,不实现 AirPlay 发送。接收进程使用系统 Avahi core 库独立发布 AirPlay/RAOP 服务,不依赖系统 Avahi daemon 的发布权限,也不修改其全局配置。自定义 GStreamer sink 在用户确认前丢弃音频和画面。断开、拒绝或停止后清除同意状态,新连接重新询问。
DLNA 使用 GUPnP 的标准发现、SOAP 和事件订阅,接收 HTTP(S) 媒体地址。每个新请求先显示通知,接受后才下载、解码和播放。接收的音频打开内置控制窗口;视频采用旋转及像素比例校正后的实际画幅,控件浮在视频上并自动收起,不改变窗口画幅。不同 DLNA 请求可以保持多个空间窗口。播放器支持暂停、音量、停止以及媒体支持时的跳转;关闭空间窗口会停止该投屏。未处理请求 60 秒后过期。
@@ -42,7 +42,7 @@ DLNA 发送需要开启网络管理面板并启动串流。GUPnP 发现设备后
头显运行依赖现有 FFmpeg(libx264、libvpx、libopus、AAC、V4L2、PulseAudio)、GStreamer 1.24+、FFmpeg 7 共享库与随包的 gst-libav 插件、GUPnP 1.6、Avahi、OpenVR、Vulkan/DRM、Qualcomm Iris。设备上已确认 FFmpeg、GStreamer 1.24.2、GUPnP 1.6.6 和上述编码器可用。
构建需要 Rust、C/C++ 编译器、CMake、Meson、Ninja、pkg-config、glslc,以及 DRM/Vulkan、GStreamer app/video、GUPnP 1.6、Avahi、OpenSSL/libplist 开发文件。`tools/build-media.sh` 构建采集助手、接收器、gst-libav 插件和 UxPlay,并下载校验固定版本的 MediaMTX。可用 `FRAMELY_SYSROOT` 为采集 crate 指定 ARM64 sysroot;gst-libav 链接 FFmpeg 7 ABI,其余组件使用目标系统的开发库。
构建需要 Rust、C/C++ 编译器、CMake、Meson、Ninja、pkg-config、glslc,以及 DRM/Vulkan、GStreamer app/video、GUPnP 1.6、Avahi、PulseAudio、libsoup、OpenSSL/libplist 开发文件。`tools/build-media.sh` 构建采集助手、接收器、gst-libav、接收管线必需的 gst-plugins-good 插件和 UxPlay,并下载校验固定版本的 MediaMTX。可用 `FRAMELY_SYSROOT` 为采集 crate 指定 ARM64 sysroot;gst-libav 链接 FFmpeg 7 ABI,其余组件使用目标系统的开发库。
`tools/package-release.sh <CEF目录>` 同时构建媒体能力;助手放入 `bin`,其他媒体程序放入 `lib/media`。第三方许可证和媒体改动源码随包提供,版本和 SHA-256 位于 `media/dependencies.json`。
+6
View File
@@ -218,3 +218,9 @@ L2/R2 digital button activation is adjustable in **APK Manager → Settings**, f
## Per-app graphics compatibility
Under **Manage app → Advanced settings**, **Framebuffer compatibility fix** is off by default. Enable it only for games that play sound but show a black screen (tested with Rock Kingdom on the current Mesa/Zink driver). Restart the app after changing it. The GLES layer validates framebuffer status after texture attachment, only in the selected application; other apps, system drivers, game files and saved data remain unchanged. Turning it off restores the normal rendering path on the next app launch. It is a targeted workaround, not a general graphics or performance enhancement.
## In-app updates
When an application downloads an APK and requests installation, Framely retains the update and automatically opens a separate installation confirmation window. Verify the versions, display mode and Quest conversion option, then confirm; opening the management panel first is unnecessary. Cancelled updates remain available under **In-app updates** in the APK manager. Updates use the existing review, container backup and installation flow. Signature failures and downgrades never cause an automatic uninstall. Cancelling review retains the download; **Ignore** removes its queued copy.
Insufficient space leaves the existing application unchanged and reports required and available space. Free space or change storage locations, then retry. Interception becomes available on the next container startup. Complete single-APK updates are supported; split APK, multi-package and staged sessions are reported as unsupported rather than partially installed. Privileged silent package-manager installations are outside this interception path.
+9 -3
View File
@@ -24,12 +24,14 @@ Downloads are checked locally and again on the device. Passwords are not saved i
## Option 2: install from Frame's SSH terminal
Run these commands **on Frame**. To install the latest stable release:
Run this command **on Frame** to open the installer:
```bash
curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes
curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash
```
With no arguments, this opens a wizard that detects whether to install or update, offers stable and testing releases, and lets you choose direct GitHub access or a mirror. You can go back or quit before final confirmation. For an explicit command without the wizard, use `bash -s -- install --yes` after the pipe.
To select a Preview or another tag:
```bash
@@ -38,7 +40,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
Replace the example with an existing device release tag. An explicit tag selects the engine and package from that Release and works without a stable release. Without a tag, the latest stable release is selected. Enter the sudo password when prompted.
Explicit `install` and `update` commands need no second confirmation. The interactive action menu and uninstall still ask for `y` and Enter. If sudo needs a password in a single remote `ssh` command, allocate a terminal with `ssh -t`; `--yes` skips only Framely's confirmation, not sudo authentication.
Explicit `install` and `update` commands retain their behavior. In the wizard, direct GitHub access disables automatic mirror fallback for that run; leaving the mirror address blank selects `https://gh-proxy.com`. If sudo needs a password in a single remote `ssh` command, allocate a terminal with `ssh -t`; `--yes` skips only Framely's confirmation, not sudo authentication.
To route the script download and all later GitHub requests through a chosen HTTPS URL prefix, use `--github-proxy`:
@@ -58,6 +60,8 @@ python3 bootstrap.py install --archive "./framely-<version-and-build>-offline-li
Angle brackets are placeholders; substitute the actual filename. The engine verifies, stages and extracts under `/home`, then checks internal hashes, avoiding the device's limited `/tmp`.
To walk through the interactive installer with a staged archive, place `install.sh`, `bootstrap.py`, the archive, and its `SHA256SUMS` in one directory, then run `bash install.sh --local-bundle /absolute/path/to/directory`. The wizard still checks the selected channel's latest release tag; the local archive must belong to that tag. After confirmation, the normal engine verifies and installs the complete archive.
## Release packages and CEF
Each device release provides three archives:
@@ -109,6 +113,8 @@ sudo journalctl -u framely -u framely-session --no-pager -n 100
Repair restores accounts and services from the retained release and stops on UID conflicts. It cannot restore deleted `/home` data. Recovery after SteamOS updates still requires device validation.
Command-line installation logs use one trace ID across download and device installation. They show the release package, download route, stage duration, safe error code, and next action. When reporting a failure, include the trace ID, failing stage, and relevant output. Review the output for passwords, tokens, or personal information printed by other programs before sharing it. Framely hides custom proxy addresses and parameterized download URLs in its own logs.
## Uninstall
Use the desktop maintenance page or run:
+6
View File
@@ -182,3 +182,9 @@ Framely 在自己的私有 Devkit 入口目录记录生成图片的内容摘要
Android 输入保护层清零后台输入,并使用现有输入及震动开关控制游戏请求的力反馈。控制租约到期后停止输入和震动,避免会话辅助进程退出后继续响应。直通震动直接交给 Steam 原设备;游戏本身必须发出手柄震动请求。桥接专用的强度及持续时间限制不适用于直通。
只挂载选中的设备,不暴露整个主机输入目录,不修改 SteamOS 或 Lepton 安装文件。设备节点重建或 Steam 手柄归属变化后停止输入;重新连接后需关闭并重启该容器,不会静默切换到其他设备或重启游戏。日志位于 `~/.local/share/framely/apk-manager/logs/gamepad.log`,不记录按键事件。
## 应用内更新
应用自行下载 APK 并请求安装时,Framely 会保留更新文件并直接弹出更新安装确认窗口。核对版本、显示方式及 Quest 转换选项,再确认更新;无需先进入管理面板。取消后仍可在 APK 管理页的“应用内更新”中再次审核。更新继续使用现有审核、容器备份及安装流程,保留数据;签名不匹配或降级不会自动卸载重装。取消审核会保留下载包,选择“忽略”才删除该待处理副本。
存储空间不足时,原应用保持不变,页面会提示所需与可用空间;释放空间或调整存储位置后重试。此能力在容器下次启动后生效。目前支持完整的单 APK 更新;拆分 APK、多包和 staged Session 会提示不支持,不会只安装其中一部分。特权应用静默调用包管理器的安装不在接管范围内。
+14 -2
View File
@@ -24,7 +24,15 @@ Linux 用户可在安装器解压目录执行 `bash install-desktop-entry.sh`,
## 方式二:在 Frame 的 SSH 终端安装
以下命令在 **Frame 上** 执行。默认安装最新正式版:
以下命令在 **Frame 上** 执行。
不带参数时会打开交互向导,自动识别安装或更新,选择正式版/测试版及下载线路;在最终确认前可返回或退出:
```bash
curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash
```
需要跳过向导时,继续使用明确的命令:
```bash
curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes
@@ -38,7 +46,7 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
将示例标签换成 Releases 中实际存在的本体标签。指定标签时从该 Release 下载引擎和包,不要求已有正式版;不指定标签时使用最新正式版。终端会按需请求 sudo 密码。
明确指定 `install` 或 `update` 时无需再次确认。使用交互菜单或卸载时,脚本会询问是否继续,需要输入 `y` 并回车。如果通过一条 `ssh` 远程命令执行且 sudo 需要密码,请用 `ssh -t` 分配交互终端;`--yes` 仅跳过 Framely 的确认,不会跳过 sudo 密码。
明确指定 `install` 或 `update` 时仍按原方式执行。交互向导从终端读取选项,选择直连时本次运行不会自动切换镜像;选择镜像并将地址留空时使用 `https://gh-proxy.com`。如果通过一条 `ssh` 远程命令执行且 sudo 需要密码,请用 `ssh -t` 分配交互终端;`--yes` 仅跳过 Framely 的确认,不会跳过 sudo 密码。
使用 `--github-proxy` 可通过指定的 HTTPS 地址代理入口脚本及后续所有 GitHub 下载。例如:
@@ -58,6 +66,8 @@ python3 bootstrap.py install --archive "./framely-<版本及构建号>-offline-l
尖括号是占位符,执行前替换为真实文件名。引擎校验后在 `/home` 暂存、解压并检查包内哈希,避免耗尽设备的 `/tmp`。
若要用已暂存的完整安装包测试交互向导,将 `install.sh`、`bootstrap.py`、安装包和对应的 `SHA256SUMS` 放在同一目录,运行 `bash install.sh --local-bundle /目录的绝对路径`。向导仍会查询所选渠道的最新标签,本地包必须属于该标签;最终确认后由正常安装引擎校验并安装整包。
## 发行包与 CEF
每个本体 Release 提供三种压缩包:
@@ -109,6 +119,8 @@ sudo journalctl -u framely -u framely-session --no-pager -n 100
修复会使用已保留的发行恢复账号及服务;UID 冲突时停止。它不能找回已删除的 `/home` 数据,SteamOS 更新后的恢复仍需实机验证。
命令行安装日志使用同一个追踪编号记录开始时间、版本、发行包、下载线路、阶段耗时与安全错误码。下载时会持续显示已下载量;失败时会给出原因和下一步。反馈安装问题时,请提供追踪编号、出错阶段和相关输出;发送前仍应检查并移除终端中其他程序可能打印的密码、Token 或个人信息。安装器会隐藏自定义代理地址及带参数的下载 URL。
## 卸载
桌面安装器“安装与维护”提供卸载,也可直接执行:
+231 -26
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Public bootstrap: curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash
set -euo pipefail
export FRAMELY_INSTALL_TRACE_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$"
repo=${FRAMELY_REPOSITORY:-SteamFramelyHomebrew/framely}
[[ $repo =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]] || { echo 'Invalid repository.' >&2; exit 1; }
command -v python3 >/dev/null || { echo 'Python 3 is required.' >&2; exit 1; }
@@ -9,6 +10,234 @@ trap 'rm -rf -- "$work"' EXIT
release_path=latest/download
args=("$@")
github_proxy=${FRAMELY_GITHUB_PROXY:-}
local_bundle=''
if (( $# == 2 )) && [[ $1 == --local-bundle ]]; then
local_bundle=$2
[[ $local_bundle == /* && -f $local_bundle/bootstrap.py && -f $local_bundle/SHA256SUMS ]] || {
echo 'Framely: --local-bundle needs an absolute directory containing bootstrap.py and SHA256SUMS.' >&2
exit 1
}
args=()
fi
fetch_engine() {
if [[ -n $local_bundle ]]; then
cp -- "$local_bundle/bootstrap.py" "$work/bootstrap.py"
return
fi
local bootstrap_url="https://github.com/$repo/releases/$release_path/bootstrap.py"
if [[ -n $github_proxy ]]; then
echo "Framely[$FRAMELY_INSTALL_TRACE_ID]: Fetching installer engine through configured GitHub proxy (address hidden)."
if status=$(curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --write-out '%{http_code}' --output "$work/bootstrap.py" "$github_proxy/$bootstrap_url"); then
:
else
code=$?
echo "Framely[$FRAMELY_INSTALL_TRACE_ID]: Failed to download the installer engine through the configured GitHub proxy (curl $code, HTTP ${status:-000}). Check the proxy connection or use another trusted proxy, then retry." >&2
return 1
fi
return 0
fi
echo "Framely[$FRAMELY_INSTALL_TRACE_ID]: Fetching installer engine from GitHub."
if status=$(curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --dump-header "$work/headers" --write-out '%{http_code}' --output "$work/bootstrap.py" "$bootstrap_url"); then
:
else
code=$?
retry=false
case "$code" in 5|6|7|18|28|35|52|55|56) retry=true;; esac
if [[ $code == 22 ]]; then
case "$status" in 408|429|5??) retry=true;; 403)
if grep -Eiq '^(x-ratelimit-remaining:[[:space:]]*0[[:space:]]*$|retry-after:)' "$work/headers"; then retry=true; fi;;
esac
fi
if [[ ${FRAMELY_GITHUB_AUTO_FALLBACK:-1} == 0 || $retry != true ]]; then
echo "Framely[$FRAMELY_INSTALL_TRACE_ID]: Failed to download the installer engine from GitHub (curl $code, HTTP $status). Check the network, release version and proxy settings, then retry." >&2; return 1
fi
echo "Framely[$FRAMELY_INSTALL_TRACE_ID]: GitHub request failed (curl $code, HTTP $status); trying gh-proxy.com once." >&2
if ! curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --output "$work/bootstrap.py" "https://gh-proxy.com/$bootstrap_url"; then
echo "Framely[$FRAMELY_INSTALL_TRACE_ID]: Failed to download the installer engine from GitHub (curl $code, HTTP $status) and gh-proxy.com. Check the network or specify another trusted --github-proxy, then retry." >&2; return 1
fi
fi
}
if (( ${#args[@]} == 0 )); then
if ! exec 3<>/dev/tty; then
echo 'Framely: The interactive installer needs a terminal. Run it in a Frame terminal, use an explicit command such as install --yes, or add -t to SSH.' >&2
exit 1
fi
state=/home/.framely/state
[[ -L $state/current || ! -L /var/lib/framely/current ]] || state=/var/lib/framely
if [[ -L $state/current ]]; then
link=$(readlink "$state/current")
if [[ ! $link =~ ^releases/[a-zA-Z0-9.+-]+$ || ! -f $state/current/VERSION || ! -f $state/current/uninstall.sh ]]; then
echo 'Framely: Installation records are incomplete. Run /home/.framely/repair.sh, then try again.' >&3
exit 1
fi
installed_version=$(<"$state/current/VERSION")
if [[ ! $installed_version =~ ^[a-zA-Z0-9][a-zA-Z0-9.+-]*$ ]]; then
echo 'Framely: The installed version record is invalid. Repair the installation, then try again.' >&3
exit 1
fi
installed=true
elif [[ -e $state/current || -d /home/.framely/releases && -n $(ls -A /home/.framely/releases 2>/dev/null) ]]; then
echo 'Framely: Installation records are incomplete. Run /home/.framely/repair.sh, then try again.' >&3
exit 1
else
installed=false
installed_version='Not installed'
fi
echo >&3
echo ' FRAMELY INSTALL & MAINTAIN' >&3
echo ' ──────────────────────────────' >&3
echo " Current version: $installed_version" >&3
[[ -z $local_bundle ]] || echo ' Package source: local bundle' >&3
echo >&3
step=menu
while :; do
case $step in
menu)
echo " 1) $([[ $installed == true ]] && echo 'Update Framely' || echo 'Install Framely')" >&3
[[ $installed == true ]] && echo ' 2) Uninstall Framely' >&3
echo ' q) Quit' >&3
printf ' Select: ' >&3
IFS= read -r choice <&3 || exit 1
case $choice in
1) [[ $installed == true ]] && wizard_action=update || wizard_action=install; step=channel ;;
2) [[ $installed == true ]] && step=uninstall_confirm ;;
q|Q) echo 'Framely: Exited.' >&3; exit 0 ;;
*) echo ' Enter one of the listed options.' >&3 ;;
esac ;;
uninstall_confirm)
echo ' Uninstall disables and removes all plugins, then removes Framely. Saved data is kept.' >&3
printf ' 1) Start b) Back q) Quit: ' >&3
IFS= read -r choice <&3 || exit 1
case $choice in
1)
cp -- "$state/current/uninstall.sh" "$work/uninstall.sh"
if [[ ! -e /var/lib/framely/current && -f /home/.framely/repair.sh ]]; then
if (( EUID == 0 )); then bash /home/.framely/repair.sh steamos; else sudo bash /home/.framely/repair.sh steamos; fi
fi
if (( EUID == 0 )); then bash "$work/uninstall.sh"; else sudo bash "$work/uninstall.sh"; fi
exit ;;
b|B) step=menu ;;
q|Q) exit 0 ;;
esac ;;
channel)
echo >&3
echo ' Release channel' >&3
echo ' 1) Stable 2) Testing (includes stable releases)' >&3
printf ' Select (b: back, q: quit): ' >&3
IFS= read -r choice <&3 || exit 1
case $choice in
1) channel=stable; step=mirror ;;
2) channel=testing; step=mirror ;;
b|B) step=menu ;;
q|Q) exit 0 ;;
*) echo ' Enter one of the listed options.' >&3 ;;
esac ;;
mirror)
echo >&3
echo ' Download route' >&3
echo ' 1) Direct from GitHub 2) Use a mirror' >&3
printf ' Select (b: back, q: quit): ' >&3
IFS= read -r choice <&3 || exit 1
case $choice in
1) github_proxy=''; export FRAMELY_GITHUB_PROXY=''; export FRAMELY_GITHUB_AUTO_FALLBACK=0; step=lookup ;;
2) step=mirror_address ;;
b|B) step=channel ;;
q|Q) exit 0 ;;
*) echo ' Enter one of the listed options.' >&3 ;;
esac ;;
mirror_address)
echo >&3
printf ' Mirror HTTPS base URL (blank: https://gh-proxy.com; b: back): ' >&3
IFS= read -r choice <&3 || exit 1
case $choice in
b|B) step=mirror ;;
q|Q) exit 0 ;;
*) github_proxy=${choice:-https://gh-proxy.com}; step=lookup ;;
esac ;;
lookup)
if [[ -n $github_proxy ]]; then
if [[ ! $github_proxy =~ ^https://[a-zA-Z0-9.-]+(:[0-9]{1,5})?(/[a-zA-Z0-9._~/-]*)?$ || /$github_proxy/ == *'/../'* || /$github_proxy/ == *'/./'* ]]; then
echo ' Invalid address. Enter an HTTPS mirror base URL without query parameters.' >&3
step=mirror_address
continue
fi
github_proxy=${github_proxy%/}
export FRAMELY_GITHUB_PROXY=$github_proxy
unset FRAMELY_GITHUB_AUTO_FALLBACK
fi
echo ' Checking available releases...' >&3
# The current stable release supplies the resolver; the chosen tag
# supplies the actual engine used after final confirmation.
release_path=latest/download
if ! fetch_engine; then
echo ' Could not download the release checker. Check your connection or mirror, then retry.' >&3
step=lookup_error
continue
fi
if ! selection=$(python3 "$work/bootstrap.py" --repo "$repo" --resolve-release "$channel" --selection-action "$wizard_action"); then
echo ' Could not find an installable release. Check the connection, mirror, and release assets, then retry.' >&3
step=lookup_error
continue
fi
selected_tag=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["tag"])' "$selection")
selected_size=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["size"])' "$selection")
if [[ -n $local_bundle ]]; then
local_archives=()
for candidate in "$local_bundle"/framely-*-linux-arm64.tar.gz; do
[[ -f $candidate ]] || continue
if [[ $wizard_action == install && $candidate == *-offline-linux-arm64.tar.gz ]] ||
[[ $wizard_action == update && $candidate != *-offline-linux-arm64.tar.gz ]]; then
local_archives+=("$candidate")
fi
done
if (( ${#local_archives[@]} != 1 )); then
echo ' The local bundle must contain exactly one archive for this operation.' >&3
step=lookup_error
continue
fi
local_archive=${local_archives[0]}
local_name=${local_archive##*/}
local_suffix='-linux-arm64.tar.gz'
[[ $wizard_action != install ]] || local_suffix='-offline-linux-arm64.tar.gz'
if [[ ! $local_name =~ ^framely-${selected_tag#v}-[a-f0-9]{12}${local_suffix//./\.}$ ]]; then
echo ' The local archive does not match the selected release tag.' >&3
step=lookup_error
continue
fi
selected_size=$(stat -c %s -- "$local_archive")
fi
step=confirm ;;
lookup_error)
printf ' r) Retry b) Back to download route q) Quit: ' >&3
IFS= read -r choice <&3 || exit 1
case $choice in
r|R) step=lookup ;;
b|B) step=mirror ;;
q|Q) exit 0 ;;
esac ;;
confirm)
echo >&3
echo ' Ready to proceed' >&3
echo " Action: $([[ $wizard_action == update ]] && echo Update || echo Install)" >&3
echo " Current version: $installed_version" >&3
echo " Target release: $selected_tag" >&3
echo " Package size: about $(( (selected_size + 1048575) / 1048576 )) MiB" >&3
if [[ -n $local_bundle ]]; then
echo ' Package source: Local staged archive (SHA256 checked before installation)' >&3
echo " Release lookup: $([[ -n $github_proxy ]] && echo 'GitHub mirror (address hidden)' || echo 'Direct from GitHub')" >&3
else
echo " Download route: $([[ -n $github_proxy ]] && echo 'GitHub mirror (address hidden)' || echo 'Direct from GitHub')" >&3
fi
printf ' 1) Start b) Back q) Quit: ' >&3
IFS= read -r choice <&3 || exit 1
case $choice in
1) args=("$wizard_action" --version "$selected_tag" --yes); [[ -n $github_proxy ]] && args+=(--github-proxy "$github_proxy"); [[ -z $local_bundle ]] || args+=(--archive "$local_archive" --checksums "$local_bundle/SHA256SUMS"); release_path="download/$selected_tag"; break ;;
b|B) [[ -n $github_proxy ]] && step=mirror_address || step=mirror ;;
q|Q) exit 0 ;;
esac ;;
esac
done
fi
for ((i=0; i<${#args[@]}; i++)); do
case ${args[i]} in
--version)
@@ -39,29 +268,5 @@ if [[ -n $github_proxy ]]; then
github_proxy=${github_proxy%/}
export FRAMELY_GITHUB_PROXY=$github_proxy
fi
bootstrap_url="https://github.com/$repo/releases/$release_path/bootstrap.py"
if [[ -n $github_proxy ]]; then
curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --output "$work/bootstrap.py" "$github_proxy/$bootstrap_url"
python3 "$work/bootstrap.py" --repo "$repo" "$@"
exit
fi
if status=$(curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --dump-header "$work/headers" --write-out '%{http_code}' --output "$work/bootstrap.py" "$bootstrap_url"); then
:
else
code=$?
retry=false
case "$code" in 5|6|7|18|28|35|52|55|56) retry=true;; esac
if [[ $code == 22 ]]; then
case "$status" in 408|429|5??) retry=true;; 403)
if grep -Eiq '^(x-ratelimit-remaining:[[:space:]]*0[[:space:]]*$|retry-after:)' "$work/headers"; then retry=true; fi;;
esac
fi
if [[ ${FRAMELY_GITHUB_AUTO_FALLBACK:-1} == 0 || $retry != true ]]; then
echo "Bootstrap primary route failed (curl $code, HTTP $status); not eligible for automatic proxy." >&2; exit 1
fi
echo 'GitHub connection failed; trying gh-proxy.com once' >&2
if ! curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --output "$work/bootstrap.py" "https://gh-proxy.com/$bootstrap_url"; then
echo "Bootstrap primary route failed (curl $code, HTTP $status); gh-proxy.com fallback also failed." >&2; exit 1
fi
fi
python3 "$work/bootstrap.py" --repo "$repo" "$@"
fetch_engine
python3 "$work/bootstrap.py" --repo "$repo" "${args[@]}"
+7 -1
View File
@@ -18,11 +18,17 @@
Its version and checksum are pinned; releases include its source and license.
CI links against the FFmpeg 7 development API to match SteamOS; that build
dependency is pinned but its FFmpeg libraries are not distributed.
- [gst-plugins-good](https://gstreamer.freedesktop.org/modules/gst-plugins-good.html),
LGPL-2.1-or-later, supplies the bundled level, PulseAudio, video filter,
ISO MP4 and HTTP source plugins required by the receive pipelines.
The version and checksum are pinned; source, build recipe and license ship with releases.
- Avahi core (LGPL) is linked as a system library to publish only the receiver’s
AirPlay/RAOP records without changing system Avahi publishing settings.
- GStreamer and GUPnP are linked as system libraries (LGPL). GStreamer performs
decoding, audio playback, rotation and pixel aspect correction; GUPnP provides
UPnP discovery, SOAP and event subscriptions. FFmpeg is invoked as the installed
system executable for SteamVR capture, software encoding and audio muxing.
These system components are not copied into Framely packages.
The GStreamer core, GUPnP and their system libraries are not copied into Framely packages.
- `native/json.hpp` is the existing nlohmann/json dependency (MIT), included with
the main Framely release notices.
+5
View File
@@ -18,5 +18,10 @@
"version": "7.0",
"url": "https://ffmpeg.org/releases/ffmpeg-7.0.tar.xz",
"sha256": "4426a94dd2c814945456600c8adfc402bee65ec14a70e8c531ec9a2cd651da7b"
},
"gst-plugins-good": {
"version": "1.24.13",
"url": "https://gstreamer.freedesktop.org/src/gst-plugins-good/gst-plugins-good-1.24.13.tar.xz",
"sha256": "574ac6f9fd84b32eb04e80572391d6762df6f9802a47bc0386cd6cc48c14d08b"
}
}
+152
View File
@@ -0,0 +1,152 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Publish only UxPlay's records, independently of the system Avahi D-Bus policy.
#include "airplay_mdns.h"
#include <avahi-core/core.h>
#include <avahi-core/publish.h>
#include <avahi-core/log.h>
#include <avahi-common/alternative.h>
#include <avahi-common/error.h>
#include <avahi-common/malloc.h>
#include <avahi-common/strlst.h>
#include <avahi-common/thread-watch.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
namespace {
struct Service {
std::string name, type;
uint16_t port;
AvahiStringList* txt = nullptr;
AvahiSEntryGroup* group = nullptr;
~Service() {
if (group) avahi_s_entry_group_free(group);
avahi_string_list_free(txt);
}
};
class Publisher {
AvahiThreadedPoll* poll = nullptr;
AvahiServer* server = nullptr;
std::vector<std::unique_ptr<Service>> services;
static void group_state(AvahiServer* server, AvahiSEntryGroup*, AvahiEntryGroupState state, void* data) {
auto* service = static_cast<Service*>(data);
if (state == AVAHI_ENTRY_GROUP_ESTABLISHED)
std::fprintf(stderr, "AirPlay mDNS: published %s\n", service->type.c_str());
else if (state == AVAHI_ENTRY_GROUP_COLLISION || state == AVAHI_ENTRY_GROUP_FAILURE) {
std::fprintf(stderr, "AirPlay mDNS: cannot publish %s: %s\n", service->type.c_str(),
state == AVAHI_ENTRY_GROUP_COLLISION ? "service name conflict" : avahi_strerror(avahi_server_errno(server)));
std::exit(1);
}
}
void publish(Service& service) {
if (!service.group) service.group = avahi_s_entry_group_new(server, group_state, &service);
if (!service.group) throw std::runtime_error(avahi_strerror(avahi_server_errno(server)));
if (!avahi_s_entry_group_is_empty(service.group)) return;
int error = avahi_server_add_service_strlst(server, service.group, AVAHI_IF_UNSPEC,
AVAHI_PROTO_UNSPEC, AvahiPublishFlags(0), service.name.c_str(), service.type.c_str(),
nullptr, nullptr, service.port, service.txt);
if (error >= 0) error = avahi_s_entry_group_commit(service.group);
if (error < 0) throw std::runtime_error(avahi_strerror(error));
}
static void server_state(AvahiServer* server, AvahiServerState state, void* data) {
auto* self = static_cast<Publisher*>(data);
// avahi_server_new can invoke us before it returns.
self->server = server;
try {
if (state == AVAHI_SERVER_RUNNING) {
for (auto& service : self->services) self->publish(*service);
} else if (state == AVAHI_SERVER_REGISTERING || state == AVAHI_SERVER_COLLISION) {
for (auto& service : self->services)
if (service->group) avahi_s_entry_group_reset(service->group);
if (state == AVAHI_SERVER_COLLISION) {
char* name = avahi_alternative_host_name(avahi_server_get_host_name(server));
int error = avahi_server_set_host_name(server, name);
avahi_free(name);
if (error < 0) throw std::runtime_error(avahi_strerror(error));
}
} else if (state == AVAHI_SERVER_FAILURE) {
throw std::runtime_error(avahi_strerror(avahi_server_errno(server)));
}
} catch (const std::exception& error) {
std::fprintf(stderr, "AirPlay mDNS: %s\n", error.what());
std::exit(1);
}
}
public:
Publisher() {
avahi_set_log_function([](AvahiLogLevel level, const char* message) {
if (level <= AVAHI_LOG_WARN) std::fprintf(stderr, "AirPlay mDNS: %s\n", message);
});
poll = avahi_threaded_poll_new();
if (!poll) throw std::runtime_error("cannot create mDNS poll");
AvahiServerConfig config;
avahi_server_config_init(&config);
// Share the machine hostname with other local mDNS stacks, avoiding
// conflicting reverse address records for the same network interfaces.
config.publish_hinfo = config.publish_workstation = config.publish_domain = 0;
config.enable_wide_area = 0;
int error = 0;
server = avahi_server_new(avahi_threaded_poll_get(poll), &config, server_state, this, &error);
avahi_server_config_free(&config);
if (!server || avahi_threaded_poll_start(poll) < 0) {
if (server) avahi_server_free(server);
avahi_threaded_poll_free(poll);
throw std::runtime_error(error ? avahi_strerror(error) : "cannot start mDNS poll");
}
}
~Publisher() {
avahi_threaded_poll_stop(poll);
services.clear();
avahi_server_free(server);
avahi_threaded_poll_free(poll);
}
Service* add(const char* name, const char* type, uint16_t port, const void* txt, uint16_t size) {
std::unique_ptr<Service> service(new Service);
service->name = name; service->type = type; service->port = ntohs(port);
if (avahi_string_list_parse(txt, size, &service->txt) < 0)
throw std::runtime_error("invalid mDNS TXT record");
avahi_threaded_poll_lock(poll);
try {
if (avahi_server_get_state(server) == AVAHI_SERVER_RUNNING) publish(*service);
auto* result = service.get();
services.push_back(std::move(service));
avahi_threaded_poll_unlock(poll);
return result;
} catch (...) {
// Free any registered group while its poll is still locked.
service.reset();
avahi_threaded_poll_unlock(poll);
throw;
}
}
void remove(Service* service) {
avahi_threaded_poll_lock(poll);
services.erase(std::remove_if(services.begin(), services.end(),
[service](const std::unique_ptr<Service>& item) { return item.get() == service; }), services.end());
avahi_threaded_poll_unlock(poll);
}
};
Publisher& publisher() { static Publisher instance; return instance; }
}
extern "C" DNSServiceErrorType framely_mdns_register(DNSServiceRef* result, DNSServiceFlags flags,
uint32_t interface_index, const char* name, const char* type, const char* domain, const char* host,
uint16_t port, uint16_t size, const void* txt, DNSServiceRegisterReply callback, void*) {
if (!result || !name || !type || flags || interface_index || domain || host || callback)
return kDNSServiceErr_BadParam;
try {
*result = reinterpret_cast<DNSServiceRef>(publisher().add(name, type, port, txt, size));
return kDNSServiceErr_NoError;
} catch (const std::exception& error) {
std::fprintf(stderr, "AirPlay mDNS initialization failed: %s\n", error.what());
return kDNSServiceErr_Unknown;
}
}
extern "C" void framely_mdns_deallocate(DNSServiceRef service) {
publisher().remove(reinterpret_cast<Service*>(service));
}
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <dns_sd.h>
#ifdef __cplusplus
extern "C" {
#endif
DNSServiceErrorType framely_mdns_register(DNSServiceRef*, DNSServiceFlags, uint32_t,
const char*, const char*, const char*, const char*, uint16_t, uint16_t,
const void*, DNSServiceRegisterReply, void*);
void framely_mdns_deallocate(DNSServiceRef);
#ifdef __cplusplus
}
#endif
+16 -12
View File
@@ -265,17 +265,21 @@ int main(int argc,char** argv) {
try {
if (argc<4) throw std::runtime_error("receiver DIRECTORY XMLDIR RECEIVE");
directory=argv[1]; xml_directory=argv[2]; receive_enabled=!strcmp(argv[3],"1"); gst_init(&argc,&argv);
// Frame's V4L2/Vulkan decoders can return corrupted frames and block
// playback controls. Prefer the bundled FFmpeg software decoders.
for (const char* name : {"avdec_h264", "avdec_h265"}) {
auto* decoder=gst_element_factory_find(name);
if (!decoder) throw std::runtime_error(std::string("Required decoder unavailable: ")+name);
gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(decoder),GST_RANK_PRIMARY+100);
gst_object_unref(decoder);
}
for (const char* name : {"v4l2h264dec", "v4l2h265dec", "vulkanh264dec", "vulkanh265dec"}) {
auto* decoder=gst_element_factory_find(name);
if (decoder) { gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(decoder),GST_RANK_NONE); gst_object_unref(decoder); }
// Decoder policy is only relevant to local DLNA playback. AirPlay uses
// UxPlay's own pipeline; discovery/control alone needs no video decoder.
if (receive_enabled) {
// Frame's V4L2/Vulkan decoders can return corrupted frames and block
// playback controls. Prefer the bundled FFmpeg software decoders.
for (const char* name : {"avdec_h264", "avdec_h265"}) {
auto* decoder=gst_element_factory_find(name);
if (!decoder) throw std::runtime_error(std::string("Required decoder unavailable: ")+name);
gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(decoder),GST_RANK_PRIMARY+100);
gst_object_unref(decoder);
}
for (const char* name : {"v4l2h264dec", "v4l2h265dec", "vulkanh264dec", "vulkanh265dec"}) {
auto* decoder=gst_element_factory_find(name);
if (decoder) { gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(decoder),GST_RANK_NONE); gst_object_unref(decoder); }
}
}
auto* manager=gupnp_context_manager_create_full(GSSDP_UDA_VERSION_1_0,G_SOCKET_FAMILY_IPV4,0);
g_signal_connect(manager,"context-available",G_CALLBACK(context_available),nullptr);
@@ -285,5 +289,5 @@ int main(int argc,char** argv) {
for (auto* p:transports) g_object_unref(p);
for (auto* p:renderers) g_object_unref(p);
g_io_channel_unref(channel); g_object_unref(manager); g_main_loop_unref(loop); return 0;
} catch (const std::exception& e) { output({{"event","fatal"},{"error",e.what()}}); return 1; }
} catch (const std::exception& e) { std::cerr << "DLNA receiver: " << e.what() << std::endl; output({{"event","fatal"},{"error",e.what()}}); return 1; }
}
+41 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Add only lifecycle events to UxPlay; protocol/decoder code remains upstream."""
"""Adapt UxPlay lifecycle events and explicit sinks; protocol/decoder code remains upstream."""
from pathlib import Path
import sys
p=Path(sys.argv[1])/'uxplay.cpp'
@@ -123,3 +123,43 @@ if 'Framely HLS consent' not in s:
}
''',1)
renderer.write_text(s)
# Framely supplies both sinks explicitly; autodetect is not part of its pipeline.
# Keep upstream checks unchanged for all other UxPlay output modes.
audio=p.parent/'renderers/audio_renderer.c'
s=audio.read_text()
if 'framely_explicit_sinks' not in s:
assert 'static gboolean check_plugins (void)' in s
s=s.replace('static gboolean check_plugins (void)', 'static gboolean check_plugins (bool needs_autodetect)',1)
marker=' GstPlugin *plugin = NULL;'
assert marker in s
s=s.replace(marker, ' /* framely_explicit_sinks: no automatic device selection needed. */\n if (!needs_autodetect && !strcmp(needed[i], "autodetect")) continue;\n'+marker,1)
s=s.replace('bool gstreamer_init(){','bool gstreamer_init(bool needs_autodetect){',1)
s=s.replace('return (bool) check_plugins ();','return (bool) check_plugins (needs_autodetect);',1)
audio.write_text(s)
header=p.parent/'renderers/audio_renderer.h'
s=header.read_text()
if 'bool gstreamer_init();' in s:
header.write_text(s.replace('bool gstreamer_init();','bool gstreamer_init(bool needs_autodetect);',1))
s=p.read_text()
if 'if (!gstreamer_init())' in s:
s=s.replace('if (!gstreamer_init())', 'if (!gstreamer_init(!(videosink == "framelyvideosink" && audiosink == "framelyaudiosink")))',1)
p.write_text(s)
# SteamOS can disable publication in the system Avahi daemon. Use its existing
# Avahi core library inside the receiver instead; leave system policy untouched.
for name in ('airplay_mdns.h', 'airplay_mdns.cpp'):
copyfile(Path(__file__).parent/'native'/name, p.parent/'framely'/name)
dns=p.parent/'lib/dnssd.c'
s=dns.read_text()
if 'framely_mdns_register' not in s:
s=s.replace('#include <dns_sd.h>', '#include <dns_sd.h>\n#include "../framely/airplay_mdns.h"', 1)
marker=' dnssd->name_len = name_len;'
assert marker in s
s=s.replace(marker, ' if (getenv("FRAMELY_CAST_DIR")) {\n dnssd->DNSServiceRegister = &framely_mdns_register;\n dnssd->DNSServiceRefDeallocate = &framely_mdns_deallocate;\n }\n\n'+marker, 1)
dns.write_text(s)
cmake=p.parent/'lib/CMakeLists.txt'
s=cmake.read_text()
if 'framely/airplay_mdns.cpp' not in s:
s+='\ntarget_sources(airplay PRIVATE ../framely/airplay_mdns.cpp)\npkg_check_modules(FRAMELY_AVAHI REQUIRED IMPORTED_TARGET avahi-core)\ntarget_link_libraries(airplay PUBLIC PkgConfig::FRAMELY_AVAHI)\n'
cmake.write_text(s)
+164
View File
@@ -0,0 +1,164 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Android 11 helper. Runs only inside a Framely-launched container.
import android.app.*;
import android.content.*;
import android.content.pm.*;
import android.net.Uri;
import android.os.*;
import org.json.JSONObject;
import java.io.*;
import java.lang.reflect.*;
import java.util.*;
import java.util.concurrent.*;
public final class FramelyUpdates extends Binder {
private static final String DESCRIPTOR="android.app.IActivityController";
private static final long MAX_BYTES=8L*1024*1024*1024;
private static final BlockingQueue<Runnable> work=new ArrayBlockingQueue<>(8);
private static final Map<Integer,String> methods=new HashMap<>();
private static final BlockingQueue<String> forwards=new ArrayBlockingQueue<>(64);
private static final Set<String> allowed=new HashSet<>(),sessions=new HashSet<>();
private static Context context;
private static File directory;
private static void log(String event){System.out.println("Framely APK updates: "+event);}
private static void publish(JSONObject data, String id)throws Exception {
File temporary=new File(directory,id+".json.part"), ready=new File(directory,id+".json");
try(FileOutputStream f=new FileOutputStream(temporary)){f.write(data.toString().getBytes("UTF-8"));f.getFD().sync();}
if(!temporary.renameTo(ready))throw new IOException("Cannot publish update request");
}
private static void failure(String reason){
log(reason);
try {String id=UUID.randomUUID().toString().replace("-","");publish(new JSONObject().put("error",reason),id);}catch(Exception ignored){}
}
public boolean onTransact(int code,Parcel data,Parcel reply,int flags)throws RemoteException {
if(code==INTERFACE_TRANSACTION){reply.writeString(DESCRIPTOR);return true;}
String method=methods.get(code);
if(method==null)return super.onTransact(code,data,reply,flags);
data.enforceInterface(DESCRIPTOR);
boolean allow=true;
if(method.equals("activityStarting")) {
Intent i=data.readInt()!=0?Intent.CREATOR.createFromParcel(data):null;data.readString();
String action=i==null?null:i.getAction();
if(i!=null && (Intent.ACTION_INSTALL_PACKAGE.equals(action)||"application/vnd.android.package-archive".equals(i.getType()))) {
final String uri=i.getDataString();allow=false;
if(uri==null || !work.offer(()->capture(uri,false)))failure("Cannot queue APK installation request; retry from the application");
} else if("android.content.pm.action.CONFIRM_INSTALL".equals(action)) {
// ActivityController receives cloneFilter(), so Session ID extras are absent.
if(hasManagedSession()){allow=false;if(!work.offer(FramelyUpdates::captureSessions))failure("Update queue is busy; retry from the application");}
}
}
reply.writeNoException();
if(method.equals("appNotResponding")||method.equals("appEarlyNotResponding")||method.equals("systemNotResponding"))reply.writeInt(method.equals("systemNotResponding")?-1:0);
else reply.writeInt(allow?1:0);
return true;
}
private static void forward(String text) {
// Opening Steam's FIFO can block when Steam is stopped. Keep that off Binder/reader threads.
if(!forwards.offer(text))log("Steam URI forwarding queue is full");
}
private static final class ProviderInput implements AutoCloseable {
InputStream stream;Object manager;IBinder token;String authority;
public void close()throws Exception {try{if(stream!=null)stream.close();}finally{if(manager!=null)Class.forName("android.app.IActivityManager").getMethod("removeContentProviderExternal",String.class,IBinder.class).invoke(manager,authority,token);}}
}
private static ProviderInput open(Uri uri)throws Exception {
ProviderInput in=new ProviderInput();
if("file".equals(uri.getScheme())) {
File file=new File(uri.getPath()).getCanonicalFile();String path=file.getPath();
List<String> bases=new ArrayList<>(Arrays.asList("/sdcard","/storage/emulated/0","/data/media/0"));
for(String pkg:allowed){bases.add("/data/user/0/"+pkg);bases.add("/data/data/"+pkg);}
boolean owned=false;
for(String base:bases){File root=new File(base).getCanonicalFile();if(root.isDirectory() && !root.getPath().equals("/"))owned|=path.startsWith(root.getPath()+"/");}
if(!owned || !file.isFile())throw new IOException("APK file is outside application storage");
in.stream=new FileInputStream(file);return in;
}
if(!"content".equals(uri.getScheme()) || uri.getUserInfo()!=null)throw new IOException("Unsupported APK URI");
ProviderInfo info=context.getPackageManager().resolveContentProvider(uri.getAuthority(),0);
boolean downloads=info!=null && ("com.android.providers.downloads".equals(info.packageName)||"com.android.externalstorage".equals(info.packageName));
if(info==null || !(allowed.contains(info.packageName)||downloads))throw new IOException("APK provider does not belong to a managed application");
in.authority=uri.getAuthority();in.token=new Binder();
in.manager=Class.forName("android.app.ActivityManager").getMethod("getService").invoke(null);
try {
Object holder=Class.forName("android.app.IActivityManager").getMethod("getContentProviderExternal",String.class,int.class,IBinder.class,String.class).invoke(in.manager,in.authority,0,in.token,"Framely APK update");
Object provider=holder.getClass().getField("provider").get(holder);
Method m=Class.forName("android.content.IContentProvider").getMethod("openFile",String.class,String.class,Uri.class,String.class,Class.forName("android.os.ICancellationSignal"),IBinder.class);
ParcelFileDescriptor fd=(ParcelFileDescriptor)m.invoke(provider,null,null,uri,"r",null,null);
in.stream=new ParcelFileDescriptor.AutoCloseInputStream(fd);return in;
}catch(Exception e){in.close();throw e;}
}
private static boolean stage(InputStream in,String id)throws Exception {
File partial=new File(directory,id+".apk.part"),apk=new File(directory,id+".apk");
try {
File[] pending=directory.listFiles(f->f.getName().endsWith(".apk")||f.getName().endsWith(".apk.part"));
long retained=0;if(pending!=null)for(File f:pending)retained+=f.length();
if(pending!=null && (pending.length>=8 || retained>=MAX_BYTES))throw new IOException("Update queue storage is full");
try(FileOutputStream f=new FileOutputStream(partial)){
byte[] bytes=new byte[262144];long total=0;int n;
while((n=in.read(bytes))!=-1){total+=n;if(total+retained>MAX_BYTES||directory.getUsableSpace()<256*1024*1024)throw new IOException("Insufficient space for APK extraction");f.write(bytes,0,n);}f.getFD().sync();
}
PackageInfo info=context.getPackageManager().getPackageArchiveInfo(partial.getPath(),0);
if(info==null || !allowed.contains(info.packageName))return false;
PackageInfo installed=context.getPackageManager().getPackageInfo(info.packageName,0);
if(info.getLongVersionCode()<installed.getLongVersionCode())throw new IOException("Application requested an APK downgrade");
if(!partial.renameTo(apk))throw new IOException("Cannot save extracted APK");
publish(new JSONObject().put("package",info.packageName).put("bytes",apk.length()),id);
log("APK captured request="+id+" bytes="+apk.length());return true;
}finally{partial.delete();}
}
private static void capture(String text,boolean view){
String id=UUID.randomUUID().toString().replace("-","");
try(ProviderInput in=open(Uri.parse(text))){
if(!stage(in.stream,id)){if(view)forward("steam://openurl/"+text);else failure("APK is not an update to an installed application");}
}catch(Exception e){if(view)forward("steam://openurl/"+text);else failure("Cannot extract application update; retry or select its APK in Framely");}
}
private static boolean hasManagedSession(){
long identity=Binder.clearCallingIdentity();
try {
for(PackageInstaller.SessionInfo info:context.getPackageManager().getPackageInstaller().getAllSessions())
if(info.isSealed() && info.isCommitted() && allowed.contains(info.getInstallerPackageName()))return true;
return false;
}catch(Exception e){failure("Cannot inspect installation session; original installer retained");return false;}
finally{Binder.restoreCallingIdentity(identity);}
}
private static void captureSessions(){
try {
PackageInstaller installer=context.getPackageManager().getPackageInstaller();
for(PackageInstaller.SessionInfo info:installer.getAllSessions()) {
String id=Integer.toString(info.getSessionId());
if(!info.isSealed()||!info.isCommitted()||!allowed.contains(info.getInstallerPackageName())||!sessions.add(id))continue;
if(info.isMultiPackage()||info.isStaged()||info.hasParentSessionId()){failure("Multi-package and staged updates are not supported; select a complete APK in Framely");continue;}
File stage=new File("/data/app/vmdl"+id+".tmp");File[] apks=stage.listFiles(f->f.isFile()&&f.getName().endsWith(".apk"));
if(apks==null||apks.length!=1){failure("Split APK update cannot be installed as a single APK; select a complete APK in Framely");continue;}
String key=UUID.randomUUID().toString().replace("-","");
try(InputStream stream=new FileInputStream(apks[0])) {
if(!stage(stream,key)){failure("Session APK does not match an installed application");continue;}
}
// Only abandon the original session after retaining the complete APK.
installer.abandonSession(info.getSessionId());
}
}catch(Exception e){failure("Cannot extract pending installation session; retry from the application");}
}
public static void main(String[] args)throws Exception {
if(args.length!=2)throw new IllegalArgumentException("Expected private update directory and package list");
directory=new File(args[0]);
try(BufferedReader r=new BufferedReader(new FileReader(args[1]))){String p;while((p=r.readLine())!=null)if(p.matches("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z0-9_]+)+"))allowed.add(p);}
Looper.prepareMainLooper();Class<?> thread=Class.forName("android.app.ActivityThread");Object at=thread.getMethod("systemMain").invoke(null);context=(Context)thread.getMethod("getSystemContext").invoke(at);
Class<?> stub=Class.forName("android.app.IActivityController$Stub");
for(Field f:stub.getDeclaredFields())if(f.getName().startsWith("TRANSACTION_")){f.setAccessible(true);methods.put(f.getInt(null),f.getName().substring(12));}
FramelyUpdates gate=new FramelyUpdates();Class<?> iface=Class.forName("android.app.IActivityController");
Object proxy=Proxy.newProxyInstance(iface.getClassLoader(),new Class[]{iface},(p,m,a)->m.getName().equals("asBinder")?gate:null);
Object manager=Class.forName("android.app.ActivityManager").getMethod("getService").invoke(null);
Class.forName("android.app.IActivityManager").getMethod("setActivityController",iface,boolean.class).invoke(manager,proxy,false);
new Thread(()->{while(true)try{String line=forwards.take();try(FileOutputStream f=new FileOutputStream("/lepton/framely-original.pipe")){f.write((line+"\n").getBytes("UTF-8"));}}catch(Exception e){log("Steam URI forwarding failed");}},"steam-forward").start();
new Thread(()->{while(true)try{work.take().run();}catch(Exception e){failure("Update extraction worker failed");}},"apk-extract").start();
new Thread(()->{while(true)try(BufferedReader reader=new BufferedReader(new FileReader("/lepton/steam.pipe"))){
String line;while((line=reader.readLine())!=null){
if(line.length()>16384){log("Oversized URI ignored");continue;}
if(line.startsWith("steam://openurl/content://")||line.startsWith("steam://openurl/file://")){
final String uri=line.substring(16);if(!work.offer(()->capture(uri,true)))failure("Update extraction queue is full; retry from the application");
}else forward(line);
}
}catch(Exception e){log("Steam URI pipe unavailable");try{Thread.sleep(1000);}catch(InterruptedException ignored){}}},"apk-uri").start();
new File(directory,"ready").createNewFile();log("ready");
Class.forName("com.android.internal.os.BinderInternal").getMethod("joinThreadPool").invoke(null);
}
}
+19
View File
@@ -28,3 +28,22 @@ Binder 调用编号。
执行 `bash tools/build-android-window.sh` 可重新生成,需要 JDK 和 curl。
构建使用 Google D8 8.3.37,并校验固定 SHA-256;安装包不包含 D8。
## APK update interception
`FramelyUpdates.java` runs as Android root inside Framely-launched Lepton containers.
The private launch adapter mounts the helper and a package allowlist read-only,
redirects Lepton's VIEW pipe to a bounded reader, and retains the original pipe
for unrelated links. Android's activity controller intercepts installer intents
and pending PackageInstaller confirmations. Complete, single-package APKs are
retained in a private host queue; review and installation use Framely's existing
APK manager. No system image, host root daemon or runtime JDK is required.
The helper targets Android 11's Binder APIs. If startup fails, its supervisor
forwards URI requests through the original Steam pipe. Split APK, multi-package
and staged sessions are reported as unsupported rather than partially installed.
The controller does not intercept privileged silent package-manager installations.
Source URIs, credentials and tokens are never written to the queue or its logs.
Rebuild the embedded DEX with `bash tools/build-android-updates.sh`. Android SDK
platform 30 and D8 are downloaded with pinned SHA-256 checksums at build time only.
+43
View File
@@ -0,0 +1,43 @@
# This hook runs only in Framely's private Lepton adapter, never in its image.
if [[ -n "${FRAMELY_UPDATE_DIR:-}" ]]; then
if declare -F setup_podman_mounts >/dev/null && declare -F generate_zygote_launch_rc >/dev/null; then
eval "$(declare -f setup_podman_mounts | sed '1s/setup_podman_mounts/framely_update_original_mounts/')"
function setup_podman_mounts() {
framely_update_original_mounts "$@" || return
# Replace the single original entry, retaining a forwarding endpoint.
local i found=false
for ((i=0; i<${#PODMAN_CMDLINE[@]}; i++)); do
if [[ "${PODMAN_CMDLINE[i]}" == *,destination=/lepton/steam.pipe,* ]]; then
PODMAN_CMDLINE[i]="${PODMAN_CMDLINE[i]/,destination=\/lepton\/steam.pipe,/,destination=\/lepton\/framely-original.pipe,}"
found=true
elif [[ "${PODMAN_CMDLINE[i]}" == *:/lepton/steam.pipe:* ]]; then
PODMAN_CMDLINE[i]="${PODMAN_CMDLINE[i]/:\/lepton\/steam.pipe:/:\/lepton\/framely-original.pipe:}"
found=true
fi
done
[[ "$found" == true ]] || { echo 'Framely: unsupported Steam pipe mount' >&2; return 64; }
podman_mount_entry "$FRAMELY_UPDATE_DIR" /framely-apk-updates rw
podman_mount_entry "${FRAMELY_UPDATE_PIPE:?}" /lepton/steam.pipe rw
podman_mount_entry "${FRAMELY_UPDATE_DEX:?}" /vendor/share/framely-updates.dex ro
podman_mount_entry "${FRAMELY_UPDATE_SCRIPT:?}" /vendor/share/framely-updates.sh ro
podman_mount_entry "${FRAMELY_UPDATE_PACKAGES:?}" /vendor/share/framely-update-packages ro
}
eval "$(declare -f generate_zygote_launch_rc | sed '1s/generate_zygote_launch_rc/framely_update_original_rc/')"
function generate_zygote_launch_rc() {
framely_update_original_rc "$@" || return
cat >> "$(prefix)/init.zygote64.rc" <<'FRAMELY_UPDATE_RC'
service framely-apk-updates /system/bin/sh /vendor/share/framely-updates.sh
class late_start
user root
group root
disabled
on property:sys.boot_completed=1
start framely-apk-updates
FRAMELY_UPDATE_RC
}
else
echo 'Framely: APK update interception unavailable with this Lepton entry' >&2
fi
fi
+17
View File
@@ -0,0 +1,17 @@
#!/system/bin/sh
# Keep the original URI path usable if the Android helper is incompatible.
if (
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
export CLASSPATH=/vendor/share/framely-updates.dex
exec app_process /system/bin FramelyUpdates /framely-apk-updates /vendor/share/framely-update-packages
); then
exit 0
fi
echo 'Framely: APK update helper failed; restoring original Steam URI forwarding' >&2
while true; do
cat /lepton/steam.pipe > /lepton/framely-original.pipe
sleep 1
done
Binary file not shown.
+16 -13
View File
@@ -59,7 +59,7 @@
using json=nlohmann::json;
static volatile sig_atomic_t running=1;
static void stop(int){running=0;}
static std::string origin,runtime,token;
static std::string origin,runtime,token,boot_url;
static vr::IVROverlay* overlays=nullptr;
static vr::IVRSystem* vr_system=nullptr;
static Display* xdisplay=nullptr;
@@ -211,9 +211,11 @@ static std::string pending_screenshot_hotkey,pending_screenshot_close,pending_sy
static uint64_t system_action_sequence=0;
static RecordingRegionOverlay screenshot_region_overlay{"framely.screenshot.region"};
static std::string pending_region_close;
static std::string pending_apk_update_close;
static bool apk_prompt(const std::string& key){return key=="framely.apk.permission"||key=="framely.apk.update";}
static RecordingRegionOverlay recording_region_overlay;
static RecordingIndicator recording_indicator;
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;std::string stop_id,region_closed,screenshot_closed,hotkey,system_action;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();stop_id=pending_recording_stop;region_closed=pending_region_close;screenshot_closed=pending_screenshot_close;hotkey=pending_screenshot_hotkey;system_action=pending_system_action;payload=json{{"views",visible_views},{"closedCasts",closed_casts},{"stopRecording",stop_id},{"framingClosed",region_closed},{"screenshotClosed",screenshot_closed},{"screenshotHotkey",hotkey},{"radialAction",system_action}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard<std::mutex> lock(visibility_mutex);if(pending_region_close==region_closed)pending_region_close.clear();if(pending_screenshot_close==screenshot_closed)pending_screenshot_close.clear();if(pending_screenshot_hotkey==hotkey)pending_screenshot_hotkey.clear();if(pending_system_action==system_action)pending_system_action.clear();if(pending_recording_stop==stop_id)pending_recording_stop.clear();closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;std::string stop_id,region_closed,screenshot_closed,hotkey,system_action,apk_update_closed;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();stop_id=pending_recording_stop;region_closed=pending_region_close;apk_update_closed=pending_apk_update_close;screenshot_closed=pending_screenshot_close;hotkey=pending_screenshot_hotkey;system_action=pending_system_action;payload=json{{"views",visible_views},{"closedCasts",closed_casts},{"stopRecording",stop_id},{"framingClosed",region_closed},{"screenshotClosed",screenshot_closed},{"apkUpdateClosed",apk_update_closed},{"screenshotHotkey",hotkey},{"radialAction",system_action}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard<std::mutex> lock(visibility_mutex);if(pending_region_close==region_closed)pending_region_close.clear();if(pending_apk_update_close==apk_update_closed)pending_apk_update_close.clear();if(pending_screenshot_close==screenshot_closed)pending_screenshot_close.clear();if(pending_screenshot_hotkey==hotkey)pending_screenshot_hotkey.clear();if(pending_system_action==system_action)pending_system_action.clear();if(pending_recording_stop==stop_id)pending_recording_stop.clear();closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
static json service_call(const std::string& method,const json& params){
const char* configured=std::getenv("FRAMELY_CONTROL_SOCKET");
const std::string path=configured&&*configured?configured:"/run/framely/control.sock";
@@ -505,7 +507,7 @@ static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_o
}else {keyboard_done=keyboard_done||e.eventType==vr::VREvent_KeyboardDone;keyboard_close_pending=true;}
return true;
}
static void close_view(View& v){if(v.closing)return;if(external_keyboard.active&&v.key==external_keyboard.view_key)external_keyboard_clear(true);if(v.key==frame_keyboard_window_key){std::lock_guard<std::mutex> lock(queue_mutex);queue.erase(std::remove_if(queue.begin(),queue.end(),[](const json& command){return command.value("kind",std::string{})=="window.open"&&command.value("plugin",std::string{})=="tooru.frame-keyboard";}),queue.end());if(running)std::thread([]{frame_keyboard_command("dashboardClose",json::object());}).detach();}if(v.key.rfind("framely.cast.",0)==0){std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}if(v.key=="framely.framing"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_region_close=v.region_editor_id;}if(v.key=="framely.screenshot"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_screenshot_close=v.region_editor_id;}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu"||v.key=="framely.apk.permission")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
static void close_view(View& v){if(v.closing)return;if(external_keyboard.active&&v.key==external_keyboard.view_key)external_keyboard_clear(true);if(v.key==frame_keyboard_window_key){std::lock_guard<std::mutex> lock(queue_mutex);queue.erase(std::remove_if(queue.begin(),queue.end(),[](const json& command){return command.value("kind",std::string{})=="window.open"&&command.value("plugin",std::string{})=="tooru.frame-keyboard";}),queue.end());if(running)std::thread([]{frame_keyboard_command("dashboardClose",json::object());}).detach();}if(v.key.rfind("framely.cast.",0)==0){std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}if(v.key=="framely.framing"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_region_close=v.region_editor_id;}if(v.key=="framely.screenshot"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_screenshot_close=v.region_editor_id;}if(v.key=="framely.apk.update"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_apk_update_close=v.region_editor_id;}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu"||apk_prompt(v.key))overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
static void anchor_to_head(View& v,float width,float x=0,float y=0,float z=-1.35f){vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);if(!poses[0].bPoseIsValid)return;auto m=poses[0].mDeviceToAbsoluteTracking;for(int r=0;r<3;r++)m.m[r][3]+=m.m[r][0]*x+m.m[r][1]*y+m.m[r][2]*z;overlays->SetOverlayWidthInMeters(v.overlay,width);overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&m);}
static void anchor_frame_keyboard(View& v){
// A keyboard button must not move keyboard focus away from the edited app.
@@ -525,7 +527,7 @@ static void anchor_frame_keyboard(View& v){
overlays->SetOverlayCurvature(v.overlay,curvature);overlays->SetOverlayPreCurvePitch(v.overlay,pitch);
}else anchor_to_head(v,1.35f,0,-.38f,-1.2f);
}
static void anchor_permission(View& v){
static void anchor_permission(View& v,float width=.85f){
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);
if(!poses[0].bPoseIsValid)return;
@@ -539,7 +541,7 @@ static void anchor_permission(View& v){
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->SetOverlayWidthInMeters(v.overlay,width);
overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&upright);
}
static void anchor_notification(View& v){
@@ -564,14 +566,14 @@ static View& create_view(const std::string& key,const std::string& title,const s
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dashboard;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications"||key=="framely.framing"||key=="framely.screenshot"||key=="framely.system.radial"||key=="framely.system.radial.progress")?2:1;
auto error=dashboard?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
const bool radial=key=="framely.system.radial"||key=="framely.system.radial.progress";
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"||radial?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,key==frame_keyboard_window_key||key=="framely.apk.permission"||radial);overlays->SetOverlaySortOrder(v->overlay,key==frame_keyboard_window_key?20:key=="framely.apk.permission"?100:radial?200:0);if(key=="framely.framing"||key=="framely.screenshot")recording_region_foreground(overlays,v->overlay);if(key=="framely.apk.permission")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true);
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"||radial?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,radial||key=="framely.apk.update");overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,key==frame_keyboard_window_key||apk_prompt(key)||radial);overlays->SetOverlaySortOrder(v->overlay,key==frame_keyboard_window_key?20:apk_prompt(key)?100:radial?200:0);if(key=="framely.framing"||key=="framely.screenshot")recording_region_foreground(overlays,v->overlay);if(apk_prompt(key))overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true);
if(key=="framely.system.radial"){
vr::VROverlayIntersectionMaskPrimitive_t mask{};
mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Circle;
mask.m_Primitive.m_Circle={width*.5f,height*.5f,260.f};
if(auto e=overlays->SetOverlayIntersectionMask(v->overlay,&mask,1))std::cerr<<"Radial input mask failed "<<e<<"\n";
}
if(key=="framely.apk.permission"){
if(apk_prompt(key)){
vr::VROverlayIntersectionMaskPrimitive_t mask{};
mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;
mask.m_Primitive.m_Rectangle={0,0,float(width),float(height)};
@@ -1024,7 +1026,8 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
// 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");
auto permission=views.find("framely.apk.update");
if(permission==views.end()||permission->second->closing)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;
@@ -1082,7 +1085,7 @@ static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lo
if(screenshot_radial_held){auto key="framely.screenshot.preview";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}double aspect=c.value("aspect",1.0);if(!std::isfinite(aspect)||aspect<.3||aspect>4)aspect=1.0;int width=960,height=std::clamp(int(960/aspect),240,960);auto& v=create_view(key,"Screenshot preview",origin+"/cast/screenshot/shortcut",false,width,height,"",.95f);overlays->SetOverlayInputMethod(v.overlay,vr::VROverlayInputMethod_None);}
}else if(kind=="cast.screenshot.shortcutPreview.close"){
if(auto preview=views.find("framely.screenshot.preview");preview!=views.end())close_view(*preview->second);
}else if(kind=="cast.framing.open"){auto key="framely.framing";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto geometry=c.at("geometry");RecordingRegionPlane plane;auto view=geometry.at("view");if(!recording_region_plane(view,geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Recording frame",origin+"/cast/framing",false,1600,1600);v.region_editor_id=geometry.at("id");v.region_geometry=geometry;update_framing_placement(v);menu_open=false;hide_launcher(false);}else if(kind=="cast.framing.reference"){auto found=views.find("framely.framing");if(found!=views.end()&&!found->second->closing&&found->second->region_editor_id==c.value("id",std::string{})){double cx=c.at("centerTanX"),cy=c.at("centerTanY");if(std::isfinite(cx)&&std::isfinite(cy)&&std::abs(cx)<=1.733&&std::abs(cy)<=1.733){found->second->region_geometry["view"]["centerTanX"]=cx;found->second->region_geometry["view"]["centerTanY"]=cy;}}}else if(kind=="cast.framing.close"){if(views.count("framely.framing")&&views.at("framely.framing")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.framing"));}else if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"))Line truncated
}else if(kind=="cast.framing.open"){auto key="framely.framing";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto geometry=c.at("geometry");RecordingRegionPlane plane;auto view=geometry.at("view");if(!recording_region_plane(view,geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Recording frame",origin+"/cast/framing",false,1600,1600);v.region_editor_id=geometry.at("id");v.region_geometry=geometry;update_framing_placement(v);menu_open=false;hide_launcher(false);}else if(kind=="cast.framing.reference"){auto found=views.find("framely.framing");if(found!=views.end()&&!found->second->closing&&found->second->region_editor_id==c.value("id",std::string{})){double cx=c.at("centerTanX"),cy=c.at("centerTanY");if(std::isfinite(cx)&&std::isfinite(cy)&&std::abs(cx)<=1.733&&std::abs(cy)<=1.733){found->second->region_geometry["view"]["centerTanX"]=cx;found->second->region_geometry["view"]["centerTanY"]=cy;}}}else if(kind=="cast.framing.close"){if(views.count("framely.framing")&&views.at("framely.framing")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.framing"));}else if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"))Line truncated
}
static void release_framing_pointer(View& v){
if(!v.framing_left_down)return;v.framing_left_down=false;
@@ -1103,13 +1106,13 @@ static void reconcile_framing_pointer(View& v){
}
if((owner||found)&&known&&!held)release_framing_pointer(v);
}
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if(v.key=="framely.system.radial"&&e.eventType==vr::VREvent_FocusLeave)v.pointer_valid=false;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;if(menu_open&&v.key!="menu"&&e.eventType==vr::VREvent_MouseButtonDown&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now()))menu_open=false;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))mouse.modifiers=v.framing_left_down?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{if(v.key!=frame_keyboard_window_key)v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view")))&&button==MBT_LEFT)v.framing_left_down=e.eventType==vr::VREvent_MouseButtonDown;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||(keyboard_open&&keyboard_view!=v.key)||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(!keyboard_open&&!v.dashboard&&v.key!=frame_keyboard_window_key&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key!="framely.system.radial"&&v.key.rfind("framely.cast.",0)!=0&&v.key!="framely.apk.permission"&&v.key!="framely.framing"&&v.key!="framely.screenshot")close_view(v);break;default:break;}}
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if(v.key=="framely.system.radial"&&e.eventType==vr::VREvent_FocusLeave)v.pointer_valid=false;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;if(menu_open&&v.key!="menu"&&e.eventType==vr::VREvent_MouseButtonDown&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now()))menu_open=false;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))mouse.modifiers=v.framing_left_down?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{if(v.key!=frame_keyboard_window_key)v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view")))&&button==MBT_LEFT)v.framing_left_down=e.eventType==vr::VREvent_MouseButtonDown;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||(keyboard_open&&keyboard_view!=v.key)||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(!keyboard_open&&!v.dashboard&&v.key!=frame_keyboard_window_key&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key!="framely.system.radial"&&v.key.rfind("framely.cast.",0)!=0&&!apk_prompt(v.key)&&v.key!="framely.framing"&&v.key!="framely.screenshot")close_view(v);break;default:break;}}
int main(int argc,char** argv){
std::cout.setf(std::ios::unitbuf);std::string initial;int port=0;for(int i=1;i+1<argc;i++){std::string s=argv[i];if(s=="--url")initial=argv[++i];else if(s=="--port")port=std::stoi(argv[++i]);else if(s=="--runtime")runtime=argv[++i];}
const char* key=std::getenv("FRAMELY_NATIVE_TOKEN");if(key)token=key;
cef_main_args_t args{argc,argv};if(std::strcmp(cef_api_hash(CEF_API_VERSION,0),CEF_API_HASH_PLATFORM)){std::cerr<<"CEF ABI mismatch\n";return 2;}int child=cef_execute_process(&args,nullptr,nullptr);if(child>=0)return child;
if(initial.empty()||runtime.empty()||!port||token.empty())return 2;origin="http://127.0.0.1:"+std::to_string(port);std::signal(SIGINT,stop);std::signal(SIGTERM,stop);std::signal(SIGPIPE,SIG_IGN);
if(initial.empty()||runtime.empty()||!port||token.empty())return 2;boot_url=initial;origin="http://127.0.0.1:"+std::to_string(port);std::signal(SIGINT,stop);std::signal(SIGTERM,stop);std::signal(SIGPIPE,SIG_IGN);
// Check the existing runtime without bootstrapping it before opening the renderer.
vr::EVRInitError vr_error;vr_system=vr::VR_Init(&vr_error,vr::VRApplication_Background);if(vr_error){std::cerr<<"SteamVR unavailable "<<vr_error<<"\n";return 3;}overlays=vr::VROverlay();
vr::VROverlayHandle_t native_main=0,native_dock=0;
@@ -1128,7 +1131,7 @@ int main(int argc,char** argv){
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);if(v&&v->key=="launcher"){const auto value=text(message);if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=1")launcher_fade.reduced=true;else if(value=="FRAMELY_LAUNCHER_REDUCED_MOTION=0")launcher_fade.reduced=false;else if(value=="FRAMELY_GAZE_FOCUS"&&gaze_active&&launcher_gaze_allowed()){
auto now=std::chrono::steady_clock::now();if(now-gaze_feedback_at>=std::chrono::milliseconds(180)){gaze_feedback_at=now;if(ui_sounds)ui_audio.play();int hand=gaze_feedback_hand;auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);if(device==vr::k_unTrackedDeviceIndexInvalid){hand=1-hand;device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);}if(ui_haptics&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr::VRInput()->TriggerHapticVibrationAction(gaze_haptic_action,0,.012f,110.f,.16f,hands[hand])!=vr::VRInputError_None)vr_system->TriggerHapticPulse(device,0,1200);}
return 1;}}std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
load_handler.api.on_load_end=[](cef_load_handler_t*,cef_browser_t*,cef_frame_t* frame,int){inject_page_scroll(frame);};
load_handler.api.on_load_end=[](cef_load_handler_t*,cef_browser_t* browser,cef_frame_t* frame,int status){if(frame&&frame->is_main(frame)&&status>=400){auto* v=find_browser(browser);if(v&&v->key=="framely.apk.update"){std::cerr<<"APK update window HTTP error: "<<status<<"; closing modal to restore input\n";close_view(*v);return;}}inject_page_scroll(frame);};
client.api.get_load_handler=[](cef_client_t*){return load_handler.acquire();};
request_handler.api.on_before_browse=[](cef_request_handler_t*,cef_browser_t* b,cef_frame_t* f,cef_request_t* r,int,int){auto* uri=r->get_url(r);auto s=text(uri);cef_string_userfree_utf16_free(uri);auto* v=find_browser(b);return int(!allow_plugin_navigation(s,origin,!f||f->is_main(f),v&&!v->plugin.empty()));};
request_handler.api.on_render_process_terminated=[](cef_request_handler_t*,cef_browser_t* b,cef_termination_status_t,int,const cef_string_t*){auto* v=find_browser(b);if(v){std::cerr<<"Renderer crashed: "<<v->key<<"\n";close_view(*v);if(v->key=="menu")running=0;if(v->key=="launcher")launcher_open=false;}};
@@ -1162,7 +1165,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!=frame_keyboard_window_key&&key!="menu"&&key!="notifications"&&key!="launcher"&&key!="framely.system.radial"&&key!="framely.system.radial.progress"&&key.rfind("framely.cast.",0)!=0&&key!="framely.apk.permission")close_view(*v);}was_visible=visible;
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!=frame_keyboard_window_key&&key!="menu"&&key!="notifications"&&key!="launcher"&&key!="framely.system.radial"&&key!="framely.system.radial.progress"&&key.rfind("framely.cast.",0)!=0&&!apk_prompt(key))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.
+1 -1
View File
@@ -221,7 +221,7 @@ cat > /etc/systemd/system/framely-session.service <<EOF
[Unit]
Description=Framely SteamVR UI session
After=framely.service user@$steam_uid.service
Requires=framely.service
Requires=framely.service user@$steam_uid.service
[Service]
User=$steam_user
Environment=HOME=$steam_home
+169 -5
View File
@@ -22,6 +22,7 @@ use std::{
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
mod cleanup;
pub(crate) mod updates;
mod graphics;
mod install_rollback;
mod lifecycle;
@@ -1276,7 +1277,9 @@ fn space(p: &Path, needed: u64) -> Result<()> {
let free = (s.f_bavail as u64).saturating_mul(s.f_frsize as u64);
ensure!(
free > needed.saturating_add(128 * 1024 * 1024),
"Not enough disk space for APK, installation and backup"
"Not enough disk space for APK, installation and backup: need {:.2} GiB, available {:.2} GiB. Free space or move APK/container storage, then retry; existing data has not been replaced",
needed.saturating_add(128*1024*1024) as f64 / (1024.0*1024.0*1024.0),
free as f64 / (1024.0*1024.0*1024.0)
);
Ok(())
}
@@ -1668,7 +1671,7 @@ function setup_props() {
&format!(
"{include}\n{}\n{hooks}\n{}",
storage::HOOKS,
graphics::HOOKS
format!("{}\n{}",graphics::HOOKS, updates::hooks())
),
))
}
@@ -1929,6 +1932,7 @@ fn start_oriented_container_for(
let (window_dex, window_script) = window_runtime(home)?;
cmd.env("FRAMELY_WINDOW_DEX", window_dex)
.env("FRAMELY_WINDOW_SCRIPT", window_script);
updates::configure(&mut cmd, home, c)?;
graphics::configure_command(&mut cmd, false, "")?;
cmd.env_remove("SteamAppId");
storage::configure(
@@ -2276,11 +2280,35 @@ fn backup(home: &Path, c: &Container, db: &Database, log: &Path) -> Result<Strin
fs::create_dir(&d)?;
let dest = d.join("baked");
fs::create_dir(&dest)?;
for entry in fs::read_dir(&c.baked)?.flatten() {
for entry in fs::read_dir(&c.baked)? {
let entry = entry?;
let name = entry.file_name();
if name.to_string_lossy().ends_with("_workdir") {
continue;
}
// Native launches keep their OverlayFS upper/work pair here. The
// kernel-owned work/work can be unreadable even after stopping.
let native_overlay = name.to_str().and_then(|n| n.strip_prefix("native-apk-"))
.is_some_and(|id| id.len() == 24 && id.bytes().all(|b| b.is_ascii_hexdigit()));
if native_overlay && entry.file_type()?.is_dir() {
let target = dest.join(&name);
fs::create_dir(&target)?;
for child in fs::read_dir(entry.path())? {
let child = child?;
if child.file_name() == "work" {
ensure!(child.file_type()?.is_dir(), "Invalid native OverlayFS work directory");
// Include a fresh work directory in the verified snapshot,
// preserving all persistent data (including upper).
let work = target.join("work");
fs::create_dir(&work)?;
fs::set_permissions(work, fs::Permissions::from_mode(0o700))?;
} else {
copy(&child.path(), &target.join(child.file_name()), log)?;
}
}
fs::set_permissions(&target, fs::metadata(entry.path())?.permissions())?;
continue;
}
copy(&entry.path(), &dest.join(name), log)?;
}
@@ -2811,7 +2839,9 @@ pub fn operate(
}
}
}
if kind == "install" { updates::validate_install(home, p)?; }
let result = operate_internal(home, kind, p, cancel, progress, None);
if kind == "install" && result.is_ok() { updates::installed(home, p); }
if matches!(kind, "install" | "steam.retention.settings" | "settings") && result.is_ok() {
steam_shortcuts::wake_registration();
}
@@ -3052,7 +3082,7 @@ fn operate_internal(
};
let app_id = format!("{context}/{}", metadata.package);
let existing = cs.iter().find(|c| c.id == context).cloned();
let c = existing.unwrap_or_else(|| Container {
let mut c = existing.unwrap_or_else(|| Container {
id: context.clone(),
name: context.clone(),
baked: contexts_root(home)
@@ -3073,7 +3103,14 @@ fn operate_internal(
"Unknown target container"
);
ensure!(
safe(&c.name) && !c.name.starts_with("steamlaunch-"),
safe(&c.name) && (!c.name.starts_with("steamlaunch-") || (
// Framely's Steam shortcut changes only the runtime name.
// Accept that exact recorded binding, never an unrelated Steam container.
safe(&c.id) && !c.id.starts_with("steamlaunch-")
&& cs.iter().any(|existing| existing.id == c.id && existing.baked == c.baked)
&& db.records.values().any(|record| record.context == c.id
&& native::runtime_name(record).as_deref() == Some(c.name.as_str()))
)),
"Invalid side-loaded container name"
);
if !cs.iter().any(|x| x.id == context) {
@@ -3146,6 +3183,12 @@ fn operate_internal(
cancel.commit(||{
progress(json!({"cancellable":false}));
if fresh.is_none() && c.baked.exists(){progress(json!({"phase":"backing-up"}));stop(&c,&log)?;backup_id=Some(backup(home,&c,&db,&log)?);}
// Steam runtime names require Steam's APK launch arguments. The installer
// instead boots the same baked data under its stable Framely context name.
if c.name.starts_with("steamlaunch-") {
let _=fs::OpenOptions::new().append(true).open(&log).and_then(|mut file|writeln!(file,"Installer runtime: {} -> {}; data location retained",c.name,c.id));
c.name=c.id.clone();c.running=false;
}
progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log).with_context(||format!("Cannot initialize Android container {}",c.baked.display()))?;
let sdk=podman(&["exec",&format!("lepton-{}",c.name),"getprop","ro.build.version.sdk"],Some(&log))?.trim().parse::<u32>()?;ensure!(metadata.min_sdk<=sdk,"APK requires Android SDK {}, container provides {}",metadata.min_sdk,sdk);
progress(json!({"phase":"installing","cancellable":false}));install_package(&c,&review.join("base.apk"),&log)?;
@@ -6558,7 +6601,22 @@ esac
c.baked.join("data_overlay/media/0"),
)
.unwrap();
let native_name = format!("native-apk-{}", hash("com.example.app"));
let native = c.baked.join(&native_name);
fs::create_dir_all(native.join("upper/files/work")).unwrap();
fs::write(native.join("upper/files/work/save"), b"native progress").unwrap();
std::os::unix::fs::symlink("files/work/save", native.join("upper/save-link")).unwrap();
fs::create_dir_all(native.join("work/work")).unwrap();
fs::write(native.join("work/work/transient"), b"kernel scratch").unwrap();
fs::set_permissions(native.join("work/work"), fs::Permissions::from_mode(0)).unwrap();
fs::create_dir_all(c.baked.join("app_workdir/work")).unwrap();
fs::set_permissions(c.baked.join("app_workdir/work"), fs::Permissions::from_mode(0)).unwrap();
let id = backup(&f.home, &c, &db, &f.dir.path().join("test.log")).unwrap();
let saved = root(&f.home).join("backups").join(&id).join("baked");
assert_eq!(fs::read_dir(saved.join(&native_name).join("work")).unwrap().count(), 0);
assert!(!saved.join("app_workdir").exists());
assert_eq!(fs::read(saved.join(&native_name).join("upper/save-link")).unwrap(), b"native progress");
verified_snapshot(saved.parent().unwrap()).unwrap();
assert!(clean_candidates(&f.home, &db)
.unwrap()
.iter()
@@ -6566,6 +6624,7 @@ esac
fs::write(f.save_file(), "changed progress").unwrap();
fs::write(&resource, b"changed download").unwrap();
fs::write(&shader, b"changed shader").unwrap();
fs::write(native.join("upper/files/work/save"), b"changed native progress").unwrap();
let scratch = c.baked.join("data_workdir/work");
fs::create_dir_all(&scratch).unwrap();
fs::set_permissions(&scratch, fs::Permissions::from_mode(0)).unwrap();
@@ -6579,6 +6638,9 @@ esac
assert_eq!(fs::read_to_string(f.save_file()).unwrap(), "saved progress");
assert_eq!(fs::read(&resource).unwrap(), b"unfinished download");
assert_eq!(fs::read(&shader).unwrap(), b"compiled shader");
assert_eq!(fs::read(native.join("upper/save-link")).unwrap(), b"native progress");
assert_eq!(fs::read_dir(native.join("work")).unwrap().count(), 0);
assert_eq!(fs::metadata(native.join("work")).unwrap().permissions().mode() & 0o777, 0o700);
assert_eq!(
fs::read(
c.baked
@@ -6676,4 +6738,106 @@ esac
std::os::unix::fs::symlink(f.dir.path(), r.join("reviews")).unwrap();
assert!(upload_dir(&f.home, 100).is_err());
}
#[test]
fn updating_running_native_steam_shortcut_uses_its_owned_container() {
let f=Fixture::new();
list(&f.home).unwrap();
let id="test/com.example.app";
let mut db=load(&f.home).unwrap();
let record=db.records.get_mut(id).unwrap();
record.steam_app_id=Some(0x92345678);
record.steam_binding=Some(steam_shortcuts::Binding {game_id:steam_shortcuts::game_id(id),native:true,revision:String::new()});
save(&f.home,&db).unwrap();
let tool=f.dir.path().join("tools/podman");
let compat=f.home.join(".local/share/lepton/contexts/test");
let source=fs::read_to_string(&tool).unwrap()
.replace("lepton-test|", "lepton-steamlaunch-2452903544|")
.replace("echo lepton-test", "echo lepton-steamlaunch-2452903544")
.replace(&format!("fixture|{}|[]",compat.display()),&compat.to_string_lossy());
fs::write(&tool,source).unwrap();
let cs=containers(&f.home,&db);
let c=cs.iter().find(|c|c.id=="test").unwrap();
assert_eq!(c.name,"steamlaunch-2452903544");
assert!(!c.steam);
assert!(c.running);
// Model Lepton's reserved-name behaviour instead of accepting every start.
let lepton=runner(&f.home).unwrap();
let source=fs::read_to_string(&lepton).unwrap().replace("setup_props\necho true",&format!("if [[ \"${{2:-}}\" == steamlaunch-* ]]; then echo 'No package id found' >&2; exit 97; fi\nprintf '%s\\n' \"$2\" > '{}'\nsetup_props\necho true",f.dir.path().join("installer-context").display()));
fs::write(&lepton,source).unwrap();
let incoming=f.dir.path().join("update.apk");
crate::apk_metadata::fixture(&incoming,true,43);
let review=inspect_local(&f.home,&incoming,&Cancellation::default()).unwrap();
let result=f.operation("install",json!({"app":id,"context":"test","ticket":review["ticket"],"approve":true})).unwrap();
assert!(result["backup"].is_string());
assert_eq!(fs::read_to_string(f.save_file()).unwrap(),"saved progress");
let baked=compat.join("baked");
assert_eq!(cached_metadata(&baked.join("app_overlay/base.apk")).unwrap().version_code,43);
let commands=fs::read_to_string(f.dir.path().join("commands")).unwrap();
assert!(commands.contains("stop --time 10 lepton-steamlaunch-2452903544"));
assert_eq!(fs::read_to_string(f.dir.path().join("installer-context")).unwrap().trim(),"test");
assert!(commands.contains("exec lepton-test getprop ro.build.version.sdk"));
// Without a matching Framely binding the same runtime remains Steam-owned.
let mut db=load(&f.home).unwrap();
db.records.get_mut(id).unwrap().steam_binding=None;
save(&f.home,&db).unwrap();
fs::write(f.dir.path().join("running"),"true").unwrap();
assert!(containers(&f.home,&db).iter().find(|c|c.id=="test").unwrap().steam);
}
#[test]
fn captured_update_requires_review_keeps_backup_and_preserves_data() {
let f=Fixture::new();
let mut db=load(&f.home).unwrap();
let (a,c)=app(&f.home,&db,"test/com.example.app").unwrap();
record_app(&mut db,&a);save(&f.home,&db).unwrap();
let env=updates::prepare(&f.home,&c).unwrap().unwrap();
let dir=PathBuf::from(env["FRAMELY_UPDATE_DIR"].as_str().unwrap());
let id="0123456789abcdef0123456789abcdef";
let duplicate="abcdef0123456789abcdef0123456789";
let downgrade="1123456789abcdef0123456789abcdef";
for (key,version) in [(id,43),(duplicate,43),(downgrade,41)] {
let path=dir.join(format!("{key}.apk"));
crate::apk_metadata::fixture(&path,true,version);
fs::write(dir.join(format!("{key}.json")),serde_json::to_vec(&json!({"package":"com.example.app","bytes":fs::metadata(path).unwrap().len()})).unwrap()).unwrap();
}
let queue=updates::poll(&f.home).unwrap();
assert_eq!(queue.len(),2);
let id=queue.iter().find(|r|r.error.is_none()).unwrap().id.as_str();
assert_eq!(cached_metadata(&c.baked.join("app_overlay/base.apk")).unwrap().version_code,42);
let mut windows=updates::Windows::default();
assert_eq!(windows.next(&queue),Some(id.to_owned()));
windows.close(id);
assert_eq!(windows.next(&queue),None);
// All three Android request paths stage the same APK in this format.
// Each fresh request must reopen the retained confirmation, even in the same second.
for repeated in ["2123456789abcdef0123456789abcdef", "3123456789abcdef0123456789abcdef", "4123456789abcdef0123456789abcdef"] {
fs::copy(dir.join(format!("{id}.apk")),dir.join(format!("{repeated}.apk"))).unwrap();
fs::write(dir.join(format!("{repeated}.json")),serde_json::to_vec(&json!({"package":"com.example.app","bytes":fs::metadata(dir.join(format!("{repeated}.apk"))).unwrap().len()})).unwrap()).unwrap();
let repeated_queue=updates::poll(&f.home).unwrap();
assert_eq!(repeated_queue.len(),2);
assert!(!dir.join(format!("{repeated}.apk")).exists());
assert_eq!(windows.next(&repeated_queue),Some(id.to_owned()));
windows.close(id);
assert_eq!(windows.next(&repeated_queue),None);
updates::presented(&f.home,id).unwrap();
let after_receipt=updates::poll(&f.home).unwrap();
assert_eq!(updates::Windows::default().next(&after_receipt),None);
}
let review=updates::review_request(&f.home,id,&Cancellation::default()).unwrap();
let invalid=json!({"app":"other/com.example.app","context":"other","updateRequest":id});
assert!(updates::validate_install(&f.home,&invalid).is_err());
let p=json!({"ticket":review["ticket"],"approve":true,"app":a.id,"context":c.id,"updateRequest":id});
updates::validate_install(&f.home,&p).unwrap();
let result=f.operation("install",p.clone()).unwrap();
assert!(result["backup"].is_string());
updates::installed(&f.home,&p);
assert_eq!(fs::read_to_string(f.save_file()).unwrap(),"saved progress");
assert_eq!(cached_metadata(&c.baked.join("app_overlay/base.apk")).unwrap().version_code,43);
assert!(!dir.join(format!("{id}.apk")).exists());
assert_eq!(updates::list(&f.home).unwrap().as_array().unwrap().len(),1);
// The FIFO lives outside persistent APK storage and cannot break migration.
let fifo=PathBuf::from(env["FRAMELY_UPDATE_PIPE"].as_str().unwrap());
assert!(!fifo.starts_with(root(&f.home)));
fs::remove_file(fifo).unwrap();
}
}
+4
View File
@@ -184,6 +184,10 @@ pub(super) fn prepare(
"APP_WANTS_FLATSCREEN":if flat {"true"}else{"false"},
"LEPTON_NO_CLEANUP":"true", "TERM":"dumb"
});
for key in ["FRAMELY_UPDATE_DIR","FRAMELY_UPDATE_PIPE","FRAMELY_UPDATE_DEX","FRAMELY_UPDATE_SCRIPT","FRAMELY_UPDATE_PACKAGES"] {env[key]=json!("");}
if let Some(update_env) = updates::prepare(home, &c)? {
env.as_object_mut().unwrap().extend(update_env.as_object().unwrap().clone());
}
graphics::configure_environment(&mut env, a.framebuffer_compatibility, &a.metadata.package)?;
let installed_dir = c
.baked
+10 -6
View File
@@ -52,13 +52,14 @@ fn runtime() -> Result<PathBuf> {
let uid = unsafe { libc::geteuid() };
ensure!(uid != 0, "Steam APK wrapper must run as the session user");
let p = PathBuf::from(format!("/run/user/{uid}/framely-apk-steam"));
fs::create_dir_all(&p)?;
let m = fs::symlink_metadata(&p)?;
fs::create_dir_all(&p).with_context(|| format!("Create Steam APK runtime directory {}", p.display()))?;
let m = fs::symlink_metadata(&p).with_context(|| format!("Inspect Steam APK runtime directory {}", p.display()))?;
ensure!(
m.is_dir() && m.uid() == uid,
"Invalid Steam wrapper runtime directory"
);
fs::set_permissions(&p, fs::Permissions::from_mode(0o700))?;
fs::set_permissions(&p, fs::Permissions::from_mode(0o700))
.with_context(|| format!("Protect Steam APK runtime directory {}", p.display()))?;
Ok(p)
}
fn socket() -> Result<PathBuf> {
@@ -503,7 +504,8 @@ pub(super) fn start_session() -> Result<()> {
.create(true)
.truncate(false)
.mode(0o600)
.open(dir.join("server.lock"))?;
.open(dir.join("server.lock"))
.with_context(|| format!("Open Steam APK bridge lock {}", dir.join("server.lock").display()))?;
ensure!(
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
"Steam APK bridge is already running"
@@ -516,8 +518,10 @@ pub(super) fn start_session() -> Result<()> {
);
fs::remove_file(&path)?;
}
let listener = UnixListener::bind(&path)?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
let listener = UnixListener::bind(&path)
.with_context(|| format!("Bind Steam APK bridge socket {}", path.display()))?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
.with_context(|| format!("Protect Steam APK bridge socket {}", path.display()))?;
std::thread::spawn(move || {
let _lock = lock;
let workers = Arc::new(std::sync::atomic::AtomicUsize::new(0));
+602
View File
@@ -0,0 +1,602 @@
//! App-originated APK updates are queued, never installed without review.
use super::*;
use std::collections::{BTreeMap, BTreeSet};
const HOOKS: &str = include_str!("../../native/android/apk-updates-hooks.sh");
const MAX_AGE: u64 = 7 * 24 * 3600;
static QUEUE_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Bridge {
context: String,
}
#[derive(Deserialize)]
struct Captured {
package: Option<String>,
bytes: Option<u64>,
error: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Request {
pub id: String,
pub app: String,
pub name: String,
pub previous: String,
pub version: String,
pub bytes: u64,
pub created: u64,
pub error: Option<String>,
#[serde(default)]
sha256: String,
#[serde(default)]
revision: u64,
#[serde(default)]
presented_revision: Option<u64>,
// Old records have no trustworthy delivery history and stay manually accessible.
#[serde(default)]
presentation_tracked: bool,
}
/// One confirmation at a time; cancelling retains the APK without immediately reopening it.
#[derive(Default)]
pub(crate) struct Windows {
active: Option<String>,
shown: BTreeMap<String, u64>,
sent_at: Option<std::time::Instant>,
attempts: u8,
failed: Option<String>,
}
impl Windows {
pub(crate) fn next(&mut self, requests: &[Request]) -> Option<String> {
self.next_at(requests, std::time::Instant::now())
}
fn next_at(&mut self, requests: &[Request], now: std::time::Instant) -> Option<String> {
self.shown.retain(|id, _| requests.iter().any(|r| &r.id == id));
if let Some(active) = &self.active {
// Coalesce repeats while their confirmation is already open.
if let Some(r) = requests.iter().find(|r| &r.id == active) {
self.shown.insert(r.id.clone(), r.revision);
if r.presented_revision != Some(r.revision)
&& self.sent_at.is_some_and(|sent| now.saturating_duration_since(sent) >= Duration::from_secs(5)) {
if self.attempts < 3 {
self.attempts += 1;
self.sent_at = Some(now);
return Some(r.id.clone());
}
if self.attempts == 3 {
self.failed = Some(r.id.clone());
self.attempts = 4;
}
}
return None;
}
// Installed/dismissed requests must not block the next application.
self.active = None;
}
let request = requests
.iter()
.filter(|r| r.error.is_none() && r.presentation_tracked && r.presented_revision != Some(r.revision) && self.shown.get(&r.id) != Some(&r.revision))
.min_by_key(|r| r.created)?;
let id = request.id.clone();
self.shown.insert(id.clone(), request.revision);
self.active = Some(id.clone());
self.sent_at = Some(now);
self.attempts = 1;
Some(id)
}
pub(crate) fn take_failure(&mut self) -> Option<String> { self.failed.take() }
pub(crate) fn close(&mut self, id: &str) {
if self.active.as_deref() == Some(id) {
self.active = None;
}
}
pub(crate) fn reopen(&mut self, id: &str) -> Result<()> {
ensure!(key(id), "Invalid APK update request");
ensure!(
self.active.as_deref().is_none_or(|active| active == id),
"Finish the current APK update first"
);
self.active = Some(id.to_owned());
self.sent_at = Some(std::time::Instant::now());
self.attempts = 1;
self.shown.entry(id.to_owned()).or_insert(0);
Ok(())
}
}
fn directory(home: &Path) -> PathBuf {
root(home).join("updates")
}
fn key(id: &str) -> bool {
id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit())
}
fn private(path: &Path) -> Result<()> {
fs::create_dir_all(path)?;
ensure!(
!fs::symlink_metadata(path)?.file_type().is_symlink(),
"Invalid APK update directory"
);
ensure!(
fs::metadata(path)?.uid() == unsafe { libc::geteuid() },
"APK update directory belongs to another user"
);
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
Ok(())
}
fn write(path: &Path, value: &impl Serialize) -> Result<()> {
steam_shortcuts::write_owned(path, &serde_json::to_vec(value)?, 0o600)
}
pub(super) fn hooks() -> &'static str {
HOOKS
}
pub(super) fn prepare(home: &Path, c: &Container) -> Result<Option<Value>> {
let db = load(home)?;
let packages: BTreeSet<_> = db
.records
.values()
.filter(|r| r.context == c.id && (!r.removed || r.pending.is_some()))
.map(|r| r.metadata.package.as_str())
.filter(|p| apk_metadata::valid_package(p))
.collect();
if packages.is_empty() {
return Ok(None);
}
let base = directory(home);
private(&base)?;
let runtime = base.join("runtime");
private(&runtime)?;
let dir = base.join(format!("bridge-{}", hash(&c.id)));
private(&dir)?;
write(
&dir.join("context.json"),
&Bridge {
context: c.id.clone(),
},
)?;
let pipe_root = std::env::temp_dir().join(format!("framely-apk-updates-{}", unsafe {
libc::geteuid()
}));
private(&pipe_root)?;
let fifo = pipe_root.join(format!("{}.pipe", hash(&c.id)));
match fs::symlink_metadata(&fifo) {
Ok(m) => ensure!(m.file_type().is_fifo(), "APK update pipe is not a FIFO"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let name = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes())?;
ensure!(
unsafe { libc::mkfifo(name.as_ptr(), 0o600) } == 0,
"Cannot create APK update pipe"
);
}
Err(e) => return Err(e.into()),
}
let dex = runtime.join("framely-updates.dex");
let script = runtime.join("updates.sh");
steam_shortcuts::write_owned(
&dex,
include_bytes!("../../native/android/framely-updates.dex"),
0o600,
)?;
steam_shortcuts::write_owned(
&script,
include_bytes!("../../native/android/apk-updates.sh"),
0o600,
)?;
let allowlist = dir.join("packages");
steam_shortcuts::write_owned(
&allowlist,
packages
.into_iter()
.collect::<Vec<_>>()
.join("\n")
.as_bytes(),
0o600,
)?;
let _ = fs::remove_file(dir.join("ready"));
Ok(Some(
json!({"FRAMELY_UPDATE_DIR":dir,"FRAMELY_UPDATE_PIPE":fifo,"FRAMELY_UPDATE_DEX":dex,
"FRAMELY_UPDATE_SCRIPT":script,"FRAMELY_UPDATE_PACKAGES":allowlist}),
))
}
pub(super) fn configure(cmd: &mut Command, home: &Path, c: &Container) -> Result<()> {
for key in [
"FRAMELY_UPDATE_DIR",
"FRAMELY_UPDATE_PIPE",
"FRAMELY_UPDATE_DEX",
"FRAMELY_UPDATE_SCRIPT",
"FRAMELY_UPDATE_PACKAGES",
] {
cmd.env_remove(key);
}
if let Some(env) = prepare(home, c)? {
for (k, v) in env.as_object().unwrap() {
cmd.env(k, v.as_str().context("Invalid APK update environment")?);
}
}
Ok(())
}
fn bridge_dirs(home: &Path) -> Vec<PathBuf> {
fs::read_dir(directory(home))
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("bridge-"))
&& e.file_type().is_ok_and(|t| t.is_dir())
})
.map(|e| e.path())
.collect()
}
fn regular(path: &Path, max: u64) -> Result<fs::File> {
use std::os::unix::fs::OpenOptionsExt;
let f = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
ensure!(
f.metadata()?.is_file() && f.metadata()?.len() <= max,
"Invalid APK update file"
);
Ok(f)
}
fn request(home: &Path, id: &str) -> Result<(PathBuf, Request)> {
ensure!(key(id), "Invalid APK update request");
for dir in bridge_dirs(home) {
if let Ok(f) = regular(&dir.join(format!("{id}.request")), 16384) {
let r: Request = serde_json::from_reader(f)?;
ensure!(r.id == id, "APK update request changed");
return Ok((dir, r));
}
}
bail!("APK update request has ended; retry from the application")
}
fn remove(dir: &Path, id: &str) -> Result<()> {
ensure!(key(id), "Invalid APK update request");
for ext in ["json", "apk", "apk.part", "request", "json.part"] {
let p = dir.join(format!("{id}.{ext}"));
match fs::remove_file(p) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
}
Ok(())
}
/// Called by the session worker. Metadata parsing happens off the UI request thread.
pub(crate) fn poll(home: &Path) -> Result<Vec<Request>> {
let _gate = QUEUE_GATE.lock().unwrap();
let db = load(home)?;
let mut notices = Vec::new();
for dir in bridge_dirs(home) {
let bridge: Bridge = serde_json::from_reader(regular(&dir.join("context.json"), 4096)?)?;
for entry in fs::read_dir(&dir)?.filter_map(|e| e.ok()) {
let name = entry.file_name();
let Some(id) = name
.to_str()
.and_then(|n| n.strip_suffix(".json"))
.filter(|n| key(n))
else {
continue;
};
let id = id.to_owned();
if dir.join(format!("{id}.request")).exists() {
continue;
}
let captured: Result<Captured> =
(|| Ok(serde_json::from_reader(regular(&entry.path(), 16384)?)?))();
let result = (|| -> Result<Request> {
let captured = captured?;
if let Some(error) = captured.error {
bail!("{error}");
}
let package = captured
.package
.context("Update package identity missing")?;
let rec = db
.records
.values()
.find(|r| {
r.context == bridge.context && !r.removed && r.metadata.package == package
})
.context("Update does not match an installed application")?;
let apk = dir.join(format!("{id}.apk"));
let f = regular(&apk, 8 * 1024 * 1024 * 1024)?;
ensure!(
captured.bytes == Some(f.metadata()?.len()),
"Extracted APK size changed"
);
let metadata = apk_metadata::read(&apk)?;
ensure!(
metadata.package == package
&& metadata.version_code >= rec.metadata.version_code,
"Update package mismatch or downgrade"
);
let sha256 = crate::package::digest_reader(&mut fs::File::open(&apk)?)?;
// Retain one APK, but treat a fresh installer request as a new prompt.
for previous in list(home)?.as_array().into_iter().flatten() {
if previous["app"] == rec.id
&& previous["version"] == metadata.version
&& previous["sha256"] == sha256
{
let (previous_dir, mut previous) = request(home, previous["id"].as_str().context("Missing retained update identity")?)?;
previous.presentation_tracked = true;
previous.revision = previous.revision.checked_add(1).context("APK update request revision exhausted")?;
write(&previous_dir.join(format!("{}.request", previous.id)), &previous)?;
eprintln!("APK update request={} revision={} repeated request received", previous.id, previous.revision);
remove(&dir, &id)?;
return Err(anyhow::anyhow!("duplicate"));
}
}
Ok(Request {
id: id.clone(),
app: rec.id.clone(),
name: rec.metadata.name.clone(),
previous: rec.metadata.version.clone(),
version: metadata.version,
bytes: captured.bytes.unwrap(),
created: now(),
error: None,
sha256,
revision: 0,
presented_revision: None,
presentation_tracked: true,
})
})();
let value = match result {
Ok(r) => r,
Err(e) if e.to_string() == "duplicate" => continue,
Err(e) => Request {
id: id.clone(),
app: String::new(),
name: String::new(),
previous: String::new(),
version: String::new(),
bytes: 0,
created: now(),
error: Some(e.to_string()),
sha256: String::new(),
revision: 0,
presented_revision: None,
presentation_tracked: true,
},
};
write(&dir.join(format!("{id}.request")), &value)?;
eprintln!("APK update request={id} revision=0 captured valid={}", value.error.is_none());
notices.push(value);
}
}
let _ = notices;
Ok(serde_json::from_value(list(home)?)?)
}
pub(crate) fn list(home: &Path) -> Result<Value> {
let mut values = Vec::new();
for dir in bridge_dirs(home) {
for entry in fs::read_dir(&dir)?.filter_map(|e| e.ok()) {
let name = entry.file_name();
let Some(id) = name
.to_str()
.and_then(|n| n.strip_suffix(".request"))
.filter(|n| key(n))
else {
continue;
};
let value: Request = serde_json::from_reader(regular(&entry.path(), 16384)?)?;
if now().saturating_sub(value.created) > MAX_AGE {
remove(&dir, id)?;
} else {
values.push(value);
}
}
}
values.sort_by_key(|r| r.created);
Ok(json!(values))
}
pub(crate) fn presented(home: &Path, id: &str) -> Result<()> {
let _gate = QUEUE_GATE.lock().unwrap();
let (dir, mut value) = request(home, id)?;
if value.presented_revision != Some(value.revision) {
value.presentation_tracked = true;
value.presented_revision = Some(value.revision);
write(&dir.join(format!("{id}.request")), &value)?;
eprintln!("APK update request={id} revision={} confirmation presented", value.revision);
}
Ok(())
}
pub(crate) fn review_request(home: &Path, id: &str, cancel: &Cancellation) -> Result<Value> {
let (dir, r) = request(home, id)?;
ensure!(
r.error.is_none(),
"Update could not be extracted; retry from the application"
);
let db = load(home)?;
let rec = db
.records
.get(&r.app)
.context("Application no longer exists")?;
ensure!(!rec.removed, "Application was uninstalled");
let source = dir.join(format!("{id}.apk"));
regular(&source, 8 * 1024 * 1024 * 1024)?;
let review = inspect(home, &source, cancel)?;
if review["metadata"]["package"] != rec.metadata.package {
let _ = reviews_drop(home, review["ticket"].as_str().unwrap());
bail!("APK update package changed");
}
Ok(review)
}
pub(crate) fn dismiss(home: &Path, id: &str) -> Result<Value> {
let (dir, _) = request(home, id)?;
remove(&dir, id)?;
Ok(json!(true))
}
pub(super) fn validate_install(home: &Path, p: &Value) -> Result<()> {
let Some(id) = p["updateRequest"].as_str() else {
return Ok(());
};
let (_, r) = request(home, id)?;
ensure!(
p["app"] == r.app && p["context"].as_str() == r.app.split_once('/').map(|v| v.0),
"APK update target changed"
);
Ok(())
}
pub(super) fn installed(home: &Path, p: &Value) {
if let Some(id) = p["updateRequest"].as_str() {
let _ = dismiss(home, id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_do_not_escape_queue() {
for id in ["../x", "", "abc", "0123456789abcdef0123456789abcde/ff"] {
assert!(!key(id));
}
assert!(key("0123456789abcdef0123456789abcdef"));
}
#[test]
fn dismiss_preserves_other_requests() {
let t = tempfile::tempdir().unwrap();
let a = "0123456789abcdef0123456789abcdef";
let b = "abcdef0123456789abcdef0123456789";
fs::write(t.path().join(format!("{a}.apk")), b"test").unwrap();
fs::write(t.path().join(format!("{b}.apk")), b"keep").unwrap();
remove(t.path(), a).unwrap();
assert!(!t.path().join(format!("{a}.apk")).exists());
assert!(t.path().join(format!("{b}.apk")).exists());
}
#[test]
fn no_follow_rejects_symlinks() {
let t = tempfile::tempdir().unwrap();
fs::write(t.path().join("data"), b"test").unwrap();
std::os::unix::fs::symlink(t.path().join("data"), t.path().join("link")).unwrap();
assert!(regular(&t.path().join("link"), 100).is_err());
}
#[test]
fn boot_hook_preserves_steam_forwarding_and_starts_helper_only_inside_container() {
let t = tempfile::tempdir().unwrap();
let script = format!(
r#"
function setup_podman_mounts() {{ PODMAN_CMDLINE+=(--mount 'type=bind,source=/original/steam.pipe,destination=/lepton/steam.pipe,rw'); }}
function generate_zygote_launch_rc() {{ echo '# original zygote' > "$TEST_RC/init.zygote64.rc"; }}
function prefix() {{ echo "$TEST_RC"; }}
function podman_mount_entry() {{ PODMAN_CMDLINE+=(--mount "type=bind,source=$1,destination=$2,$3"); }}
PODMAN_CMDLINE=()
{HOOKS}
setup_podman_mounts
generate_zygote_launch_rc
printf '%s\n' "${{PODMAN_CMDLINE[@]}}"
"#
);
let out = Command::new("bash")
.args(["-euc", &script])
.env("TEST_RC", t.path())
.env("FRAMELY_UPDATE_DIR", "/private/updates")
.env("FRAMELY_UPDATE_PIPE", "/private/fifo")
.env("FRAMELY_UPDATE_DEX", "/private/helper.dex")
.env("FRAMELY_UPDATE_SCRIPT", "/private/helper.sh")
.env("FRAMELY_UPDATE_PACKAGES", "/private/packages")
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let mounts = String::from_utf8(out.stdout).unwrap();
assert!(mounts
.contains("source=/original/steam.pipe,destination=/lepton/framely-original.pipe,rw"));
assert!(mounts.contains("source=/private/fifo,destination=/lepton/steam.pipe,rw"));
assert!(mounts.contains("destination=/vendor/share/framely-updates.dex,ro"));
let rc = fs::read_to_string(t.path().join("init.zygote64.rc")).unwrap();
assert!(rc.starts_with("# original zygote"));
assert!(rc.contains("on property:sys.boot_completed=1\n start framely-apk-updates"));
}
#[test]
fn confirmations_are_automatic_sequential_and_cancel_does_not_reopen() {
let request = |id: &str, created| Request {
id: id.into(),
app: "test/com.example.app".into(),
name: "test".into(),
previous: "1".into(),
version: "2".into(),
bytes: 100,
created,
error: None,
sha256: String::new(),
revision: 0,
presented_revision: None,
presentation_tracked: true,
};
let a = request("0123456789abcdef0123456789abcdef", 1);
let b = request("abcdef0123456789abcdef0123456789", 2);
let mut legacy = serde_json::to_value(&a).unwrap();
legacy.as_object_mut().unwrap().remove("presentedRevision");
legacy.as_object_mut().unwrap().remove("presentationTracked");
legacy.as_object_mut().unwrap().remove("revision");
let legacy: Request = serde_json::from_value(legacy).unwrap();
assert_eq!(Windows::default().next(&[legacy]), None);
let mut state = Windows::default();
let queue = vec![b.clone(), a.clone()];
assert_eq!(state.next(&queue), Some(a.id.clone()));
assert_eq!(state.next(&queue), None);
state.close(&b.id);
assert_eq!(state.next(&queue), None);
state.close(&a.id);
assert_eq!(state.next(&queue), Some(b.id.clone()));
state.close(&b.id);
assert_eq!(state.next(&queue), None);
state.reopen(&a.id).unwrap();
assert_eq!(state.next(&queue), None);
state.close(&a.id);
// A new request for the same retained APK reopens after cancellation.
let mut repeated = a.clone();
repeated.revision = 1;
assert_eq!(state.next(&[repeated.clone()]), Some(a.id.clone()));
assert_eq!(state.next(&[repeated.clone()]), None);
// Repeats received while already visible do not reopen after closing.
repeated.revision = 2;
assert_eq!(state.next(&[repeated.clone()]), None);
state.close(&a.id);
assert_eq!(state.next(&[repeated]), None);
let mut removed = Windows::default();
assert_eq!(removed.next(&[a.clone()]), Some(a.id.clone()));
assert_eq!(removed.next(&[b.clone()]), Some(b.id.clone()));
// A dropped open command is retried, and missing presentation is surfaced.
let now = std::time::Instant::now();
let mut delivery = Windows::default();
assert_eq!(delivery.next_at(&[a.clone()], now), Some(a.id.clone()));
assert_eq!(delivery.next_at(&[a.clone()], now + Duration::from_secs(4)), None);
assert_eq!(delivery.next_at(&[a.clone()], now + Duration::from_secs(5)), Some(a.id.clone()));
assert_eq!(delivery.next_at(&[a.clone()], now + Duration::from_secs(10)), Some(a.id.clone()));
assert_eq!(delivery.next_at(&[a.clone()], now + Duration::from_secs(15)), None);
assert_eq!(delivery.take_failure(), Some(a.id.clone()));
assert_eq!(delivery.next_at(&[a.clone()], now + Duration::from_secs(20)), None);
let mut delivered = a.clone();
delivered.presented_revision = Some(delivered.revision);
let mut delivery = Windows::default();
assert_eq!(delivery.next_at(&[a.clone()], now), Some(a.id.clone()));
assert_eq!(delivery.next_at(&[delivered], now + Duration::from_secs(20)), None);
assert_eq!(delivery.take_failure(), None);
// Installation/session restart must not reopen retained requests.
let mut presented_queue = queue.clone();
for r in &mut presented_queue { r.presented_revision = Some(r.revision); }
let mut resumed = Windows::default();
assert_eq!(resumed.next(&presented_queue), None);
assert_eq!(Windows::default().next(&queue), Some(a.id.clone()));
// Manual review remains possible, without automatic reopening on close.
resumed.reopen(&a.id).unwrap();
resumed.close(&a.id);
assert_eq!(resumed.next(&presented_queue), None);
// A new capture prompts even with older retained requests in the queue.
let mut fresh = a.clone();
fresh.id = "1123456789abcdef0123456789abcdef".into();
let mut pending = presented_queue.clone();
pending.push(fresh.clone());
assert_eq!(resumed.next(&pending), Some(fresh.id.clone()));
resumed.close(&fresh.id);
// A newly submitted identical APK must also prompt after restart.
let mut repeated = a.clone();
repeated.revision += 1;
assert_eq!(resumed.next(&[repeated]), Some(a.id.clone()));
}
}
+21
View File
@@ -10,3 +10,24 @@ mod region_device_test;
pub mod receiver;
pub mod watch_access;
// Preserve one bounded failure tail before a later start truncates the live log.
fn retain_failed_log(directory: &std::path::Path, name: &str) {
use std::io::{Read, Seek, SeekFrom};
let result = (|| -> std::io::Result<()> {
let mut source = std::fs::File::open(directory.join(format!("{name}.log")))?;
let size = source.metadata()?.len();
source.seek(SeekFrom::Start(size.saturating_sub(256 * 1024)))?;
let mut tail = Vec::new();
source.take(256 * 1024).read_to_end(&mut tail)?;
if !tail.is_empty() {
std::fs::write(directory.join(format!("{name}.failed.log")), tail)?;
}
Ok(())
})();
if let Err(error) = result {
if error.kind() != std::io::ErrorKind::NotFound {
eprintln!("Cannot retain casting {name} failure log: {error}");
}
}
}
+23
View File
@@ -302,6 +302,7 @@ impl Receivers {
.as_mut()
.and_then(|w| w.child.try_wait().ok().flatten())
{
super::retain_failed_log(&self.directory, "receiver");
self.error = Some(format!("DLNA 接收程序已退出:{code}"));
self.worker = None;
self.end_protocol("DLNA");
@@ -311,6 +312,7 @@ impl Receivers {
.as_mut()
.and_then(|w| w.try_wait().ok().flatten())
{
super::retain_failed_log(&self.directory, "airplay");
let details = fs::read_to_string(self.directory.join("airplay.log")).unwrap_or_default();
let tail = details.lines().rev().take(8).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n");
self.error = Some(format!("AirPlay 接收程序已退出:{code}\n{tail}"));
@@ -561,6 +563,27 @@ mod tests {
assert!(r.control("0123", "volume", json!({"volume":0.5})).is_err());
}
#[test]
fn airplay_failure_log_survives_receiver_restart() {
let root=tempfile::tempdir().unwrap();
let bin=root.path().join("bin");
fs::create_dir(&bin).unwrap();
let executable=bin.join("uxplay");
fs::write(&executable,"#!/bin/sh\necho \"Required gstreamer plugin 'autodetect' not found\" >&2\nexit 1\n").unwrap();
fs::set_permissions(&executable,fs::Permissions::from_mode(0o700)).unwrap();
let mut receiver=Receivers::new(root.path().to_owned(),bin);
receiver.start_airplay("test").unwrap();
receiver.airplay.as_mut().unwrap().wait().unwrap();
receiver.poll();
let saved=fs::read_to_string(root.path().join("airplay.failed.log")).unwrap();
assert!(saved.contains("autodetect"));
fs::write(&executable,"#!/bin/sh\nexit 0\n").unwrap();
receiver.start_airplay("test").unwrap();
receiver.airplay.as_mut().unwrap().wait().unwrap();
assert_eq!(fs::read_to_string(root.path().join("airplay.failed.log")).unwrap(),saved);
assert!(fs::metadata(root.path().join("airplay.log")).unwrap().len()==0);
receiver.stop();
}
#[test]
fn session_updates_preserve_consent_and_disconnect_cleans_only_its_protocol() {
let root = tempfile::tempdir().unwrap();
let mut r = Receivers::new(root.path().to_owned(), root.path().join("bin"));
+2
View File
@@ -92,6 +92,7 @@ impl Casting {
if let Some(code) = exited {
self.error = Some(format!("串流进程已退出:{code}"));
self.stop_workers();
super::retain_failed_log(&self.directory, "stream");
}
}
pub fn recording_status(&self) -> Value {
@@ -467,6 +468,7 @@ impl Casting {
}
self.error = Some(format!("{e:#}\n{}", detail.trim()));
self.stop_workers();
super::retain_failed_log(&self.directory, "stream");
return Err(anyhow::anyhow!(self.error.clone().unwrap()));
}
result
+92
View File
@@ -3,6 +3,29 @@ use std::time::Duration;
pub(crate) mod github_fallback;
pub(crate) fn with_github_fallback<T>(initial: &str, headers: &[(&str, &str)], proxy: &crate::model::ProxySettings, mut attempt: impl FnMut(&crate::model::ProxySettings) -> Result<T>) -> Result<T> {
if proxy.github_enabled {
let mut selected = proxy.clone();
selected.github_auto_fallback = false;
return match attempt(&selected) {
Err(first)
if proxy.github_auto_fallback
&& github_fallback::eligible(initial, headers)
&& github_fallback::mirror_retryable(&first) =>
{
let mut original = selected;
original.github_enabled = false;
eprintln!("Configured GitHub mirror failed; trying the original URL once");
attempt(&original).map_err(|second| {
anyhow::anyhow!(
"Configured GitHub mirror failed: {}; original GitHub route failed: {}",
github_fallback::diagnostic(&first),
github_fallback::diagnostic(&second)
)
})
}
result => result,
};
}
github_fallback::run(proxy.github_auto_fallback, initial, headers, |fallback| {
let mut route=proxy.clone();route.github_auto_fallback=false;
if fallback {route.github_enabled=true;route.github=github_fallback::PREFIX.into();route.github_fallback_attempt=true;}
@@ -468,6 +491,75 @@ mod proxy_tests {
use super::*;
use crate::model::ProxySettings;
#[test]
fn configured_github_mirror_retries_rate_limit_at_original_url_once() {
let source = "https://api.github.com/repos/SteamFramelyHomebrew/framely/releases?per_page=100&page=1";
let proxy = ProxySettings {
github_enabled: true,
github: "https://gh-proxy.com".into(),
github_auto_fallback: true,
..Default::default()
};
for (code, headers, retry) in [
(403, "X-RateLimit-Remaining: 0\r\n", true),
(429, "", true),
(403, "", false),
(503, "", false),
] {
let mut routes = Vec::new();
let result = with_github_fallback(source, &[], &proxy, |route| {
routes.push(routed_url(&url::Url::parse(source).unwrap(), route));
if !route.github_enabled {
return Ok(7);
}
let response: ureq::Response =
format!("HTTP/1.1 {code} Test\r\n{headers}\r\n").parse().unwrap();
Err(ureq::Error::Status(code, response).into())
});
assert_eq!(result.is_ok(), retry, "HTTP {code} with {headers:?}");
assert_eq!(routes[0], format!("https://gh-proxy.com/{source}"));
assert_eq!(routes.len(), if retry { 2 } else { 1 });
if retry {
assert_eq!(routes[1], source);
}
}
let mut calls = 0;
let mut disabled = proxy.clone();
disabled.github_auto_fallback = false;
assert!(with_github_fallback::<()>(source, &[], &disabled, |_| {
calls += 1;
let response: ureq::Response =
"HTTP/1.1 403 Test\r\nX-RateLimit-Remaining: 0\r\n\r\n".parse().unwrap();
Err(ureq::Error::Status(403, response).into())
}).is_err());
assert_eq!(calls, 1);
}
#[test]
fn configured_github_mirror_retries_network_error_only_for_public_requests() {
let proxy = ProxySettings {
github_enabled: true,
github: "https://gh-proxy.com".into(),
github_auto_fallback: true,
..Default::default()
};
for (source, headers, retry) in [
("https://api.github.com/repos/a/b/releases?page=1", vec![], true),
("https://api.github.com/repos/a/b/releases?token=secret", vec![], false),
("https://api.github.com/repos/a/b/releases?page=1", vec![("Authorization", "secret")], false),
] {
let mut calls = 0;
let result = with_github_fallback(source, &headers, &proxy, |route| {
calls += 1;
if route.github_enabled {
Err(std::io::Error::from(std::io::ErrorKind::ConnectionReset).into())
} else {
Ok(7)
}
});
assert_eq!(result.is_ok(), retry);
assert_eq!(calls, if retry { 2 } else { 1 });
}
}
#[test]
fn proxy_routing_and_validation() {
let proxy = ProxySettings {
github_auto_fallback: true,
+12
View File
@@ -154,6 +154,18 @@ pub fn retryable(error: &anyhow::Error) -> bool {
})
})
}
// A configured GitHub mirror can exhaust a shared API quota. In that case,
// retry the original public URL once, but do not bypass a deliberate HTTP
// error such as 404 or 500 from the selected mirror.
pub fn mirror_retryable(error: &anyhow::Error) -> bool {
retryable(error)
&& !error.chain().any(|cause| {
matches!(
cause.downcast_ref::<ureq::Error>(),
Some(ureq::Error::Status(code, _)) if !matches!(code, 403 | 429)
)
})
}
// Error display from HTTP clients can contain signed redirect URLs. Do not
// surface those URLs when reporting both routes' errors.
pub fn diagnostic(error: &anyhow::Error) -> String {
+8
View File
@@ -1463,6 +1463,14 @@ impl Service {
self.insert_notification("", "Framely", notification, None)?;
Ok(json!(true))
},
"apk.update.notification" => {
ensure!(id.is_empty(), "Core notification only");
let notification: Notification=serde_json::from_value(p["notification"].clone())?;
notification.validate()?;
ensure!(notification.id.starts_with("apk.update."), "Invalid APK update notification");
self.insert_notification("", "Framely", notification, None)?;
Ok(json!(true))
},
"notification.send" => self.notify(&id, p["notification"].clone()),
"notification.remove" | "notification.dismiss" => {
if !id.is_empty() {
+142 -7
View File
@@ -48,6 +48,7 @@ struct Agent {
web_key: String,
network_key: Mutex<String>,
network_status: Mutex<Value>,
apk_updates: Mutex<crate::apk::updates::Windows>,
frame_access: Mutex<std::collections::BTreeMap<String, FrameAccess>>,
native_key: String,
commands: Arc<Mutex<VecDeque<Value>>>,
@@ -533,13 +534,22 @@ impl Agent {
if let Some(id)=views["framingClosed"].as_str().filter(|id|!id.is_empty()) {
self.casting.lock().unwrap().region.acknowledge_editor_closed(id);
}
if let Some(id)=views["apkUpdateClosed"].as_str().filter(|id|!id.is_empty()) {
self.apk_updates.lock().unwrap().close(id);
}
if let Some(id)=views["screenshotClosed"].as_str().filter(|id|!id.is_empty()) {
self.screenshot.lock().unwrap().acknowledge(id);
}
if views["views"].is_object() {
self.core("host.ui.visibility",views["views"].clone())?;
if views.get("views").is_some() {
// Null is the startup state before the first VR frame:
// leave visibility unknown rather than reporting hidden.
ensure!(views["views"].is_null() || views["views"].is_object(), "Invalid native visibility snapshot");
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!({}));}}}
} else if views.is_object() {
ensure!(views.as_object().unwrap().values().all(Value::is_boolean), "Invalid legacy visibility report");
self.core("host.ui.visibility", views)?;
}
}
@@ -838,6 +848,12 @@ impl Agent {
}
if let Some(key) = path.strip_prefix("/boot/") {
ensure!(key == self.web_key, "Invalid boot token");
let query:std::collections::BTreeMap<_,_>=url::form_urlencoded::parse(r.url().split_once('?').map(|(_,q)|q).unwrap_or("").as_bytes()).into_owned().collect();
let update_target=if query.get("view").map(String::as_str)==Some("apk-update") {
let id=query.get("id").context("Missing APK update request")?;
ensure!(id.len()==32&&id.bytes().all(|b|b.is_ascii_hexdigit()),"Invalid APK update request");
Some(format!("/apk/update?id={id}"))
} else { None };
let response = Response::empty(StatusCode(302))
.with_header(header("Cache-Control", "no-store"))
.with_header(header(
@@ -849,7 +865,9 @@ impl Agent {
))
.with_header(header(
"Location",
if r.url().ends_with("?view=radial") {
if let Some(target)=update_target.as_deref() {
target
} else if r.url().ends_with("?view=radial") {
"/system/radial"
} else if r.url().ends_with("?view=radial-progress") {
"/system/radial/progress"
@@ -1241,6 +1259,7 @@ impl Agent {
|| path == "/cast/screenshot"
|| path.starts_with("/cast/window/")
|| path == "/apk/permission"
|| path == "/apk/update"
|| path == "/manager"
|| path == "/notifications"
|| path == "/launcher"
@@ -1643,6 +1662,45 @@ impl Agent {
}
"apk.cleanup.list" => crate::apk::cleanup_list(&crate::steam::home()?),
"apk.storage.status" => crate::apk::storage_status(&crate::steam::home()?),
"apk.updates.window.ready" => {
let id=p["id"].as_str().context("Missing APK update request")?;
crate::apk::updates::presented(&crate::steam::home()?,id)?;
Ok(json!(true))
},
"apk.updates.window.close" => {
let id=p["id"].as_str().context("Missing APK update request")?;
let home=crate::steam::home()?;
if crate::apk::updates::list(&home)?.as_array().is_some_and(|requests|requests.iter().any(|r|r["id"]==id)) {
crate::apk::updates::presented(&home,id)?;
}
self.apk_updates.lock().unwrap().close(id);
push(&self.commands,json!({"kind":"apk.update.close","id":id}));
Ok(json!(true))
},
"apk.updates.list" => crate::apk::updates::list(&crate::steam::home()?),
"apk.updates.review" => {
let home=crate::steam::home()?;
let id=p["id"].as_str().context("Missing APK update request")?.to_owned();
self.jobs.task_progress("apk:inspect", move |cancel, _| crate::apk::updates::review_request(&home,&id,&cancel))
},
"apk.updates.dismiss" => {
let id=p["id"].as_str().context("Missing APK update request")?;
let result=crate::apk::updates::dismiss(&crate::steam::home()?,id)?;
self.core("notification.remove",json!({"plugin":"","id":format!("apk.update.{id}")}))?;
Ok(result)
},
"notification.action" if p["plugin"]=="" && p["id"].as_str().is_some_and(|id|id.starts_with("apk.update.")) => {
let notices=self.core("status",json!({}))?;
ensure!(notices["notifications"].as_array().into_iter().flatten().chain(notices["inbox"].as_array().into_iter().flatten()).any(|n|n["plugin"]==""&&n["notification"]["id"]==p["id"]&&n["createdAt"]==p["createdAt"]),"APK update notification has expired");
let id=p["id"].as_str().unwrap().strip_prefix("apk.update.").unwrap();
match p["action"].as_str() {
Some("review") => {self.apk_updates.lock().unwrap().reopen(id)?;push(&self.commands,json!({"kind":"apk.update.open","id":id}));},
Some("dismiss") => {crate::apk::updates::dismiss(&crate::steam::home()?,id)?;},
_=>anyhow::bail!("Unknown APK update action"),
}
self.core("notification.remove",json!({"plugin":"","id":p["id"]}))?;
Ok(json!(true))
},
"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")?
@@ -2003,7 +2061,7 @@ impl Agent {
view.len() <= 160
&& (matches!(
view,
"menu" | "launcher" | "framely.manager" | "notifications" | "framely.framing"
"menu" | "launcher" | "framely.manager" | "notifications" | "framely.framing" | "framely.apk.update"
) || view.starts_with("framely.window.") || view.starts_with("framely.cast.")),
"Invalid haptic view"
);
@@ -2520,7 +2578,7 @@ pub fn serve(
"UI session must not run as root"
);
crate::gamepad::session_started();
crate::apk::steam_session_started()?;
crate::apk::steam_session_started().context("Initialize Steam APK session bridge")?;
crate::apk::lifecycle_started();
let server = Server::http("127.0.0.1:0").map_err(|e| anyhow::anyhow!(e.to_string()))?;
let port = server
@@ -2536,9 +2594,10 @@ pub fn serve(
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"));
let runtime = runtime.join(format!("framely-session-{}", std::process::id()));
fs::create_dir(&runtime)?;
fs::create_dir(&runtime).with_context(|| format!("Create UI runtime directory {}", runtime.display()))?;
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))?;
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))
.with_context(|| format!("Protect UI runtime directory {}", runtime.display()))?;
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),
@@ -2556,6 +2615,7 @@ pub fn serve(
web_key: web_key.clone(),
network_key: Mutex::new(random_key()),
network_status: Mutex::new(json!({"enabled":false})),
apk_updates: Mutex::default(),
frame_access: Mutex::default(),
native_key: native_key.clone(),
commands: Arc::default(),
@@ -2574,6 +2634,44 @@ pub fn serve(
}
cast_agent.maintain_cast();std::thread::sleep(Duration::from_millis(100));
}});
let update_agent=agent.clone();
std::thread::spawn(move || {
let mut announced=std::collections::BTreeSet::new();
loop {
if let Ok(home)=crate::steam::home() {
match crate::apk::updates::poll(&home) {
Ok(requests)=>{
let current:std::collections::BTreeSet<_>=requests.iter().map(|r|r.id.clone()).collect();
for id in announced.difference(&current) {
let _=update_agent.core("notification.remove",json!({"plugin":"","id":format!("apk.update.{id}")}));
}
announced.retain(|id|current.contains(id));
let (open,failed)={let mut windows=update_agent.apk_updates.lock().unwrap();(windows.next(&requests),windows.take_failure())};
if let Some(id)=open {
eprintln!("APK update request={id} confirmation dispatched");
push(&update_agent.commands,json!({"kind":"apk.update.open","id":id}));
}
if let Some(id)=failed {
eprintln!("APK update request={id} confirmation receipt timed out after three attempts; retained for manual review");
let _=update_agent.core("apk.update.notification",json!({"notification":{"id":format!("apk.update.{id}"),"title":"无法显示应用更新确认","body":"已重试三次,仍未收到确认窗口的展示回执。请查看待更新项目,或从应用重新发起更新。","durationMs":15000,"inbox":true,"actions":[{"id":"review","label":"查看更新"},{"id":"dismiss","label":"忽略"}]}}));
}
for r in requests {
if announced.contains(&r.id) {continue;}
if r.error.is_none() {announced.insert(r.id);continue;}
let (title,body,actions)=if let Some(error)=r.error {
("应用更新提取失败".to_owned(),error.chars().take(1000).collect::<String>(),json!([{"id":"dismiss","label":"忽略"}]))
} else {
(format!("{} 有应用更新",r.name.chars().take(60).collect::<String>()),format!("{} → {},请审核后更新。",r.previous,r.version),json!([{"id":"review","label":"查看更新"},{"id":"dismiss","label":"忽略"}]))
};
if update_agent.core("apk.update.notification",json!({"notification":{"id":format!("apk.update.{}",r.id),"title":title,"body":body,"durationMs":15000,"inbox":true,"actions":actions}})).is_ok() {announced.insert(r.id);}
}
},
Err(error)=>eprintln!("APK update queue check failed: {error:#}"),
}
}
std::thread::sleep(Duration::from_secs(3));
}
});
let permission_agent = agent.clone();
std::thread::spawn(move || {
let mut shown = String::new();
@@ -3114,6 +3212,7 @@ mod tests {
web_key: random_key(),
network_key: Mutex::new(random_key()),
network_status: Mutex::new(json!({"enabled":false})),
apk_updates: Mutex::default(),
frame_access: Mutex::default(),
native_key: random_key(),
commands: Arc::default(),
@@ -3139,6 +3238,27 @@ mod tests {
})
}
#[test]
fn apk_update_boot_authenticates_before_opening_pending_confirmation() {
let root=tempfile::tempdir().unwrap();
let server=Server::http("127.0.0.1:0").unwrap();
let agent=agent(&server,root.path());
let stop=Arc::new(AtomicBool::new(false));
let worker=http_server(server,agent.clone(),stop.clone());
let client=ureq::AgentBuilder::new().redirects(0).timeout(Duration::from_secs(3)).build();
let id="0123456789abcdef0123456789abcdef";
let response=client.get(&format!("{}/boot/{}?view=apk-update&id={id}",agent.origin,agent.web_key)).call().unwrap();
assert_eq!(response.status(),302);
assert_eq!(response.header("Location"),Some(format!("/apk/update?id={id}").as_str()));
let cookie=response.header("Set-Cookie").unwrap().split(';').next().unwrap();
let page=client.get(&format!("{}/apk/update?id={id}",agent.origin)).set("Cookie",cookie).call().unwrap();
assert_eq!(page.status(),200);
assert!(page.into_string().unwrap().contains("/assets/app.js"));
for invalid in ["bad","%2F%2Fevil.example","%0d%0aLocation%3Aevil"] {
assert!(client.get(&format!("{}/boot/{}?view=apk-update&id={invalid}",agent.origin,agent.web_key)).call().is_err());
}
stop.store(true,Ordering::Relaxed);worker.join().unwrap();
}
#[test]
fn framing_apply_restarts_live_stream_and_closes_even_when_restart_fails() {
use std::os::unix::fs::PermissionsExt;
for running in [false,true] {
@@ -4036,6 +4156,9 @@ mod tests {
let socket_worker = std::thread::spawn(move || {
let mut core = crate::service::Service::load(&root.path().join("state"), 1000).unwrap();
for expected in [
"host.notification.badge",
"status",
"launcher.gaze.calibration.get",
"host.ui.visibility",
"host.notification.badge",
"status",
@@ -4074,6 +4197,18 @@ mod tests {
.set("Cookie", &format!("framely={}", agent.web_key))
.send_json(json!({"menu":true}))
.is_err());
// The first native poll precedes the first VR visibility snapshot.
// It must succeed without treating envelope fields as boolean views.
let startup: Value = ureq::post(&url)
.set("X-Framely-Native", &agent.native_key)
.send_json(json!({"views":null,"closedCasts":[],"stopRecording":"","framingClosed":"","screenshotClosed":"","screenshotHotkey":"","radialAction":""}))
.unwrap().into_json().unwrap();
assert_eq!(startup["commands"],json!([framing]));
agent.commands.lock().unwrap().push_back(framing.clone());
for invalid in [json!({"views":[],"closedCasts":[]}),json!({"menu":[],"closedCasts":[]})] {
assert!(ureq::post(&url).set("X-Framely-Native", &agent.native_key)
.send_json(invalid).is_err());
}
let response = ureq::post(&url)
.set("X-Framely-Native", &agent.native_key)
.send_json(json!({"menu":true}))
+18
View File
@@ -49,6 +49,8 @@ pub fn verify_descriptor(bytes: &[u8]) -> Result<Release> {
.all(|c| c.is_ascii_alphanumeric() || b".+-".contains(&c)),
"无效版本号"
);
descriptor_version(&release.version)
.with_context(|| format!("更新清单版本号无效:{}", release.version))?;
crate::model::validate_url(&release.url, false)?;
ensure!(
hex::decode(&release.sha256)?.len() == 32
@@ -886,4 +888,20 @@ mod tests {
value["sha256"] = json!("invalid");
assert!(verify_descriptor(&serde_json::to_vec(&value).unwrap()).is_err());
}
#[test]
fn q2f_archive_cannot_be_used_as_core_update_manifest() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("framely-q2f-overport-1.2.3-linux-arm64.tar.gz");
fs::write(&archive, b"q2f payload").unwrap();
let output = dir.path().join("framely-release.json");
let error = create_descriptor(
&archive,
"https://example.org/framely-q2f-overport-1.2.3-linux-arm64.tar.gz",
&output,
"Notes",
)
.unwrap_err();
assert!(format!("{error:#}").contains("更新清单版本号无效:q2f-overport-1.2.3"));
assert!(!output.exists());
}
}
+72
View File
@@ -0,0 +1,72 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {ApkUpdate} from '../ui/src/apk-update';
import {configureLanguage} from '../ui/src/i18n';
import '../ui/src/style.css';
const id='0123456789abcdef0123456789abcdef';
const metadata={package:'com.example.game',name:'Update test game',version:'1.0',versionCode:1,minSdk:24,activities:['com.example.game.Main'],declaredActivities:['com.example.game.Main'],abis:['arm64-v8a'],vr:false};
const app={id:'test/com.example.game',context:'test',metadata,installed:true,running:false,managed:true,stateKnown:true,size:100};
const request={id,app:app.id,name:metadata.name,previous:'1.0',version:'2.0',bytes:200};
let language='zh-CN';
let pending=true,fail=false,installs=0,closes=0,reviews=0,jobCounter=0,receipts=0;
const jobs=new Map<string,any>();
window.fetch=async(_url,init)=>{
const {method,params:p}=JSON.parse(String(init?.body));let result:any=true;
if(method==='status')result={database:{language}};
else if(method==='language.list')result={packs:[]};
else if(method==='apk.updates.list')result=pending?[request]:[];
else if(method==='apk.q2f.status')result={id:'test',bytes:100,cached:true};
else if(method==='apk.review.drop')result=true;
else if(method==='apk.updates.window.ready'){if(p.id!==id||!document.querySelector('.apk-install-review'))throw Error('Receipt preceded rendered confirmation');receipts++;}
else if(method==='apk.updates.window.close'){if(p.id!==id)throw Error('Wrong close identity');closes++;}
else if(method==='job.status')result=jobs.get(p.job);
else if(method==='apk.list.start'||method==='apk.updates.review'||method==='apk.operation.start'){
let value:any;
if(method==='apk.list.start')value={apps:[app],containers:[{id:'test',name:'test',managed:true,running:false}],roots:[],warnings:[],available:true};
if(method==='apk.updates.review'){reviews++;if(p.id!==id)throw Error('Wrong review identity');value={ticket:'review-'+reviews,metadata:{...metadata,version:'2.0',versionCode:2},bytes:200};}
if(method==='apk.operation.start'){
if(p.operation!=='install'||p.updateRequest!==id||p.app!==app.id||p.context!=='test'||!p.approve)throw Error('Confirmation lost normal installation parameters');
installs++;if(!fail){pending=false;value={backup:'preserved-test-backup'};}
}
const job='job-'+jobCounter++;jobs.set(job,{id:job,kind:'apk',phase:method==='apk.operation.start'&&fail?'failed':'done',error:'Fixture signature mismatch',result:value,received:0});result={job};
}
return new Response(JSON.stringify({result}));
};
const root=createRoot(document.getElementById('root')!);
const wait=()=>new Promise(r=>setTimeout(r,30));
async function until(check:()=>unknown){for(let i=0;i<250;i++){if(check())return;await wait();}throw Error('Timeout: '+document.body.innerText);}
const button=(label:string)=>Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent?.trim()===label)!;
(window as any).runInstallReviewChecks=async()=>{
try{
await until(()=>!!button('确认更新'));
if(document.documentElement.lang!=='zh-CN')throw Error('Saved Chinese language was ignored');
await until(()=>receipts===1);
if(installs!==0||reviews!==1)throw Error('Update installed without explicit confirmation');
if(document.querySelector('.apk-tabs')||document.querySelector('.list-heading'))throw Error('Management panel leaked into update confirmation');
if(!document.body.textContent?.includes('1.0 → 2.0'))throw Error('Version comparison missing');
document.dispatchEvent(new MouseEvent('mousemove',{clientX:150,clientY:180,bubbles:true}));
await until(()=>document.querySelector('.apk-update-pointer'));
const cursor=document.querySelector<HTMLElement>('.apk-update-pointer')!;
const bounds=cursor.getBoundingClientRect();
if(Math.abs(bounds.x+bounds.width/2-150)>1||Math.abs(bounds.y+bounds.height/2-180)>1)throw Error('Laser marker does not match hit coordinates');
if(getComputedStyle(cursor).pointerEvents!=='none'||Number(getComputedStyle(cursor).zIndex)<10000)throw Error('Laser marker obscures input or sits behind confirmation');
if(document.elementFromPoint(150,180)===cursor)throw Error('Marker intercepts clicks');
document.dispatchEvent(new MouseEvent('mouseleave'));
await until(()=>!document.querySelector('.apk-update-pointer'));
console.log('FRAMELY_PREVIEW_INSTALL_APP_UPDATE');
button('取消').click();await until(()=>closes===1);
if(!pending||installs)throw Error('Cancel deleted or installed the queued update');
language='en-US';root.render(<ApkUpdate key="retry" id={id}/>);
await until(()=>reviews===2&&!!button('Confirm update'));
fail=true;button('Confirm update').click();
await until(()=>document.body.textContent?.includes('Fixture signature mismatch'));
if(!pending||closes!==1)throw Error('Failed installation lost queued APK or closed confirmation');
await until(()=>!!button('Confirm update')&&!button('Confirm update').disabled);
fail=false;button('Confirm update').click();
await until(()=>!pending&&!!button('Close')&&!document.querySelector('.apk-install-review'));
if(installs!==2)throw Error('Unexpected install submission count');
button('Close').click();await until(()=>closes===2);
console.log('FRAMELY_BRIDGE_PASS');
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}
};
configureLanguage('en-US',[]);document.documentElement.classList.add('apk-update-view');root.render(<ApkUpdate id={id}/>);console.log('FRAMELY_VIEW_READY');
+1 -1
View File
@@ -51,5 +51,5 @@ if(s=="FRAMELY_DPI_CLICK_REQUEST"){auto* host=b->get_host(b);cef_mouse_event_t m
auto end=std::chrono::steady_clock::now()+std::chrono::seconds(45);bool injected=false;
while(std::chrono::steady_clock::now()<end&&!passed&&!failed){cef_do_message_loop_work();if(press_pending){auto now=std::chrono::steady_clock::now();cef_mouse_event_t mouse{};mouse.x=test_pointer.x+press_dx;mouse.y=test_pointer.y+press_dy;test_pointer.constrain(mouse.x,mouse.y,now);mouse.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;host->send_mouse_move_event(host,&mouse,0);if(now-test_pointer.pressed>=std::chrono::milliseconds(press_duration)){mouse.modifiers=0;host->send_mouse_click_event(host,&mouse,MBT_LEFT,1,1);test_pointer.clear();press_pending=false;}}if(ready&&!injected){auto* f=browser->get_main_frame(browser);cef_string_t code{},source{};str(code,install_review_mode?"window.runInstallReviewChecks()":notification_mode?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));for(let i=0;i<100&&!document.querySelector('.toast');i++)await wait(30);if(innerWidth!==600||innerHeight!==360||devicePixelRatio!==2)throw Error('notification dimensions');const toast=document.querySelector('.toast');if(!toast?.textContent.includes('Toast integration'))throw Error('notification missing');if(toast.getBoundingClientRect().height>240)throw Error('notification too large');if(toast.querySelectorAll('.toast-actions button').length!==3)throw Error('actions missing');toast.querySelector('[aria-label="确认"]').click();await wait(150);document.querySelector('[aria-label="关闭通知"]').click();await wait(150);const status=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method:'status',params:{}})}).then(r=>r.json());if(status.result.notifications.length)throw Error('notification not withdrawn');console.log('FRAMELY_BRIDGE_PASS');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_REMOTE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));await wait(300);const button=document.createElement('button');button.style.cssText='position:fixed;left:20px;top:20px;width:100px;height:50px;z-index:9999';button.onclick=()=>console.log('FRAMELY_DPI_CLICK_PASS');document.body.append(button);console.log('FRAMELY_DPI_CLICK_REQUEST');await wait(200);button.remove();for(let i=0;i<100;i++){const plugin=document.querySelector('.plugin-main');if(plugin){plugin.click();return;}await wait(50);}throw Error('remote plugin list timeout');})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e)))JS":std::getenv("FRAMELY_STORE_TEST")?R"JS((async()=>{const wait=ms=>new Promise(r=>setTimeout(r,ms));const until=async find=>{for(let i=0;i<160;i++){const value=find();if(value)return value;await wait(50);}throw Error("store timeout "+document.body.innerText);};await wait(300);if(innerWidth!==1440||innerHeight!==810||devicePixelRatio!==4)throw Error("manager layout or DPI mismatch");[...document.querySelectorAll("nav button")].find(b=>b.textContent==="插件源").click();await wait(200);if(!document.body.textContent.includes("Subscription fixture"))throw Error("subscription settings missing");const autoRefresh=[...document.querySelectorAll("button")].find(b=>b.textContent==="关闭自动刷新");if(!autoRefresh)throw Error("subscription automatic refresh missing");autoRefresh.click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="开启自动刷新"));[...document.querySelectorAll("nav button")].find(b=>b.textContent==="关于").click();await wait(150);const updates=document.querySelector(".system-updates");if(!updates||updates.querySelector("input:not([type=checkbox]),textarea"))throw Error("release configuration leaked into user settings");if(!updates.querySelector(".automatic-update-settings input[type=checkbox]")?.disabled)throw Error("automatic check controls enabled without source");if(!updates.textContent.includes("尚未配置更新服务"))throw Error("missing unconfigured update service message");const check=[...updates.querySelectorAll("button")].find(b=>b.textContent==="检查更新");if(!check?.disabled)throw Error("unconfigured check enabled");[...document.querySelectorAll("button")].find(b=>b.textContent==="查看协议与隐私声明").click();await until(()=>[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议"));document.querySelector('.language-picker button').click();const english=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='English'));english.click();await until(()=>document.querySelector('h1')?.textContent==='Before using Framely');document.querySelector('.language-picker button').click();const chinese=await until(()=>[...document.querySelectorAll('[role=option]')].find(b=>b.textContent==='简体中文'));chinese.click();await until(()=>[...document.querySelectorAll('button')].find(b=>b.textContent==='拒绝协议'));[...document.querySelectorAll("button")].find(b=>b.textContent==="拒绝协议").click();await until(()=>document.body.textContent.includes("拒绝协议将停用全部插件"));[...document.querySelectorAll("button")].fiLine truncated
if(!passed&&!failed){auto* f=browser->get_main_frame(browser);cef_string_t diagnostic{},source{};str(diagnostic,"console.error('FRAMELY_TIMEOUT '+location.pathname.replace(/\\/boot\\/[^/]+/,'/boot/token')+' '+document.body.innerText.slice(0,400))");f->execute_java_script(f,&diagnostic,&source,1);cef_string_utf16_clear(&diagnostic);f->base.release(&f->base);for(int i=0;i<40;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}snapshot("FAILURE");}
host->close_browser(host,1);for(int i=0;i<400&&!closed;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}bool ok=passed&&!failed&&paints>0&&closed&&scaled_paint&&(notification_mode||rounded_paint)&&(notification_mode||install_review_mode||std::getenv("FRAMELY_STORE_TEST")||dpi_click);std::cout<<"BROWSER_RESULT pass="<<ok<<" paints="<<paints<<" closed="<<closed<<"\n";if(!closed)_Exit(5);host->base.release(&host->base);browser->base.release(&browser->base);cef_shutdown();return ok?0:5;
host->close_browser(host,1);for(int i=0;i<400&&!closed;i++){cef_do_message_loop_work();std::this_thread::sleep_for(std::chrono::milliseconds(5));}bool ok=passed&&!failed&&paints>0&&closed&&scaled_paint&&(notification_mode||std::getenv("FRAMELY_APK_UPDATE_TEST")||rounded_paint)&&(notification_mode||install_review_mode||std::getenv("FRAMELY_STORE_TEST")||dpi_click);std::cout<<"BROWSER_RESULT pass="<<ok<<" paints="<<paints<<" closed="<<closed<<"\n";if(!closed)_Exit(5);host->base.release(&host->base);browser->base.release(&browser->base);cef_shutdown();return ok?0:5;
}
+124
View File
@@ -7,6 +7,9 @@ import pathlib
import tarfile
import tempfile
import unittest
import errno
import http.client
import urllib.error
from types import SimpleNamespace
from unittest.mock import mock_open, patch
@@ -16,6 +19,92 @@ spec.loader.exec_module(bootstrap)
class BootstrapTests(unittest.TestCase):
def test_incomplete_metadata_has_actionable_error(self):
with self.assertRaises(bootstrap.InstallationFailure) as failure:
with bootstrap.installation_step('Read release metadata', 'Retry or choose another mirror.'):
raise http.client.IncompleteRead(b'partial', 10)
self.assertIn('Retry or choose another mirror.', str(failure.exception))
def test_interactive_release_lookup_retries_partial_transfer(self):
releases = [{'tag_name': 'v0.8.3', 'draft': False, 'prerelease': False,
'assets': [{'name': name, 'size': 1024} for name in (
'bootstrap.py', 'SHA256SUMS',
'framely-0.8.3-012345abcdef-linux-arm64.tar.gz')]}]
with patch.object(bootstrap, 'read_metadata', side_effect=[
http.client.IncompleteRead(b'partial', 10), json.dumps(releases).encode()]) as read, \
patch.object(bootstrap.time, 'sleep'):
self.assertEqual(bootstrap.resolve_release('owner/repo', 'stable', 'update')['tag'], 'v0.8.3')
self.assertEqual(read.call_count, 2)
def test_explicit_direct_route_overrides_saved_proxy(self):
with patch.dict(bootstrap.os.environ, {'FRAMELY_GITHUB_PROXY': '',
'FRAMELY_GITHUB_AUTO_FALLBACK': '0'}), \
patch.object(bootstrap, 'device_proxy_settings', return_value={
'githubEnabled': True, 'github': 'https://proxy.example'}):
self.assertEqual(bootstrap.configured_github_proxy(), '')
self.assertFalse(bootstrap.fallback_enabled())
def test_explicit_preview_uses_release_assets_without_api(self):
tag = 'v0.8.4-preview.1'
package = 'framely-0.8.4-preview.1-012345abcdef-linux-arm64.tar.gz'
checksums = hashlib.sha256(b'archive').hexdigest() + ' ' + package + '\n'
urls = []
def download(url, destination, _limit, _progress=None, expected_size=None):
urls.append(url)
pathlib.Path(destination).write_bytes(checksums.encode() if url.endswith('SHA256SUMS') else b'archive')
with tempfile.TemporaryDirectory() as tmp, \
patch.dict(bootstrap.os.environ, {}, clear=True), \
patch.object(bootstrap.pathlib.Path, 'home', return_value=pathlib.Path(tmp)), \
patch.object(bootstrap.os, 'uname', return_value=SimpleNamespace(machine='aarch64')), \
patch.object(bootstrap.os, 'geteuid', return_value=0), \
patch.object(bootstrap, 'read_metadata', side_effect=AssertionError('API must not be used')), \
patch.object(bootstrap, 'download', side_effect=download), \
patch.object(bootstrap, 'root_operation') as operation:
bootstrap.main(['update', '--version', tag, '--github-proxy', 'https://gh-proxy.com', '--yes'])
operation.assert_called_once()
self.assertEqual(urls, [
f'https://github.com/{bootstrap.DEFAULT_REPO}/releases/download/{tag}/SHA256SUMS',
f'https://github.com/{bootstrap.DEFAULT_REPO}/releases/download/{tag}/{package}',
])
def test_explicit_tag_requires_one_matching_package(self):
package = 'framely-0.8.4-preview.1-012345abcdef-linux-arm64.tar.gz'
offline = 'framely-0.8.4-preview.1-012345abcdef-offline-linux-arm64.tar.gz'
with tempfile.TemporaryDirectory() as tmp:
sums = pathlib.Path(tmp) / 'SHA256SUMS'
sums.write_text('0' * 64 + ' framely-0.8.3-012345abcdef-linux-arm64.tar.gz\n')
with self.assertRaisesRegex(ValueError, 'one matching ARM64 package'):
bootstrap.package_from_checksums(sums, 'v0.8.4-preview.1', 'update')
sums.write_text('0' * 64 + ' ' + package + '\n' + '1' * 64 + ' ' + package + '\n')
with self.assertRaisesRegex(ValueError, 'duplicate SHA256SUMS'):
bootstrap.package_from_checksums(sums, 'v0.8.4-preview.1', 'update')
sums.write_text('0' * 64 + ' ' + package + '\n' + '1' * 64 + ' ' + offline + '\n')
self.assertEqual(bootstrap.package_from_checksums(sums, 'v0.8.4-preview.1', 'update'), package)
self.assertEqual(bootstrap.package_from_checksums(sums, 'v0.8.4-preview.1', 'install'), offline)
def test_interactive_release_selection_uses_semver_and_installable_assets(self):
def release(tag, prerelease=False, missing_offline=False):
version = tag.removeprefix('v')
files = ['bootstrap.py', 'SHA256SUMS',
f'framely-{version}-012345abcdef-linux-arm64.tar.gz']
if not missing_offline:
files.append(f'framely-{version}-012345abcdef-offline-linux-arm64.tar.gz')
else:
files.append('framely-cef.json')
return {'tag_name': tag, 'draft': False, 'prerelease': prerelease,
'assets': [{'name': name, 'size': 1024} for name in files]}
releases = [release('v0.8.9'), release('v0.9.0-preview.2', True),
release('v0.9.0-preview.10', True),
release('v1.0.0', missing_offline=True), release('v2.0.0', True)]
with patch.object(bootstrap, 'read_metadata', return_value=json.dumps(releases).encode()):
self.assertEqual(bootstrap.resolve_release('owner/repo', 'stable', 'install')['tag'], 'v0.8.9')
self.assertEqual(bootstrap.resolve_release('owner/repo', 'testing', 'install')['tag'], 'v0.9.0-preview.10')
self.assertEqual(bootstrap.resolve_release('owner/repo', 'stable', 'update')['tag'], 'v1.0.0')
self.assertGreater(bootstrap.release_version('v0.9.0-preview.10'),
bootstrap.release_version('v0.9.0-preview.2'))
def test_explicit_install_and_update_skip_confirmation(self):
with tempfile.TemporaryDirectory() as tmp:
archive = pathlib.Path(tmp) / 'package.tar.gz'
@@ -136,6 +225,41 @@ class BootstrapTests(unittest.TestCase):
payload = json.loads(output.getvalue().removeprefix('FRAMELY_PROGRESS '))
self.assertEqual(payload, {'step': 'configure'})
def test_installation_failure_explains_step_cause_and_next_action_without_url(self):
with self.assertRaises(bootstrap.InstallationFailure) as failure:
with bootstrap.installation_step('下载安装包', '更换代理后重试。'):
raise urllib.error.HTTPError('https://example.invalid/?token=secret', 502, '', {}, None)
message = str(failure.exception)
self.assertIn('下载安装包失败', message)
self.assertIn('HTTP 502', message)
self.assertIn('更换代理后重试', message)
self.assertNotIn('secret', message)
with self.assertRaises(bootstrap.InstallationFailure) as failure:
with bootstrap.installation_step('下载安装包', '释放空间后重试。'):
raise OSError(errno.ENOSPC, 'disk full')
self.assertIn('设备存储空间不足', str(failure.exception))
self.assertIn('errno=28', str(failure.exception))
with self.assertRaises(bootstrap.InstallationFailure) as failure:
with bootstrap.installation_step('读取发行版信息', '检查连接后重试。'):
raise ValueError('bad URL https://example.invalid/?token=secret')
self.assertNotIn('secret', str(failure.exception))
def test_terminal_download_progress_reports_bytes_and_completion(self):
output = io.StringIO()
with contextlib.redirect_stdout(output):
progress = bootstrap.terminal_download_progress(2 * 1024**2)
progress(0)
progress(2 * 1024**2)
self.assertIn('0.0 / 2.0 MiB', output.getvalue())
self.assertIn('100.0%', output.getvalue())
output = io.StringIO()
with contextlib.redirect_stdout(output):
progress = bootstrap.terminal_download_progress(None)
progress(0)
progress(2 * 1024**2)
self.assertIn('总大小待确认', output.getvalue())
self.assertNotIn('100.0%', output.getvalue())
if __name__ == '__main__':
unittest.main()
+36
View File
@@ -20,6 +20,42 @@ class DiagnosticsTests(unittest.TestCase):
self.assertTrue(result['name'].endswith('.zip'))
return zipfile.ZipFile(io.BytesIO(base64.b64decode(result['data'])))
def test_casting_logs_include_failure_tails_but_exclude_media_and_access_files(self):
with tempfile.TemporaryDirectory() as temp:
root = pathlib.Path(temp)
session = root/'runtime/1000/framely-session-12'
for area, names in [('receive', ('airplay','receiver')), ('cast', ('stream',))]:
directory=session/area
directory.mkdir(parents=True)
for name in names:
(directory/(name+'.log')).write_text(name+' current')
(directory/(name+'.failed.log')).write_text(name+' previous failure token=PRIVATE_TOKEN')
for name in ['frame.bin','video.mp4','events.sock','session.accept','access.json','unknown.log']:
(directory/name).write_text('DO_NOT_EXPORT')
(session/'receive/symlink.log').symlink_to(session/'receive/access.json')
archive=self.archive(root)
for area,names in [('receive',('airplay','receiver')),('cast',('stream',))]:
for name in names:
prefix='casting/1000/framely-session-12/'+area+'/'+name
self.assertIn(prefix+'.log',archive.namelist())
self.assertIn('previous failure',archive.read(prefix+'.failed.log').decode())
text='\n'.join(archive.read(name).decode() for name in archive.namelist())
self.assertNotIn('DO_NOT_EXPORT',text)
self.assertNotIn('PRIVATE_TOKEN',text)
def test_casting_export_selects_recent_sessions_by_time_not_pid(self):
import os
with tempfile.TemporaryDirectory() as temp:
root=pathlib.Path(temp)
for index in range(9):
session=root/'runtime/1000'/('framely-session-'+str(9000-index*1000))
(session/'receive').mkdir(parents=True)
(session/'receive/airplay.log').write_text(str(index))
os.utime(session,(index+1,index+1))
archive=self.archive(root)
self.assertNotIn('casting/1000/framely-session-9000/receive/airplay.log',archive.namelist())
self.assertIn('casting/1000/framely-session-1000/receive/airplay.log',archive.namelist())
def test_stopped_service_partial_logs_and_credentials(self):
with tempfile.TemporaryDirectory() as temp:
root = pathlib.Path(temp)
+4 -1
View File
@@ -142,8 +142,11 @@ exit {code}
python = root / 'python3'; python.write_text('#!/bin/sh\nexit 0\n')
curl.chmod(0o755); python.chmod(0o755)
env = dict(os.environ, PATH=temp + os.pathsep + os.environ['PATH'], FRAMELY_GITHUB_AUTO_FALLBACK=enabled)
subprocess.run(['bash', str(ROOT / 'install.sh')], env=env, capture_output=True)
result = subprocess.run(['bash', str(ROOT / 'install.sh'), 'install'], env=env, capture_output=True, text=True)
self.assertEqual(len(log.read_text().splitlines()), attempts)
if attempts == 1:
self.assertIn('Failed to download the installer engine', result.stderr)
self.assertIn('then retry', result.stderr)
if __name__ == '__main__':
+9 -1
View File
@@ -34,12 +34,20 @@ class InstallEntryTests(unittest.TestCase):
self.assertEqual(calls['url'], 'https://gh-proxy.com/https://github.com/TestOwner/framely/releases/latest/download/bootstrap.py')
self.assertEqual(calls['proxy'], 'https://gh-proxy.com')
self.assertEqual(calls['args'], ['--repo', 'TestOwner/framely', *args])
self.assertRegex(calls['trace'], r'^[0-9]{8}T[0-9]{6}Z-[0-9]+$')
self.assertIn(calls['trace'], result.stdout)
for invalid in ['http://gh-proxy.com', 'https://gh-proxy.com/../bad', 'https://gh-proxy.com?key=1', '']:
with self.subTest(invalid=invalid):
result, calls = self.run_entry(['install', '--github-proxy', invalid])
self.assertNotEqual(result.returncode, 0)
self.assertEqual(calls, {})
def test_proxy_path_is_not_written_to_installer_log(self):
result, calls = self.run_entry(['install', '--github-proxy', 'https://proxy.example/private-route'])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn('private-route', result.stdout + result.stderr)
self.assertEqual(calls['proxy'], 'https://proxy.example/private-route')
def run_entry(self, args):
with tempfile.TemporaryDirectory() as temp:
root = pathlib.Path(temp)
@@ -48,7 +56,7 @@ class InstallEntryTests(unittest.TestCase):
curl.write_text('''#!/usr/bin/env python3
import json, os, pathlib, sys
args = sys.argv[1:]
pathlib.Path(os.environ['FRAMELY_ENTRY_LOG']).write_text(json.dumps({'url': args[-1], 'proxy': os.environ.get('FRAMELY_GITHUB_PROXY', '')}))
pathlib.Path(os.environ['FRAMELY_ENTRY_LOG']).write_text(json.dumps({'url': args[-1], 'proxy': os.environ.get('FRAMELY_GITHUB_PROXY', ''), 'trace': os.environ.get('FRAMELY_INSTALL_TRACE_ID', '')}))
pathlib.Path(args[args.index('--output') + 1]).write_text("import json,os,pathlib,sys; p=pathlib.Path(os.environ['FRAMELY_ENTRY_LOG']); d=json.loads(p.read_text()); d['args']=sys.argv[1:]; p.write_text(json.dumps(d))")
''')
curl.chmod(0o755)
+165
View File
@@ -0,0 +1,165 @@
"""Exercise the public installer wizard through a controlling pseudo-terminal."""
import errno
import fcntl
import json
import os
import pathlib
import pty
import subprocess
import tempfile
import termios
import unittest
ROOT = pathlib.Path(__file__).parents[1]
class InstallWizardTests(unittest.TestCase):
def test_no_terminal_explains_how_to_run(self):
result = subprocess.run(['bash', str(ROOT / 'install.sh')], capture_output=True,
text=True, preexec_fn=os.setsid)
self.assertNotEqual(result.returncode, 0)
self.assertIn('interactive installer needs a terminal', result.stderr)
def run_wizard(self, answers, installed=False, damaged=False, local_bundle=False):
with tempfile.TemporaryDirectory() as temp:
root = pathlib.Path(temp)
state = root / 'state'
releases = root / 'releases'
legacy = root / 'legacy-current'
state.mkdir()
releases.mkdir()
if installed or damaged:
current = releases / 'v0.8.3-test'
current.mkdir()
if installed:
(current / 'VERSION').write_text('0.8.3-test')
(current / 'uninstall.sh').write_text('#!/bin/sh\nexit 0\n')
(state / 'current').symlink_to('releases/v0.8.3-test')
# The real state layout keeps releases under the state root.
(state / 'releases').symlink_to(releases)
script = root / 'install.sh'
script.write_text((ROOT / 'install.sh').read_text()
.replace('/home/.framely/state', str(state))
.replace('/home/.framely/releases', str(releases))
.replace('/home/.framely/repair.sh', str(root / 'repair.sh'))
.replace('/var/lib/framely/current', str(legacy)))
engine_source = '''
import json, os, pathlib, sys
args=sys.argv[1:]
with open(os.environ['WIZARD_LOG'], 'a') as out:
out.write(json.dumps({'engine_args':args, 'proxy':os.environ.get('FRAMELY_GITHUB_PROXY',''),
'fallback':os.environ.get('FRAMELY_GITHUB_AUTO_FALLBACK','')})+'\\n')
if '--resolve-release' in args:
print(json.dumps({'tag':'v0.9.0-preview.10','size':1048576}))
'''
curl = root / 'curl'
curl.write_text('''#!/usr/bin/env python3
import json, os, pathlib, sys
args=sys.argv[1:]
with open(os.environ['WIZARD_LOG'], 'a') as out:
out.write(json.dumps({'url':args[-1], 'proxy':os.environ.get('FRAMELY_GITHUB_PROXY',''),
'fallback':os.environ.get('FRAMELY_GITHUB_AUTO_FALLBACK','')})+'\\n')
pathlib.Path(args[args.index('--output')+1]).write_text(''' + repr(engine_source) + ''')
''')
curl.chmod(0o755)
bundle = root / 'bundle'
if local_bundle:
bundle.mkdir()
(bundle / 'bootstrap.py').write_text(engine_source)
(bundle / 'SHA256SUMS').write_text('test\n')
suffix = '-linux-arm64.tar.gz' if installed else '-offline-linux-arm64.tar.gz'
(bundle / ('framely-0.9.0-preview.10-012345abcdef' + suffix)).write_bytes(b'archive')
sudo = root / 'sudo'
sudo.write_text('#!/bin/sh\nexec "$@"\n')
sudo.chmod(0o755)
log = root / 'calls'
env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'],
WIZARD_LOG=str(log), FRAMELY_REPOSITORY='TestOwner/framely')
env.pop('FRAMELY_GITHUB_PROXY', None)
master, slave = pty.openpty()
def controlling_terminal():
os.setsid()
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
try:
command = ['bash', str(script)]
if local_bundle:
command += ['--local-bundle', str(bundle)]
process = subprocess.Popen(command, stdin=slave, stdout=slave,
stderr=slave, env=env, preexec_fn=controlling_terminal)
os.close(slave)
os.write(master, answers.encode())
code = process.wait(timeout=10)
output = bytearray()
while True:
try:
block = os.read(master, 65536)
except OSError as error:
if error.errno == errno.EIO:
break
raise
if not block:
break
output.extend(block)
finally:
os.close(master)
calls = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else []
return code, output.decode(errors='replace'), calls
def test_fresh_install_with_mirror_and_confirmation(self):
code, output, calls = self.run_wizard('1\n2\n2\n\n1\n')
self.assertEqual(code, 0, output)
self.assertIn('Target release: v0.9.0-preview.10', output)
self.assertEqual(len([call for call in calls if 'url' in call]), 2)
self.assertTrue(all(call['url'].startswith('https://gh-proxy.com/') for call in calls if 'url' in call))
self.assertIn('install', calls[-1]['engine_args'])
self.assertIn('--yes', calls[-1]['engine_args'])
def test_existing_install_direct_and_back_navigation(self):
code, output, calls = self.run_wizard('1\nb\n2\nb\n1\n1\nb\nb\nb\nq\n', installed=True)
self.assertEqual(code, 0, output)
self.assertEqual(calls, [])
self.assertIn('Update Framely', output)
self.assertIn('Uninstall Framely', output)
code, output, calls = self.run_wizard('1\n1\n1\n1\n', installed=True)
self.assertEqual(code, 0, output)
self.assertIn('update', calls[-1]['engine_args'])
self.assertTrue(all(call['fallback'] == '0' for call in calls))
self.assertTrue(all(call['proxy'] == '' for call in calls))
def test_damaged_state_stops_before_network(self):
code, output, calls = self.run_wizard('', damaged=True)
self.assertNotEqual(code, 0)
self.assertIn('Installation records are incomplete', output)
self.assertEqual(calls, [])
def test_uninstall_uses_installed_script_without_network(self):
code, output, calls = self.run_wizard('2\n1\n', installed=True)
self.assertEqual(code, 0, output)
self.assertEqual(calls, [])
def test_bad_mirror_address_can_be_corrected(self):
code, output, calls = self.run_wizard('1\n1\n2\nhttp://invalid\nb\n1\n1\n')
self.assertEqual(code, 0, output)
self.assertIn('Invalid address', output)
self.assertTrue(all(call['fallback'] == '0' for call in calls))
def test_local_bundle_runs_full_interactive_flow_without_fetching_engine(self):
code, output, calls = self.run_wizard('1\n2\n1\n1\n', installed=True, local_bundle=True)
self.assertEqual(code, 0, output)
self.assertIn('Package source: Local staged archive', output)
self.assertFalse(any('url' in call for call in calls))
self.assertIn('--archive', calls[-1]['engine_args'])
self.assertIn('--checksums', calls[-1]['engine_args'])
self.assertIn('update', calls[-1]['engine_args'])
def test_local_bundle_first_install_selects_offline_archive(self):
code, output, calls = self.run_wizard('1\n1\n1\n1\n', local_bundle=True)
self.assertEqual(code, 0, output)
self.assertIn('-offline-linux-arm64.tar.gz', calls[-1]['engine_args'][calls[-1]['engine_args'].index('--archive') + 1])
if __name__ == '__main__':
unittest.main()
+24
View File
@@ -0,0 +1,24 @@
"""Check the actual installed UI unit waits for the user runtime provider."""
import configparser
import pathlib
import subprocess
import unittest
class SessionUnitTests(unittest.TestCase):
def test_session_requires_and_orders_after_user_manager(self):
script = (pathlib.Path(__file__).parents[1] / 'packaging/install.sh').read_text()
body = script.split('cat > /etc/systemd/system/framely-session.service <<EOF\n', 1)[1].split('\nEOF', 1)[0]
result = subprocess.run(
['bash', '-eu', '-c', 'steam_uid=1000; steam_user=steamos; steam_home=/home/steamos; root=/var/lib/framely\ncat <<EOF\n' + body + '\nEOF\n'],
capture_output=True, text=True, check=True,
)
unit = configparser.ConfigParser(interpolation=None, strict=False)
unit.read_string(result.stdout)
for dependency in ['framely.service', f'user@{1000}.service']:
self.assertIn(dependency, unit['Unit']['Requires'].split())
self.assertIn(dependency, unit['Unit']['After'].split())
if __name__ == '__main__':
unittest.main()
+300 -18
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
"""Shared device-side installation engine, also embedded in the desktop installer."""
import argparse
import contextlib
import datetime
import errno
import hashlib
import json
@@ -16,12 +18,24 @@ import time
import urllib.parse
import urllib.request
import urllib.error
import uuid
import socket
import ssl
import http.client
DEFAULT_REPO = 'SteamFramelyHomebrew/framely'
MAX_ARCHIVE = 2 * 1024**3
TRACE_ID = uuid.uuid4().hex[:12]
def log_event(message):
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
print(f'[{timestamp}] Framely[{TRACE_ID}] {message}', flush=True)
def log_failure(message):
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
print(f'[{timestamp}] Framely[{TRACE_ID}] {message}', file=sys.stderr, flush=True)
def emit_progress(step, completed=None, total=None):
@@ -31,6 +45,107 @@ def emit_progress(step, completed=None, total=None):
print('FRAMELY_PROGRESS ' + json.dumps(payload), flush=True)
def terminal_download_progress(total):
last_reported = 0.0
def report(completed):
nonlocal last_reported
now = time.monotonic()
if completed != 0 and completed != total and now - last_reported < 5:
return
last_reported = now
if total is None:
log_event(f'下载进度 {completed / 1024**2:.1f} MiB(总大小待确认)')
else:
percent = completed * 100 / total
log_event(f'下载进度 {percent:5.1f}% {completed / 1024**2:.1f} / {total / 1024**2:.1f} MiB')
return report
class InstallationFailure(RuntimeError):
pass
def failure_reason(error):
if isinstance(error, urllib.error.HTTPError):
if error.code == 404:
return 'HTTP 404:请求的版本或发布文件不存在。'
if error.code == 403:
return 'HTTP 403:下载服务拒绝访问,可能触发了访问限制。'
if error.code == 429:
return 'HTTP 429:下载服务正在限流。'
return f'HTTP {error.code}:下载服务返回错误。'
if isinstance(error, urllib.error.URLError):
return failure_reason(error.reason)
if isinstance(error, (ssl.SSLCertVerificationError, ssl.CertificateError)):
return 'TLS 证书验证失败;请检查设备时间、证书和代理。'
if isinstance(error, socket.gaierror):
return '域名解析失败;请检查网络和代理地址。'
if isinstance(error, (TimeoutError, ConnectionError, http.client.IncompleteRead,
http.client.RemoteDisconnected)):
return '连接超时、中断或文件传输不完整。'
if isinstance(error, OSError):
if error.errno == errno.ENOSPC:
return '设备存储空间不足。'
if error.errno in (errno.EACCES, errno.EPERM):
return '没有写入安装文件的权限。'
if error.errno == errno.ENOENT:
return '安装暂存文件或目录不存在。'
if error.errno == errno.EIO:
return '存储设备读写失败。'
if isinstance(error, ValueError) and 'SHA256 mismatch' in str(error):
return '文件校验不通过;下载内容可能损坏或被代理修改。'
if isinstance(error, (KeyError, TypeError)):
return '发行版信息缺少必要字段或格式无效。'
if isinstance(error, subprocess.CalledProcessError):
return '安装子程序执行失败;详细错误见上方输出。'
# The dual-route failure already contains only sanitized error types.
if isinstance(error, RuntimeError) and 'GitHub primary route failed:' in str(error):
return str(error)
if isinstance(error, ValueError):
if str(error) == 'Cancelled':
return '操作已由用户取消。'
if str(error).startswith(('No interactive terminal', 'No confirmation input')):
return '缺少交互终端或确认输入。'
if str(error) == 'Download exceeds size limit':
return '下载文件超过允许大小。'
if str(error) == 'Download temporary file identity changed':
return '下载暂存文件在传输期间被替换。'
return '配置或下载数据格式无效。'
return f'{type(error).__name__}'
def failure_diagnostic(error):
if isinstance(error, subprocess.CalledProcessError):
return f'{type(error).__name__} exit={error.returncode}'
if isinstance(error, urllib.error.URLError):
return failure_diagnostic(error.reason) if not isinstance(error, urllib.error.HTTPError) else f'HTTP {error.code}'
if isinstance(error, OSError) and error.errno is not None:
return f'{type(error).__name__} errno={error.errno}'
if isinstance(error, RuntimeError) and 'GitHub primary route failed:' in str(error):
return str(error)
return type(error).__name__
@contextlib.contextmanager
def installation_step(stage, next_action):
started = time.monotonic()
log_event(f'{stage}:开始')
try:
yield
except (OSError, ValueError, RuntimeError, KeyError, TypeError,
subprocess.CalledProcessError, http.client.IncompleteRead) as error:
reason = failure_reason(error)
diagnostic = failure_diagnostic(error)
raise InstallationFailure(
f'{stage}失败(耗时 {time.monotonic() - started:.1f} 秒)。\n原因:{reason}'
f'\n诊断:{diagnostic}\n下一步:{next_action}'
) from None
else:
log_event(f'{stage}:完成,耗时 {time.monotonic() - started:.1f} 秒')
class HTTPSRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
if urllib.parse.urlparse(newurl).scheme != 'https':
@@ -78,6 +193,14 @@ def configured_github_proxy():
return github_proxy_url(value) if value.strip() else ''
def download_route():
proxy = configured_github_proxy()
if proxy:
host = urllib.parse.urlsplit(proxy).hostname
return 'gh-proxy.com' if host == 'gh-proxy.com' else '自定义 GitHub 代理(地址已隐藏)'
return 'GitHub 直连(自动代理回退已开启)' if fallback_enabled() else 'GitHub 直连'
def public_github(url):
parsed = urllib.parse.urlsplit(url)
return (parsed.scheme == 'https' and parsed.hostname in GITHUB_HOSTS
@@ -118,7 +241,7 @@ def github_attempt(url, operation, enabled=None):
except Exception as primary:
if configured_github_proxy() or not (fallback_enabled() if enabled is None else enabled) or not public_github(url) or not retryable(primary):
raise
print('GitHub connection failed; trying gh-proxy.com once', file=sys.stderr, flush=True)
log_failure(f'GitHub 请求失败({network_diagnostic(primary)}),正在尝试 gh-proxy.com')
try:
return operation(True)
except Exception as secondary:
@@ -281,6 +404,102 @@ def select_package(assets, action):
return selected[0]
def package_from_checksums(checksums, tag, action):
"""Find a tagged release package without relying on the GitHub Releases API."""
plain_tag = tag[1:] if tag.startswith('v') else tag
package_name = re.compile(r'framely-' + re.escape(plain_tag)
+ r'-[a-f0-9]{12}(?:-offline)?-linux-arm64\.tar\.gz')
names = set()
for line in pathlib.Path(checksums).read_text(encoding='utf-8').splitlines():
match = re.fullmatch(r'([a-fA-F0-9]{64}) [ *]([a-zA-Z0-9._+-]+)', line)
if not match or match[2] in names:
raise ValueError('Invalid or duplicate SHA256SUMS entry')
names.add(match[2])
assets = {name: {} for name in names
if package_name.fullmatch(name) or name == 'framely-cef.json'}
assets['SHA256SUMS'] = {}
return select_package(assets, action)
def release_version(tag):
"""Return a SemVer precedence key, excluding build metadata."""
match = re.fullmatch(
r'v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?', tag)
if not match:
return None
identifiers = []
for part in (match[4] or '').split('.') if match[4] else []:
if part.isdigit():
if len(part) > 1 and part.startswith('0'):
return None
identifiers.append((0, int(part)))
else:
identifiers.append((1, part))
# A final release has higher precedence than its prereleases.
return (int(match[1]), int(match[2]), int(match[3]),
1 if not identifiers else 0, tuple(identifiers))
def resolve_release(repo, channel, action):
"""Choose an installable GitHub release for the interactive entry point."""
candidates = []
for page in range(1, 11):
url = f'https://api.github.com/repos/{repo}/releases?per_page=100&page={page}'
for attempt in range(3):
try:
metadata = read_metadata(url, 4 * 1024**2)
break
except Exception as error:
if attempt == 2 or not retryable(error):
raise
log_failure('Release metadata transfer was interrupted; retrying the same route.')
time.sleep(0.5 * (attempt + 1))
releases = json.loads(metadata)
if not isinstance(releases, list):
raise ValueError('Invalid GitHub release list')
for release in releases:
if not isinstance(release, dict) or release.get('draft'):
continue
tag = release.get('tag_name')
version = release_version(tag) if isinstance(tag, str) else None
if version is None or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.+-]*', tag):
continue
prerelease = version[3] == 0
if release.get('prerelease') != prerelease or channel == 'stable' and prerelease:
continue
if not isinstance(release.get('assets'), list):
continue
assets = {asset['name']: asset for asset in release['assets']
if isinstance(asset, dict) and isinstance(asset.get('name'), str)}
if 'bootstrap.py' not in assets:
continue
try:
package = select_package(assets, action)
except ValueError:
continue
# A release must contain a package for its own tag.
name = package[len('framely-'):-len('-linux-arm64.tar.gz')]
if name.endswith('-offline'):
name = name[:-len('-offline')]
plain_tag = tag[1:] if tag.startswith('v') else tag
if not re.fullmatch(re.escape(plain_tag) + r'-[a-f0-9]{12}', name):
continue
size = assets[package].get('size')
if not isinstance(size, int) or not 0 < size <= MAX_ARCHIVE:
continue
candidates.append((version, tag, size))
if len(releases) < 100:
break
else:
raise ValueError('Release list exceeded ten pages')
if not candidates:
raise ValueError('No installable release found for selected channel')
_, tag, size = max(candidates, key=lambda item: item[0])
return {'tag': tag, 'size': size}
def current_release():
state = pathlib.Path('/home/.framely/state')
if not state.is_dir():
@@ -334,7 +553,8 @@ def root_operation(args):
if args.action == 'install' and (base / 'CEF_RUNTIME.json').exists() and not (base / 'lib/cef/libcef.so').is_file():
raise ValueError('First installation requires the complete offline package with CEF')
report('extract')
subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=base, check=True)
with installation_step('校验解压文件', '重新运行安装命令;若仍失败,请保存上方校验输出供开发者排查。'):
subprocess.run(['sha256sum', '--quiet', '-c', 'SHA256SUMS'], cwd=base, check=True)
else:
base = current_release()
report('configure')
@@ -346,8 +566,9 @@ def root_operation(args):
same = False
if same:
command.append('--repair')
subprocess.run(command + [args.user], check=True,
env=dict(os.environ, FRAMELY_PROGRESS_JSON='1' if getattr(args, 'progress_json', False) else '0'))
with installation_step('执行安装脚本', '查看上方脚本输出,修复所提示的问题后重新运行安装命令。'):
subprocess.run(command + [args.user], check=True,
env=dict(os.environ, FRAMELY_PROGRESS_JSON='1' if getattr(args, 'progress_json', False) else '0'))
report('activate')
# Preconfigure the official update source without requiring agreement acceptance.
state = pathlib.Path('/home/.framely/state')
@@ -370,7 +591,7 @@ def root_operation(args):
elif args.action == 'rollback':
subprocess.run(['bash', str(base / 'rollback.sh')], check=True)
report('activate')
print('Framely operation completed.', flush=True)
log_event('设备端操作完成')
finally:
if work:
shutil.rmtree(work)
@@ -387,13 +608,27 @@ def main(argv=None):
parser.add_argument('--yes', action='store_true', help='Confirm the selected operation')
parser.add_argument('--github-proxy', help='HTTPS URL prefix for GitHub downloads, such as https://gh-proxy.com')
parser.add_argument('--progress-json', action='store_true', help=argparse.SUPPRESS)
parser.add_argument('--trace-id', help=argparse.SUPPRESS)
parser.add_argument('--resolve-release', choices=['stable', 'testing'], help=argparse.SUPPRESS)
parser.add_argument('--selection-action', choices=['install', 'update'], help=argparse.SUPPRESS)
args = parser.parse_args(argv)
global TRACE_ID
candidate = args.trace_id or os.environ.get('FRAMELY_INSTALL_TRACE_ID', '')
if re.fullmatch(r'[0-9]{8}T[0-9]{6}Z-[0-9]{1,10}|[0-9a-f]{12}', candidate):
TRACE_ID = candidate
if args.github_proxy:
os.environ['FRAMELY_GITHUB_PROXY'] = github_proxy_url(args.github_proxy)
if not re.fullmatch(r'[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+', args.repo):
parser.error('Invalid repository')
if not re.fullmatch(r'[a-z_][a-zA-Z0-9_-]*', args.user) or args.user == 'root':
parser.error('Invalid Steam user')
if args.version and not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.+-]*', args.version):
parser.error('Invalid release version')
if args.resolve_release:
if not args.selection_action:
parser.error('--selection-action is required with --resolve-release')
print(json.dumps(resolve_release(args.repo, args.resolve_release, args.selection_action)))
return
if sys.platform != 'linux' or os.uname().machine != 'aarch64':
parser.error('This installation engine requires Linux ARM64 Steam Frame')
explicit_action = args.action is not None
@@ -407,6 +642,8 @@ def main(argv=None):
parser.error('Invalid action')
if not args.yes and not (explicit_action and args.action in ('install', 'update')) and not confirm(f'{args.action}: ' + ('disable and uninstall ALL plugins, then remove Framely (saved data retained)' if args.action == 'uninstall' else 'continue with Framely maintenance')):
raise ValueError('Cancelled')
log_event(f'开始:操作={args.action},版本={args.version or "latest stable"},来源={"official" if args.repo == DEFAULT_REPO else "custom"},'
f'架构={os.uname().machine},Python={sys.version_info.major}.{sys.version_info.minor},下载线路={download_route()}')
cache = pathlib.Path.home() / '.cache' / 'framely-installer' if args.action in ('install', 'update') and not args.archive else None
if cache is not None:
cache.mkdir(parents=True, mode=0o700, exist_ok=True)
@@ -415,20 +652,48 @@ def main(argv=None):
if bool(args.archive) != bool(args.checksums):
parser.error('--archive and --checksums must be provided together')
if not args.archive:
endpoint = 'latest' if not args.version else 'tags/' + urllib.parse.quote(args.version, safe='')
metadata = read_metadata(f'https://api.github.com/repos/{args.repo}/releases/{endpoint}', 4 * 1024**2)
release = json.loads(metadata)
assets = {asset['name']: asset for asset in release['assets']}
package = select_package(assets, args.action)
args.archive = pathlib.Path(folder) / package
args.checksums = pathlib.Path(folder) / 'SHA256SUMS'
print('Downloading ' + package, flush=True)
download(assets[package]['browser_download_url'], args.archive, MAX_ARCHIVE)
download(assets['SHA256SUMS']['browser_download_url'], args.checksums, 1024**2)
if args.version:
# A known tag has stable asset URLs. The proxy may serve release
# downloads while rejecting GitHub's API with HTTP 403.
base = (f'https://github.com/{args.repo}/releases/download/'
+ urllib.parse.quote(args.version, safe=''))
print('正在下载校验文件…', flush=True)
with installation_step('下载校验文件', '确认版本已发布,检查网络或更换 GitHub 代理后重试。'):
download(base + '/SHA256SUMS', args.checksums, 1024**2)
with installation_step('读取发行版信息', '确认此版本包含适用的 ARM64 安装包。'):
package = package_from_checksums(args.checksums, args.version, args.action)
args.archive = pathlib.Path(folder) / package
log_event(f'发行版:{args.version},文件={package},下载线路={download_route()}')
with installation_step('下载安装包', '检查网络或更换 GitHub 代理后重新运行安装命令。'):
download(base + '/' + package, args.archive, MAX_ARCHIVE,
terminal_download_progress(None))
log_event(f'安装包大小:{args.archive.stat().st_size / 1024**2:.1f} MiB')
else:
with installation_step('读取发行版信息', '确认版本已发布,检查 GitHub 或代理连接后重试。'):
metadata = read_metadata(f'https://api.github.com/repos/{args.repo}/releases/latest', 4 * 1024**2)
release = json.loads(metadata)
assets = {asset['name']: asset for asset in release['assets']}
package = select_package(assets, args.action)
size = assets[package]['size']
if not isinstance(size, int) or size <= 0 or size > MAX_ARCHIVE:
raise ValueError('发布包大小无效')
tag = release.get('tag_name', '')
log_event(f'发行版:{tag if isinstance(tag, str) and re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9.+-]*", tag) else "unknown"},文件={package}')
args.archive = pathlib.Path(folder) / package
log_event(f'安装包大小:{size / 1024**2:.1f} MiB,下载线路:{download_route()}')
with installation_step('下载安装包', '检查网络或更换 GitHub 代理后重新运行安装命令。'):
download(assets[package]['browser_download_url'], args.archive, MAX_ARCHIVE,
terminal_download_progress(size), expected_size=size)
print('正在下载校验文件…', flush=True)
with installation_step('下载校验文件', '检查网络或更换 GitHub 代理后重新运行安装命令。'):
download(assets['SHA256SUMS']['browser_download_url'], args.checksums, 1024**2)
args.archive, args.checksums = args.archive.resolve(), args.checksums.resolve()
if args.progress_json:
emit_progress('verify')
verify_archive(args.archive, args.checksums)
print('正在校验安装包…', flush=True)
with installation_step('校验安装包', '重新运行安装命令;若仍失败,换用可信的下载线路并向开发者提供诊断信息。'):
verify_archive(args.archive, args.checksums)
print('Archive SHA256 verified.', flush=True)
if os.geteuid() == 0:
root_operation(args)
@@ -438,13 +703,30 @@ def main(argv=None):
command += ['--archive', str(args.archive), '--checksums', str(args.checksums)]
if args.progress_json:
command += ['--progress-json']
if args.github_proxy:
command += ['--github-proxy', args.github_proxy]
command += ['--trace-id', TRACE_ID]
# stdin belongs to curl | bash; sudo reads its password from the terminal.
subprocess.run(command, check=True)
try:
subprocess.run(command, check=True)
except subprocess.CalledProcessError as error:
raise InstallationFailure('设备端安装失败。\n原因:sudo 安装步骤退出,详细错误见上方输出。'
'\n下一步:按上方提示修复后,重新运行安装命令。') from error
log_event(f'操作完成:{args.action}')
if __name__ == '__main__':
try:
main()
except (ValueError, OSError, subprocess.CalledProcessError) as error:
print('Framely: ' + str(error), file=sys.stderr)
except (ValueError, OSError, RuntimeError, subprocess.CalledProcessError,
http.client.IncompleteRead) as error:
if '--resolve-release' in sys.argv:
log_failure('Release lookup failed. Diagnostic: '
+ failure_diagnostic(error)
+ '. Check the network, mirror, and release assets, then retry.')
elif isinstance(error, InstallationFailure):
log_failure(str(error))
else:
log_failure(f'安装操作失败。\n原因:{failure_reason(error)}\n诊断:{failure_diagnostic(error)}'
'\n下一步:检查上方日志和运行环境,修复问题后重试;若仍失败,请提供追踪编号及完整安装输出。')
sys.exit(1)
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Reproduce the embedded Android 11 APK-update helper. No runtime JDK needed.
set -euo pipefail
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
work="$base/target/android-updates-build"
mkdir -p "$work/classes" "$work/dex"
fetch() {
local url=$1 file=$2 digest=$3
if [[ ! -f $file ]] || ! printf '%s %s\n' "$digest" "$file" | sha256sum -c - >/dev/null 2>&1; then
curl -fSL --retry 3 "$url" -o "$file"
fi
printf '%s %s\n' "$digest" "$file" | sha256sum -c -
}
fetch https://dl.google.com/android/repository/platform-30_r03.zip "$work/platform.zip" f3f5b75744dbf6ee6ed3e8174a71e513bfee502d0bc3463ea97e517bff68d84e
fetch https://dl.google.com/dl/android/maven2/com/android/tools/r8/8.3.37/r8-8.3.37.jar "$work/r8.jar" 59753e70a74f918389cc87f1b7d66b5c0862932559167425708ded159e3de439
unzip -p "$work/platform.zip" android-11/android.jar > "$work/android.jar"
javac --release 8 -Xlint:-options -cp "$work/android.jar" -d "$work/classes" "$base/native/android/FramelyUpdates.java"
java -cp "$work/r8.jar" com.android.tools.r8.D8 --release --min-api 30 --lib "$work/android.jar" --output "$work/dex" "$work"/classes/FramelyUpdates*.class
cp "$work/dex/classes.dex" "$base/native/android/framely-updates.dex"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
cd "$base"
# SteamOS does not provide these Good plugins. Build only the elements used
# by our receive pipelines; keep GStreamer, PulseAudio and Soup system libraries.
meson setup media/source/gst-good-build media/source/gst-plugins-good-1.24.13 \
--prefix=/usr --libdir=lib --buildtype=release --wrap-mode=nofallback \
-Dauto_features=disabled -Dlevel=enabled -Dpulse=enabled \
-Dvideofilter=enabled -Disomp4=enabled -Dsoup=enabled \
-Dtests=disabled -Dexamples=disabled -Ddoc=disabled
ninja -C media/source/gst-good-build -j"${FRAMELY_BUILD_JOBS:-4}"
DESTDIR="$base/media/source/gst-good-install" meson install -C media/source/gst-good-build --no-rebuild
for plugin in level pulseaudio videofilter isomp4 soup; do
cp "media/source/gst-good-install/usr/lib/gstreamer-1.0/libgst${plugin}.so" media/bin/
done
cp media/source/gst-plugins-good-1.24.13/COPYING media/bin/LICENSE.gst-good
+1
View File
@@ -23,6 +23,7 @@ cp "$capture_target/release/framely-capture" "$capture_target/release/framely-pa
# Native dependencies use the target system's GStreamer and GUPnP libraries.
python3 tools/fetch-media.py
bash tools/build-gst-libav.sh
bash tools/build-gst-good.sh
python3 media/patch-uxplay.py media/source/UxPlay-1.73.7
cmake -S media/source/UxPlay-1.73.7 -B media/source/uxplay-build -DNO_X11_DEPS=ON -DCMAKE_BUILD_TYPE=Release
cmake --build media/source/uxplay-build -j"${FRAMELY_BUILD_JOBS:-4}"
+13 -2
View File
@@ -196,8 +196,19 @@ def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_l
try:
for path in sorted((state / 'logs').glob('*.log'))[:64]:
tail(path, 'logs/' + path.name)
for path in sorted(runtime.glob('*/framely-session-*/cef.log'))[-8:]:
tail(path, 'renderer/' + path.parent.parent.name + '-' + path.parent.name + '-cef.log')
sessions = [p for p in runtime.glob('*/framely-session-*')
if p.is_dir() and not p.is_symlink() and not p.parent.is_symlink()]
sessions.sort(key=lambda p: p.stat().st_mtime, reverse=True)
for session in sessions[:8]:
tail(session / 'cef.log', 'renderer/' + session.parent.name + '-' + session.name + '-cef.log')
# Explicit allowlist: never collect frames, media, consent or access files.
for area, names in [('receive', ('airplay', 'receiver')), ('cast', ('stream',))]:
directory = session / area
if directory.is_symlink():
continue
for name in names:
for suffix in ('.log', '.failed.log'):
tail(directory / (name + suffix), 'casting/' + session.parent.name + '/' + session.name + '/' + area + '/' + name + suffix)
except OSError as error:
report['warnings'].append('logs: ' + str(error))
# Record GPU evidence without copying command lines or authentication tokens.
+5 -2
View File
@@ -16,7 +16,7 @@ if [[ ${FRAMELY_USE_PREBUILT_MEDIA:-0} == 1 ]]; then
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
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 media/bin/LICENSE.gst-good media/bin/libgstlevel.so media/bin/libgstpulseaudio.so media/bin/libgstvideofilter.so media/bin/libgstisomp4.so media/bin/libgstsoup.so; do
[[ -s $file ]] || { echo "Missing prebuilt media: $file" >&2; exit 1; }
done
else
@@ -39,18 +39,21 @@ mkdir -p "$stage"/{bin,lib/cef,lib/openvr,lib/media,share/ui,share/licenses,tool
cp "$binary" "$stage/bin/"
cp media/bin/framely-panel-grab "$stage/bin/"
cp media/bin/framely-capture media/bin/mediamtx media/bin/uxplay media/bin/framely-receiver media/bin/libgstframely.so media/bin/libgstlibav.so "$stage/lib/media/"
cp media/bin/libgst{level,pulseaudio,videofilter,isomp4,soup}.so "$stage/lib/media/"
cp -a media/bin/upnp "$stage/lib/media/"
cp media/bin/LICENSE "$stage/share/licenses/mediamtx.txt"
cp media/bin/LICENSE.uxplay "$stage/share/licenses/uxplay.txt"
cp media/bin/LICENSE.gst-libav "$stage/share/licenses/gst-libav.txt"
cp media/bin/LICENSE.gst-good "$stage/share/licenses/gst-plugins-good.txt"
cp media/bin/LICENSE.llhttp "$stage/share/licenses/uxplay-llhttp.txt"
cp media/capture/LICENSE.framecorder "$stage/share/licenses/panel-capture.txt"
cp media/THIRD_PARTY_NOTICES.md "$stage/share/licenses/media-notices.md"
mkdir -p "$stage/share/source/media/capture" "$stage/share/source/tools" "$stage/share/source/native/vendor"
cp -a media/native media/upnp media/patch-uxplay.py media/dependencies.json "$stage/share/source/media/"
cp -a media/capture/src media/capture/shaders media/capture/Cargo.toml media/capture/Cargo.lock media/capture/build.rs media/capture/LICENSE.framecorder "$stage/share/source/media/capture/"
cp tools/build-media.sh tools/build-gst-libav.sh tools/fetch-media.py "$stage/share/source/tools/"
cp tools/build-media.sh tools/build-gst-libav.sh tools/build-gst-good.sh tools/fetch-media.py "$stage/share/source/tools/"
cp native/vendor/json.hpp "$stage/share/source/native/vendor/"
tar -cJf "$stage/share/source/media/gst-plugins-good.tar.xz" -C media/source gst-plugins-good-1.24.13
tar -cJf "$stage/share/source/media/gst-libav.tar.xz" -C media/source gst-libav-1.24.13
tar -czf "$stage/share/source/media/uxplay.tar.gz" -C media/source UxPlay-1.73.7
+32 -1
View File
@@ -9,10 +9,41 @@ binaries=Path(args.binaries).resolve()
with tempfile.TemporaryDirectory(prefix='framely-media-test-') as directory:
root=Path(directory);events=socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM);events.bind(str(root/'events.sock'));events.settimeout(5)
token='0123456789abcdef0123456789abcdef';env=dict(os.environ,GST_PLUGIN_PATH=str(binaries),FRAMELY_CAST_DIR=directory,FRAMELY_CAST_ID=token)
airplay=subprocess.Popen([str(binaries/'uxplay'),'-n','Framely test','-avdec','-hls','-vs','framelyvideosink','-as','framelyaudiosink'],env=env,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
# Check the actual receive elements, including plugins absent on stock Frame.
for element in ['framelyvideosink','framelyaudiosink','level','pulsesink','videoflip','souphttpsrc','qtdemux']:
result=subprocess.run(['gst-inspect-1.0','--exists',element],env=env,capture_output=True)
assert result.returncode==0,f'Required receive element unavailable: {element}'
test_name=f'Framely test {os.getpid()}'
airplay=subprocess.Popen([str(binaries/'uxplay'),'-n',test_name,'-nh','-m','-avdec','-hls','-vs','framelyvideosink','-as','framelyaudiosink'],env=env,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
try:
time.sleep(2);assert airplay.poll() is None,airplay.stdout.read().decode()
print('PASS: UxPlay starts with bundled decoders and consent sinks')
# Use the system daemon only to browse. Publication must also work when
# that daemon's disable-publishing policy is enabled (Frame's default).
import shutil
if shutil.which('avahi-browse'):
for service_type in ['_airplay._tcp','_raop._tcp']:
browser=subprocess.Popen(['avahi-browse','--resolve','--parsable',
'--no-db-lookup',service_type],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True)
from queue import Queue,Empty
discovered=Queue()
def browse_lines():
for line in browser.stdout:discovered.put(line)
Thread(target=browse_lines,daemon=True).start()
try:
deadline=time.monotonic()+15
while True:
try:line=discovered.get(timeout=max(.01,deadline-time.monotonic()))
except Empty:raise AssertionError(f'No resolved {service_type} advertisement')
if line.startswith('=;') and test_name in line.replace('\\032',' '):
assert 'features=' in line or 'cn=' in line,line
break
assert time.monotonic()<deadline,f'No resolved {service_type} advertisement'
finally:
browser.terminate();browser.wait(timeout=3)
print('PASS: AirPlay and RAOP multicast discovery, address resolution and TXT records')
else:
print('SKIP: multicast discovery test requires avahi-browse')
finally:
airplay.terminate();airplay.wait(timeout=3)
p=subprocess.Popen(['gst-launch-1.0','-q','videotestsrc','is-live=true','!','video/x-raw,width=320,height=240,framerate=10/1','!','framelyvideosink'],env=env,stderr=subprocess.PIPE)
+12 -3
View File
@@ -54,8 +54,8 @@
"自动换行": "Word wrap",
"正在加载代码编辑器…": "Loading code editor…",
"代码编辑器加载失败,可以使用普通文本编辑。": "The code editor could not load. You can use the plain text editor.",
"GitHub 连接失败时自动使用 gh-proxy.com": "Automatically try gh-proxy.com when GitHub cannot be reached",
"公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。": "After a network failure, public GitHub requests go through a third-party proxy once. Download verification remains enabled.",
"GitHub 连接失败时自动尝试备用线路": "Automatically try another GitHub route when the selected route fails",
"直连失败时尝试 gh-proxy.com;已启用 GitHub 代理时,代理遇到限额或网络错误则尝试直连。只重试一次,下载仍会校验。": "If direct GitHub access fails, try gh-proxy.com. If a configured GitHub proxy hits a rate limit or network error, try direct access. Retry once; downloads remain verified.",
"显示录制指示器": "Show recording indicator",
"关闭后仅隐藏右上角录制提示,不会停止录制。": "Hides only the recording indicator in the upper-right corner; recording continues.",
"安装失败,本次新建的容器已清理:{0}": "Installation failed; the newly created container was cleaned up: {0}",
@@ -1398,6 +1398,15 @@
"工具包已缓存": "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."
"要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "To use an updated converter, select the original Quest APK and install it again.",
"应用内更新": "In-app updates",
"应用下载的更新已保留。审核确认后,由 Framely 备份并安装。": "Downloaded updates are retained. Review and confirm to back up and install with Framely.",
"更新提取失败": "Update extraction failed",
"审核更新": "Review update",
"请刷新应用列表后重试。": "Refresh the application list and try again.",
"更新请求已结束,请从应用重试。": "The update request has ended. Retry from the application.",
"更新完成": "Update complete",
"无法打开更新": "Cannot open update",
"正在读取更新": "Reading update"
}
}
+12 -3
View File
@@ -54,8 +54,8 @@
"自动换行": "自动换行",
"正在加载代码编辑器…": "正在加载代码编辑器…",
"代码编辑器加载失败,可以使用普通文本编辑。": "代码编辑器加载失败,可以使用普通文本编辑。",
"GitHub 连接失败时自动使用 gh-proxy.com": "GitHub 连接失败时自动使用 gh-proxy.com",
"公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。": "公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。",
"GitHub 连接失败时自动尝试备用线路": "GitHub 连接失败时自动尝试备用线路",
"直连失败时尝试 gh-proxy.com;已启用 GitHub 代理时,代理遇到限额或网络错误则尝试直连。只重试一次,下载仍会校验。": "直连失败时尝试 gh-proxy.com;已启用 GitHub 代理时,代理遇到限额或网络错误则尝试直连。只重试一次,下载仍会校验。",
"显示录制指示器": "显示录制指示器",
"关闭后仅隐藏右上角录制提示,不会停止录制。": "关闭后仅隐藏右上角录制提示,不会停止录制。",
"安装失败,本次新建的容器已清理:{0}": "安装失败,本次新建的容器已清理:{0}",
@@ -1398,6 +1398,15 @@
"工具包已缓存": "工具包已缓存",
"首次使用需下载 {0} 工具包": "首次使用需下载 {0} 工具包",
"Quest 转换": "Quest 转换",
"要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "要使用新版转换运行包,请重新选择原始 Quest APK 安装。"
"要使用新版转换运行包,请重新选择原始 Quest APK 安装。": "要使用新版转换运行包,请重新选择原始 Quest APK 安装。",
"应用内更新": "应用内更新",
"应用下载的更新已保留。审核确认后,由 Framely 备份并安装。": "应用下载的更新已保留。审核确认后,由 Framely 备份并安装。",
"更新提取失败": "更新提取失败",
"审核更新": "审核更新",
"请刷新应用列表后重试。": "请刷新应用列表后重试。",
"更新请求已结束,请从应用重试。": "更新请求已结束,请从应用重试。",
"更新完成": "更新完成",
"无法打开更新": "无法打开更新",
"正在读取更新": "正在读取更新"
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import {t} from './i18n';
import {installKeyboard} from '../../sdk/src/keyboard';
import {installHoverFeedback} from '../../sdk/src/hover';
export async function api<T=any>(method:string,params:unknown={},signal?:AbortSignal):Promise<T>{const r=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method,params}),signal});if(r.status===401&&r.headers.get('X-Framely-Reauthenticate')==='1')location.replace('/manager');if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));return v.result;}
export function viewKey(){const p=location.pathname.split('/');return p[1]==='cast'&&p[2]==='framing'?'framely.framing':p[1]==='cast'&&p[2]==='screenshot'?'framely.screenshot':p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='cast'&&p[2]==='window'?`framely.cast.${p[3]}`:p[1]==='apk'&&p[2]==='permission'?'framely.apk.permission':p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';}
export function viewKey(){const p=location.pathname.split('/');return p[1]==='cast'&&p[2]==='framing'?'framely.framing':p[1]==='cast'&&p[2]==='screenshot'?'framely.screenshot':p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='cast'&&p[2]==='window'?`framely.cast.${p[3]}`:p[1]==='apk'&&p[2]==='permission'?'framely.apk.permission':p[1]==='apk'&&p[2]==='update'?'framely.apk.update':p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';}
export const nativeKeyboardHost=['localhost','127.0.0.1','[::1]'].includes(location.hostname);
installKeyboard(params=>nativeKeyboardHost?api('host.keyboard',{view:viewKey(),...params}):Promise.resolve(),(method,params)=>nativeKeyboardHost?api('plugin.call',{plugin:'tooru.frame-keyboard',method,params:{...params,view:viewKey()}}):Promise.resolve({ok:false}),{requestOnFocus:viewKey()!=='launcher'});
+5
View File
@@ -26,3 +26,8 @@
.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}
.apk-update-view body{padding:0;background:transparent}.apk-update-view .apk-modal-backdrop{background:transparent}.apk-update-view .apk-dialog{inset:0;transform:none;margin:0;max-width:none;max-height:100%;width:100%;border:0;border-radius:0;box-shadow:none;padding:28px;box-sizing:border-box}.apk-update-status{min-height:100vh;padding:32px;display:flex;flex-direction:column;gap:18px;background:var(--panel,#202020);box-sizing:border-box}.apk-update-status footer{margin-top:auto;display:flex;justify-content:flex-end;gap:12px}
.apk-update-view,.apk-update-view *{cursor:none!important}
.apk-update-pointer{position:fixed;z-index:2147483647;display:block;width:12px;height:12px;box-sizing:border-box;border:1px solid #0009;border-radius:50%;background:#fff;box-shadow:0 0 0 2px #0003;transform:translate(-50%,-50%);pointer-events:none}
+27 -8
View File
@@ -18,6 +18,7 @@ type App={steamName?:string|null;steamLaunch?:boolean;steamRegistered?:boolean;s
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 AppUpdate={revision?:number;id:string;app:string;name:string;previous:string;version:string;bytes:number;error?:string};
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};
@@ -32,26 +33,41 @@ function SteamNameSetting({app,busy,onSave}:{app:App;busy:boolean;onSave:(name:s
<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}={}){
export function ApkManager({view:externalView,onViewChange,updateId}:{view?:string;onViewChange?:(view:string)=>void;updateId?:string}={}){
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 [appUpdates,setAppUpdates]=useState<AppUpdate[]>([]),[updateRequest,setUpdateRequest]=useState(''),[updatesLoaded,setUpdatesLoaded]=useState(false);
const autoReviewed=useRef('');
async function closeUpdateWindow(){if(updateId)await api('apk.updates.window.close',{id:updateId}).catch(e=>setError(apkError(e)));}
async function refreshAppUpdates(){try{const value=await api<AppUpdate[]>('apk.updates.list');if(alive.current){setAppUpdates(value);setUpdatesLoaded(true);}}catch{}}
useEffect(()=>{void refreshAppUpdates();const timer=setInterval(()=>void refreshAppUpdates(),4000);return()=>clearInterval(timer);},[]);
async function reviewAppUpdate(item:AppUpdate){const app=data.apps.find(a=>a.id===item.app);if(!app){setError(t('请刷新应用列表后重试。'));return;}setBusy(true);busyRef.current=true;setError('');try{const result=await runJob('apk.updates.review',{id:item.id},updateJob);if(!alive.current)return;setOriginalReview(null);setReview(result.result);setUpdating(app);setTarget(app.context);setUpdateRequest(item.id);setQuestEnabled(!!app.q2fVersion);setDisplayMode((app.showWindow??!app.metadata.vr)?'flat':'vr');}catch(e){setError(apkError(e));}finally{setJob(null);setBusy(false);busyRef.current=false;}}
useEffect(()=>{if(!updateId||loading||!updatesLoaded||busy||autoReviewed.current===updateId)return;autoReviewed.current=updateId;const item=appUpdates.find(r=>r.id===updateId);if(!item){setError(t('更新请求已结束,请从应用重试。'));return;}void reviewAppUpdate(item);},[updateId,loading,updatesLoaded,busy,appUpdates]);
useEffect(()=>{
if(!updateId||!review||busy)return;
// A receipt follows the rendered confirmation, not just the open command.
let frame=0;let live=true;
frame=requestAnimationFrame(()=>{frame=requestAnimationFrame(()=>{if(live)void api('apk.updates.window.ready',{id:updateId}).catch(e=>{if(alive.current)setError(apkError(e));});});});
return()=>{live=false;cancelAnimationFrame(frame);};
},[updateId,review?.ticket,busy,appUpdates.find(r=>r.id===updateId)?.revision]);
async function dismissAppUpdate(id:string){try{await api('apk.updates.dismiss',{id});await refreshAppUpdates();}catch(e){setError(apkError(e));}}
const view=externalView??localView;const setView=(value:string)=>{setLocalView(value);onViewChange?.(value);};
useEffect(()=>{if(view==='cleanup')void loadCleanup();},[view]);
useEffect(()=>{if(!review||review.q2fVersion){setQ2f(null);setQ2fError('');return;}let active=true;setQ2f(null);setQ2fError('');void api<Q2fStatus>('apk.q2f.status').then(value=>{if(active)setQ2f(value);}).catch(e=>{if(active)setQ2fError(String(e));});return()=>{active=false;};},[review?.ticket]);
const input=useRef<HTMLInputElement>(null),controller=useRef<AbortController|null>(null),selection=useRef<{app:string;uninstall:boolean}|null>(null),alive=useRef(true),busyRef=useRef(false);
const updateJob=(v:Job)=>{if(alive.current)setJob(v);};
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');let saved:{job:string;app:App|null;context:string;review?:Review;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(apkError(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(apkError(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;updateRequest?: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);setUpdateRequest(saved.updateRequest??'');setQuestEnabled(true);setDisplayMode(saved.displayMode??'vr');setUpdating(saved.app);setTarget(saved.context);}setError(apkError(value.phase==='cancelled'?'操作已取消':value.error??'操作失败'));}else if(value.result?.q2fVersion){setUpdateRequest(saved?.updateRequest??'');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,updateRequest:saved.updateRequest||undefined,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(apkError(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(apkError(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(()=>{alive.current=true;void refresh();if(!updateId)void resumeOperation();const select=(e:Event)=>{const v=(e as CustomEvent).detail;if(v?.kind==='apk.selection'){selection.current={app:v.app,uninstall:!!v.uninstall};void refresh();}};window.addEventListener('framely.event',select);return()=>{alive.current=false;window.removeEventListener('framely.event',select);controller.current?.abort();};},[]);
useEffect(()=>{if(selection.current){const a=data.apps.find(a=>a.id===selection.current!.app);if(a){if(a.steam){setError(t('请通过 Steam 管理此应用。'));selection.current=null;return;}if(selection.current.uninstall)askUninstall(a);else setDetails(a);selection.current=null;}}},[data]);
useEffect(()=>{const close=(e:Event)=>{if(e instanceof KeyboardEvent&&e.key!=='Escape')return;if(busyRef.current)return;if(confirm)setConfirm(null);else if(log)setLog(null);else if(review)void discard();else setDetails(null);};window.addEventListener('keydown',close);window.addEventListener('framely.back',close);return()=>{window.removeEventListener('keydown',close);window.removeEventListener('framely.back',close);};},[confirm,log,review]);
useEffect(()=>{const dialog=document.querySelector<HTMLElement>('.apk-dialog');if(!dialog)return;const prev=document.activeElement as HTMLElement;const controls=()=>Array.from(dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input,select,a[href]')).filter(e=>e.getClientRects().length);controls()[0]?.focus();const key=(e:KeyboardEvent)=>{if(e.key!=='Tab')return;const a=controls();if(!a.length)return;if(e.shiftKey&&(document.activeElement===a[0]||!dialog.contains(document.activeElement))){e.preventDefault();a.at(-1)?.focus();}else if(!e.shiftKey&&(document.activeElement===a.at(-1)||!dialog.contains(document.activeElement))){e.preventDefault();a[0].focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);prev?.focus();};},[review,details,confirm,log,job?.id]);
async function discard(){if(review)await api('apk.review.drop',{ticket:review.ticket}).catch(()=>{});if(originalReview)await api('apk.review.drop',{ticket:originalReview.ticket}).catch(()=>{});setReview(null);setOriginalReview(null);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(apkError(e));}finally{setJob(null);setBusy(false);busyRef.current=false;}}
async function discard(){setUpdateRequest('');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);if(updateId)await closeUpdateWindow();}
async function installReviewed(){if(!review)return;const original=review;if(!questEnabled||review.q2fVersion){await perform('install',{ticket:review.ticket,context:target,app:updating?.id,updateRequest:updateRequest||undefined,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,updateRequest}));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,updateRequest:updateRequest||undefined,showWindow:displayMode==='flat',approve:true},original.ticket);}catch(e){sessionStorage.removeItem('framely.apk.q2f.job');setError(apkError(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(apkError(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(apkError(e));}finally{setBusy(false);busyRef.current=false;controller.current=null;}}
async function inspect(file:File|string,selectedApp:App|null=updating){setUpdateRequest('');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(apkError(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>={},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(apkError(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'){setUpdateRequest('');await refreshAppUpdates();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(apkError(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(apkError(e));}}
@@ -65,16 +81,19 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
const deletesData=!!confirm&&(['purge','clear','cleanup','container.delete'].includes(confirm.operation)||(confirm.operation==='uninstall'&&!!confirm.purge));
const dialog=(children:React.ReactNode,label:string)=>createPortal(<div className="apk-modal-backdrop"><div className="modal apk-dialog" role="dialog" aria-modal="true" aria-label={label}>{error&&!review&&!confirm&&<p className="error" role="alert">{error}</p>}{children}</div></div>,document.body);
return <section className="apk-manager">
{!updateId&&<>
<div className="list-heading"><div><h1>{t('APK 管理')}</h1><p>{t('安装和管理 Lepton 侧载应用。')}</p></div><div className="apk-actions"><button disabled={busy||loading} onClick={()=>void refresh()}><IconRefresh size={22}/>{t('刷新')}</button><button className="primary" disabled={busy||!data.available} onClick={()=>choose()}><IconDownload size={22}/>{t('安装 APK')}</button></div></div>
<input ref={input} type="file" accept=".apk,application/vnd.android.package-archive" hidden onChange={e=>{if(e.target.files?.[0])void inspect(e.target.files[0]);}}/>
{!data.available&&<p className="banner">{t('未检测到 Lepton,请先通过 Steam 安装。')}</p>}
{notice&&<p className="apk-notice" role="status">{notice}</p>}{error&&<div className="error apk-error" role="alert">{error}<button aria-label={t('关闭')} onClick={()=>setError('')}><IconX size={20}/></button></div>}
{!externalView&&<div className="apk-tabs" role="tablist">{[['apps','应用'],['containers','容器'],['cleanup','数据清理'],['settings','设置']].map(([key,label])=><button key={key} role="tab" aria-selected={view===key} className={view===key?'active':''} disabled={busy} onClick={()=>{setView(key);if(key==='cleanup')void loadCleanup();}}>{t(label)}</button>)}</div>}
{view==='settings'&&<><SwitchRow label={t('保留安装的 APK 到 Steam')} description={t('默认开启,在 Steam 库中保留已安装应用。关闭后只在启动时注册 Steam 入口,退出后删除入口,应用数据保留。')} checked={data.retainSteamEntries??true} disabled={busy||loading} onChange={enabled=>void perform('steam.retention.settings',{enabled})}/><SwitchRow label={t('应用退出后自动停止容器')} description={t('默认关闭。应用进程连续消失 15 秒后停止容器;切到后台不会停止。')} checked={!!data.autoStopContainer} disabled={busy||loading} onChange={autoStopContainer=>void perform('lifecycle.settings',{autoStopContainer,stopContainerOnClose:data.stopContainerOnClose??true})}/><SwitchRow label={t('关闭窗口时停止容器')} description={t('收到系统窗口关闭信号后停止容器并保留数据。部分 SteamOS 版本不支持关闭 Lepton 窗口;切到后台不会停止,共享容器中的其他应用会受到保护。')} checked={data.stopContainerOnClose??true} disabled={busy||loading} onChange={stopContainerOnClose=>void perform('lifecycle.settings',{autoStopContainer:!!data.autoStopContainer,stopContainerOnClose})}/><SwitchRow label={t('手柄输入')} description={t('默认关闭。通过 Steam Input 将手柄转发为 Android 标准游戏手柄;也可选择 Frame 直连。开启后下次打开 APK 生效,关闭立即停止输入。')} checked={!!data.gamepadEnabled} disabled={busy||loading||!data.available&&!data.gamepadEnabled} onChange={enabled=>void perform('gamepad.settings',{enabled})}/><div className="switch-row apk-input-source"><div><b>{t('手柄输入来源')}</b><small>{t('来源切换需关闭容器后重新启动。直通使用 Steam 的第一只有效手柄,保留线性扳机,不补充数字 L2/R2;设备节点重建后需重启容器。桥接支持多手柄切换和数字扳机。')}</small></div><Select label={t('手柄输入来源')} value={data.gamepadSource??'steam'} disabled={busy||loading} onChange={source=>void perform('gamepad.settings',{enabled:!!data.gamepadEnabled,source})} options={[{value:'steam-direct',label:t('Steam Input 直通')},{value:'steam',label:t('Steam Input 桥接(兼容模式)')},{value:'frame',label:t('Frame 直连(兼容模式)')}]}/></div>{data.gamepadSource!=='steam-direct'&&<GamepadTriggerSetting value={data.gamepadTriggerThreshold??80} busy={busy||loading} onSave={triggerThreshold=>void perform('gamepad.settings',{enabled:!!data.gamepadEnabled,triggerThreshold})}/>}<SwitchRow label={t('手柄震动')} description={t('将游戏的手柄震动反馈回传给当前输入手柄;应用失去 Android 前台状态或手柄断连时停止。')} checked={data.gamepadRumble??true} disabled={busy||loading} onChange={rumble=>void perform('gamepad.settings',{enabled:!!data.gamepadEnabled,rumble})}/></>}
{view==='apps'&&<><div className="apk-filters"><input type="search" aria-label={t('搜索 APK')} placeholder={t('搜索名称、包名或容器')} value={query} onChange={e=>setQuery(e.target.value)}/><Select label={t('应用筛选')} value={filter} onChange={setFilter} options={[{value:'installed',label:t('已安装')},{value:'retained',label:t('已卸载但保留数据')},{value:'all',label:t('全部')}]}/></div><div className="apk-list">{listed.map(a=><article className="apk-row" key={a.id}><PluginImage kind="lepton" src={a.metadata.icon} name={a.metadata.name} size={64}/><div className="apk-copy"><h2>{a.metadata.name}<small>{a.metadata.version}</small></h2><code>{a.metadata.package}</code><p>{contextFor(a.context)?.name??a.context} · {bytes(a.size)} · {t(a.stateKnown===false?'安装状态未知':a.installed?(a.running?'容器运行中':'已安装'):'已卸载但保留数据')}</p>{a.pending&&<p className="error">{t(a.pending)}</p>}{a.installed&&!a.metadata.activities.length&&!a.activity&&<p>{t('未发现启动入口')}</p>}</div><div className="apk-actions">{a.installed&&a.stateKnown!==false&&<button disabled={busy||!a.metadata.activities.length&&!a.activity} onClick={()=>void perform('launch',{app:a.id})}><IconPlayerPlay size={20}/>{t('打开')}</button>}<button disabled={busy} onClick={()=>setDetails(a)}>{t('管理')}</button></div></article>)}</div>{!listed.length&&<div className="empty"><IconBrandAndroid size={40}/><p>{loading?t('正在读取'):t('没有匹配的 APK 应用。')}</p></div>}</>}
{view==='apps'&&<>{appUpdates.length>0&&<section aria-label={t('应用内更新')}><h2>{t('应用内更新')}</h2><p>{t('应用下载的更新已保留。审核确认后,由 Framely 备份并安装。')}</p><div className="apk-list">{appUpdates.map(item=><article className="apk-row" key={item.id}><div className="apk-copy"><h3>{item.name||t('更新提取失败')}</h3>{item.error?<p className="error">{item.error}</p>:<p>{item.previous} → {item.version} · {bytes(item.bytes)}</p>}</div><div className="apk-actions">{!item.error&&<button className="primary" disabled={busy||loading} onClick={()=>void reviewAppUpdate(item)}>{t('审核更新')}</button>}<button disabled={busy} onClick={()=>void dismissAppUpdate(item.id)}>{t('忽略')}</button></div></article>)}</div></section>}<div className="apk-filters"><input type="search" aria-label={t('搜索 APK')} placeholder={t('搜索名称、包名或容器')} value={query} onChange={e=>setQuery(e.target.value)}/><Select label={t('应用筛选')} value={filter} onChange={setFilter} options={[{value:'installed',label:t('已安装')},{value:'retained',label:t('已卸载但保留数据')},{value:'all',label:t('全部')}]}/></div><div className="apk-list">{listed.map(a=><article className="apk-row" key={a.id}><PluginImage kind="lepton" src={a.metadata.icon} name={a.metadata.name} size={64}/><div className="apk-copy"><h2>{a.metadata.name}<small>{a.metadata.version}</small></h2><code>{a.metadata.package}</code><p>{contextFor(a.context)?.name??a.context} · {bytes(a.size)} · {t(a.stateKnown===false?'安装状态未知':a.installed?(a.running?'容器运行中':'已安装'):'已卸载但保留数据')}</p>{a.pending&&<p className="error">{t(a.pending)}</p>}{a.installed&&!a.metadata.activities.length&&!a.activity&&<p>{t('未发现启动入口')}</p>}</div><div className="apk-actions">{a.installed&&a.stateKnown!==false&&<button disabled={busy||!a.metadata.activities.length&&!a.activity} onClick={()=>void perform('launch',{app:a.id})}><IconPlayerPlay size={20}/>{t('打开')}</button>}<button disabled={busy} onClick={()=>setDetails(a)}>{t('管理')}</button></div></article>)}</div>{!listed.length&&<div className="empty"><IconBrandAndroid size={40}/><p>{loading?t('正在读取'):t('没有匹配的 APK 应用。')}</p></div>}</>}
{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>}
</>}
{updateId&&!review&&!confirm&&!job&&<div className="apk-update-status"><h2>{t(notice?'更新完成':error?'无法打开更新':'正在读取更新')}</h2>{notice&&<p role="status">{notice}</p>}{error&&<p className="error" role="alert">{error}</p>}{!notice&&!error&&<progress aria-label={t('正在读取更新')}/>}<footer>{error&&<button disabled={busy} onClick={()=>{autoReviewed.current='';setError('');void refresh();void refreshAppUpdates();}}>{t('重试')}</button>}<button disabled={busy} onClick={()=>void closeUpdateWindow()}>{t('关闭')}</button></footer></div>}
{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.stateKnown===false&&contextFor(details.context)?.managed&&!contextFor(details.context)?.steam&&<button className="danger" disabled={busy} onClick={()=>containerAsk(contextFor(details.context)!,'container.delete')}>{t('清理损坏容器')}</button>}{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('自动选择')},.Line truncated
{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)}
+29
View File
@@ -0,0 +1,29 @@
import React,{useEffect,useState} from 'react';
import {createPortal} from 'react-dom';
import {api} from './api';
import {ApkManager} from './apk-manager';
import {configureLanguage,t,type LanguagePack} from './i18n';
export function ApkUpdate({id}:{id:string}){
const [pointer,setPointer]=useState<{x:number;y:number}|null>(null);
useEffect(()=>{
const move=(event:MouseEvent)=>setPointer(event.clientX>=0&&event.clientY>=0&&event.clientX<innerWidth&&event.clientY<innerHeight?{x:event.clientX,y:event.clientY}:null);
const leave=()=>setPointer(null);
document.addEventListener('mousemove',move);
document.addEventListener('mouseleave',leave);
window.addEventListener('blur',leave);
return()=>{document.removeEventListener('mousemove',move);document.removeEventListener('mouseleave',leave);window.removeEventListener('blur',leave);};
},[]);
const [ready,setReady]=useState(false),[error,setError]=useState(''),[attempt,setAttempt]=useState(0);
useEffect(()=>{
let live=true;setError('');
void Promise.all([api<{database:{language?:string}}>('status'),api<{packs?:LanguagePack[]}>('language.list')]).then(([status,languages])=>{
if(!live)return;
configureLanguage(status.database.language??'auto',languages.packs??[]);setReady(true);
}).catch(e=>{if(live)setError(String(e));});
return()=>{live=false;};
},[attempt]);
const cursor=pointer?createPortal(<span className="apk-update-pointer" style={{left:pointer.x,top:pointer.y}} aria-hidden="true"/>,document.body):null;
if(!ready)return <>{cursor}<main className="apk-update-status" aria-busy={!error}>{error&&<><p role="alert">{error}</p><button onClick={()=>setAttempt(v=>v+1)}>{t('重试')}</button></>}</main></>;
return <>{cursor}<ApkManager updateId={id}/></>;
}
+3 -1
View File
@@ -11,6 +11,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 {ApkUpdate} from './apk-update';
import {ApkPermission} from './apk-permission';
import {Switch,SwitchRow} from './switch';
import {PluginFrame,pluginLanguagePreference,setPluginLanguagePreference} from './plugin-frame';
@@ -32,7 +33,7 @@ import {InstallReview} from './install-review';
import {MemoryLimit} from './memory-limit';
import {installScrollbars} from '../../sdk/src/scrollbars';
installScrollbars();
import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey,exportDiagnosticLogs}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';import './mobile-manager.css';import './notification-surfaces.css';if(location.pathname==='/launcher')document.documentElement.classList.add('launcher-view');if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view');
import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey,exportDiagnosticLogs}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';import './mobile-manager.css';import './notification-surfaces.css';if(location.pathname==='/launcher')document.documentElement.classList.add('launcher-view');if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view');if(location.pathname==='/apk/update')document.documentElement.classList.add('apk-update-view');
export type Manifest=Presentation&{id:string;name:string;version:string;author:string;description:string;backend?:{runAs:string;autostart:boolean;memoryLimitMiB?:number};lifecycle?:{runAs?:string};dependencies?:Record<string,unknown>;optionalDependencies?:Record<string,unknown>;conflicts?:Record<string,string>;exclusiveResources?:string[];engines?:{framely?:string};ui:{quickPage?:string;windows:Record<string,unknown>;launch?:Record<string,{type:string;window?:string}>;launcherActions?:{id:string;label:string;target:{type:string;entry?:string;window?:string}}[]}};
export type Plugin={manifest:Manifest;enabled:boolean;favorite:boolean;order:number;source?:string;error?:string};
type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:boolean};
@@ -46,6 +47,7 @@ function App(){
if(location.pathname==='/cast/framing')return <SpatialFraming/>;
if(location.pathname==='/cast/screenshot')return <ScreenshotFraming/>;
if(location.pathname==='/apk/permission')return <ApkPermission/>;
if(location.pathname==='/apk/update')return <ApkUpdate id={new URLSearchParams(location.search).get('id')??'invalid'}/>;
if(location.pathname==='/cast/watch')return <CastViewer/>;
if(location.pathname.startsWith('/cast/window/'))return <CastPlayer/>;
const mobile=useMobileManager();useMobileViewport(managerMode&&mobile);
+1 -1
View File
@@ -8,5 +8,5 @@ export function ProxySettings({config,refresh}:{config:ProxyConfig;refresh:()=>P
useEffect(()=>{setHttp(config.http);setGithub(config.github);setHttpEnabled(config.httpEnabled??false);setGithubEnabled(config.githubEnabled??false);setGithubAutoFallback(config.githubAutoFallback??true);},[config.http,config.github,config.httpEnabled,config.githubEnabled,config.githubAutoFallback]);
async function save(){setBusy(true);setError('');try{await api('proxy.save',{http:http.trim(),github:github.trim(),httpEnabled,githubEnabled,githubAutoFallback});await refresh();}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
const unchanged=http===config.http&&github===config.github&&httpEnabled===(config.httpEnabled??false)&&githubEnabled===(config.githubEnabled??false)&&githubAutoFallback===(config.githubAutoFallback??true);
return <section className="settings-card"><h2>{t("代理设置")}</h2><div className="proxy-fields"><div><SwitchRow checked={httpEnabled} label={t("启用 HTTP 代理")} disabled={busy} onChange={setHttpEnabled}/><label><input aria-label={t("HTTP 代理地址")} disabled={!httpEnabled} maxLength={2048} type="url" value={http} placeholder="http://192.168.1.2:7890" onChange={e=>setHttp(e.target.value)}/></label></div><div><SwitchRow checked={githubEnabled} label={t("启用 GitHub 代理")} disabled={busy} onChange={setGithubEnabled}/><label><input aria-label={t("GitHub 代理地址")} disabled={!githubEnabled} maxLength={2048} type="url" value={github} placeholder="https://gh-proxy.com" onChange={e=>setGithub(e.target.value)}/></label></div></div><SwitchRow checked={githubAutoFallback} label={t("GitHub 连接失败时自动使用 gh-proxy.com")} description={t("公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。")} disabled={busy} onChange={setGithubAutoFallback}/><div className="row"><button disabled={busy||unchanged||(httpEnabled&&!http.trim())||(githubEnabled&&!github.trim())} onClick={()=>void save()}>{busy?t("保存中…"):t("保存")}</button></div>{error&&<p className="error" role="alert">{error}</p>}</section>;
return <section className="settings-card"><h2>{t("代理设置")}</h2><div className="proxy-fields"><div><SwitchRow checked={httpEnabled} label={t("启用 HTTP 代理")} disabled={busy} onChange={setHttpEnabled}/><label><input aria-label={t("HTTP 代理地址")} disabled={!httpEnabled} maxLength={2048} type="url" value={http} placeholder="http://192.168.1.2:7890" onChange={e=>setHttp(e.target.value)}/></label></div><div><SwitchRow checked={githubEnabled} label={t("启用 GitHub 代理")} disabled={busy} onChange={setGithubEnabled}/><label><input aria-label={t("GitHub 代理地址")} disabled={!githubEnabled} maxLength={2048} type="url" value={github} placeholder="https://gh-proxy.com" onChange={e=>setGithub(e.target.value)}/></label></div></div><SwitchRow checked={githubAutoFallback} label={t("GitHub 连接失败时自动尝试备用线路")} description={t("直连失败时尝试 gh-proxy.com;已启用 GitHub 代理时,代理遇到限额或网络错误则尝试直连。只重试一次,下载仍会校验。")} disabled={busy} onChange={setGithubAutoFallback}/><div className="row"><button disabled={busy||unchanged||(httpEnabled&&!http.trim())||(githubEnabled&&!github.trim())} onClick={()=>void save()}>{busy?t("保存中…"):t("保存")}</button></div>{error&&<p className="error" role="alert">{error}</p>}</section>;
}