mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-24 00:01:58 +02:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5886198f4b | ||
|
|
f6e7cc4674 | ||
|
|
58fcbb851e | ||
|
|
7f12cdb7d7 | ||
|
|
da0bb44a98 | ||
|
|
4e6e538b23 | ||
|
|
4b06a59995 | ||
|
|
cea24afbc9 | ||
|
|
e43000875c | ||
|
|
a159dfc2d2 | ||
|
|
7be3553936 | ||
|
|
9e25abb9b6 | ||
|
|
590ae46bc5 | ||
|
|
f9f14947ee | ||
|
|
4746357769 | ||
|
|
b3182c46ec | ||
|
|
109160ac5b | ||
|
|
342fa96056 |
No files matched your search
@@ -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
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.8.3-preview.3"
|
||||
version = "0.8.4-preview.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.8.3-preview.3"
|
||||
version = "0.8.4-preview.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Download the package for your computer from the [Framely Installer release](http
|
||||
Run the following command **in Frame's SSH terminal** to install the latest stable release:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
|
||||
curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes
|
||||
```
|
||||
|
||||
For a Preview release, specify an actual published tag:
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ Framely 使用 Rust 核心服务和独立的 CEF/OpenVR 宿主,并提供电脑
|
||||
**在 Frame 的 SSH 终端中**执行以下命令,安装最新正式版:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
|
||||
curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes
|
||||
```
|
||||
|
||||
安装 Preview 时指定实际已发布的标签:
|
||||
|
||||
+3
-3
@@ -38,7 +38,7 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven
|
||||
|
||||
Device and installer share the repository with independent versions/tags/workflows:
|
||||
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.8.3-preview.3`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.8.3`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.3`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
|
||||
|
||||
Versions need not match or ship together. Update the relevant Cargo.lock with version changes. Installer package/macOS versions use the installer version.
|
||||
@@ -47,8 +47,8 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
|
||||
|
||||
```bash
|
||||
# After committing and pushing the intended source:
|
||||
git tag v0.8.3-preview.3
|
||||
git push origin v0.8.3-preview.3
|
||||
git tag v0.8.3
|
||||
git push origin v0.8.3
|
||||
# Independent installer release:
|
||||
git tag installer-v0.4.3
|
||||
git push origin installer-v0.4.3
|
||||
|
||||
@@ -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://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
|
||||
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,6 +40,16 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
|
||||
Replace the example with an existing device release tag. An explicit tag selects the engine and package from that Release and works without a stable release. Without a tag, the latest stable release is selected. Enter the sudo password when prompted.
|
||||
|
||||
Explicit `install` and `update` commands 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`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://gh-proxy.com/https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes --github-proxy https://gh-proxy.com
|
||||
```
|
||||
|
||||
Release `v0.8.2` does not include this option; use `v0.8.3` or newer. The installer still verifies archive checksums.
|
||||
|
||||
## Option 3: local archive
|
||||
|
||||
Download `framely-<version-and-build>-offline-linux-arm64.tar.gz`, the external `SHA256SUMS`, and `bootstrap.py` from the same Release. Place them together on Frame:
|
||||
@@ -48,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:
|
||||
@@ -99,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:
|
||||
|
||||
@@ -48,7 +48,7 @@ CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时
|
||||
|
||||
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
|
||||
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.8.3-preview.3`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.8.3`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.3`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
|
||||
|
||||
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
|
||||
@@ -59,8 +59,8 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
|
||||
|
||||
```bash
|
||||
# 提交并推送代码后,选择需要发布的产品
|
||||
git tag v0.8.3-preview.3
|
||||
git push origin v0.8.3-preview.3
|
||||
git tag v0.8.3
|
||||
git push origin v0.8.3
|
||||
|
||||
# 独立发布安装器,不触发 Framely 构建
|
||||
git tag installer-v0.4.3
|
||||
|
||||
@@ -24,10 +24,18 @@ Linux 用户可在安装器解压目录执行 `bash install-desktop-entry.sh`,
|
||||
|
||||
## 方式二:在 Frame 的 SSH 终端安装
|
||||
|
||||
以下命令在 **Frame 上** 执行。默认安装最新正式版:
|
||||
以下命令在 **Frame 上** 执行。
|
||||
|
||||
不带参数时会打开交互向导,自动识别安装或更新,选择正式版/测试版及下载线路;在最终确认前可返回或退出:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
|
||||
curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash
|
||||
```
|
||||
|
||||
需要跳过向导时,继续使用明确的命令:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes
|
||||
```
|
||||
|
||||
指定 Preview 或其他标签:
|
||||
@@ -38,6 +46,16 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
|
||||
将示例标签换成 Releases 中实际存在的本体标签。指定标签时从该 Release 下载引擎和包,不要求已有正式版;不指定标签时使用最新正式版。终端会按需请求 sudo 密码。
|
||||
|
||||
明确指定 `install` 或 `update` 时仍按原方式执行。交互向导从终端读取选项,选择直连时本次运行不会自动切换镜像;选择镜像并将地址留空时使用 `https://gh-proxy.com`。如果通过一条 `ssh` 远程命令执行且 sudo 需要密码,请用 `ssh -t` 分配交互终端;`--yes` 仅跳过 Framely 的确认,不会跳过 sudo 密码。
|
||||
|
||||
使用 `--github-proxy` 可通过指定的 HTTPS 地址代理入口脚本及后续所有 GitHub 下载。例如:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://gh-proxy.com/https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash -s -- install --yes --github-proxy https://gh-proxy.com
|
||||
```
|
||||
|
||||
`v0.8.2` 不支持此参数,请使用 `v0.8.3` 或更新版本。安装器仍会校验下载包的哈希值。
|
||||
|
||||
## 方式三:本地发行包
|
||||
|
||||
下载完整离线包 `framely-<版本及构建号>-offline-linux-arm64.tar.gz`、外部 `SHA256SUMS` 和同一 Release 的 `bootstrap.py`,放入 Frame 的同一目录:
|
||||
@@ -48,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 提供三种压缩包:
|
||||
@@ -99,6 +119,8 @@ sudo journalctl -u framely -u framely-session --no-pager -n 100
|
||||
|
||||
修复会使用已保留的发行恢复账号及服务;UID 冲突时停止。它不能找回已删除的 `/home` 数据,SteamOS 更新后的恢复仍需实机验证。
|
||||
|
||||
命令行安装日志使用同一个追踪编号记录开始时间、版本、发行包、下载线路、阶段耗时与安全错误码。下载时会持续显示已下载量;失败时会给出原因和下一步。反馈安装问题时,请提供追踪编号、出错阶段和相关输出;发送前仍应检查并移除终端中其他程序可能打印的密码、Token 或个人信息。安装器会隐藏自定义代理地址及带参数的下载 URL。
|
||||
|
||||
## 卸载
|
||||
|
||||
桌面安装器“安装与维护”提供卸载,也可直接执行:
|
||||
|
||||
+247
-18
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Public bootstrap: curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash
|
||||
# Public bootstrap: curl -fsSL https://github.com/SteamFramelyHomebrew/framely/releases/latest/download/install.sh | bash
|
||||
set -euo pipefail
|
||||
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; }
|
||||
@@ -8,19 +9,34 @@ work=$(mktemp -d)
|
||||
trap 'rm -rf -- "$work"' EXIT
|
||||
release_path=latest/download
|
||||
args=("$@")
|
||||
for ((i=0; i<${#args[@]}; i++)); do
|
||||
case ${args[i]} in
|
||||
--version)
|
||||
((i+=1))
|
||||
version=${args[i]:-}
|
||||
;;
|
||||
--version=*) version=${args[i]#--version=} ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
[[ $version =~ ^[a-zA-Z0-9][a-zA-Z0-9.+-]*$ ]] || { echo 'Invalid release version.' >&2; exit 1; }
|
||||
release_path="download/$version"
|
||||
done
|
||||
bootstrap_url="https://github.com/$repo/releases/$release_path/bootstrap.py"
|
||||
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
|
||||
@@ -33,11 +49,224 @@ else
|
||||
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
|
||||
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 'GitHub connection failed; trying gh-proxy.com once' >&2
|
||||
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 "Bootstrap primary route failed (curl $code, HTTP $status); gh-proxy.com fallback also failed." >&2; exit 1
|
||||
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
|
||||
python3 "$work/bootstrap.py" --repo "$repo" "$@"
|
||||
}
|
||||
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)
|
||||
((i+=1))
|
||||
version=${args[i]:-}
|
||||
[[ $version =~ ^[a-zA-Z0-9][a-zA-Z0-9.+-]*$ ]] || { echo 'Invalid release version.' >&2; exit 1; }
|
||||
release_path="download/$version"
|
||||
;;
|
||||
--version=*)
|
||||
version=${args[i]#--version=}
|
||||
[[ $version =~ ^[a-zA-Z0-9][a-zA-Z0-9.+-]*$ ]] || { echo 'Invalid release version.' >&2; exit 1; }
|
||||
release_path="download/$version"
|
||||
;;
|
||||
--github-proxy)
|
||||
((i+=1))
|
||||
github_proxy=${args[i]:-}
|
||||
[[ -n $github_proxy ]] || { echo 'Missing GitHub proxy URL.' >&2; exit 1; }
|
||||
;;
|
||||
--github-proxy=*)
|
||||
github_proxy=${args[i]#--github-proxy=}
|
||||
[[ -n $github_proxy ]] || { echo 'Missing GitHub proxy URL.' >&2; exit 1; }
|
||||
;;
|
||||
*) continue ;;
|
||||
esac
|
||||
done
|
||||
if [[ -n $github_proxy ]]; then
|
||||
[[ $github_proxy =~ ^https://[a-zA-Z0-9.-]+(:[0-9]{1,5})?(/[a-zA-Z0-9._~/-]*)?$ && /$github_proxy/ != *'/../'* && /$github_proxy/ != *'/./'* ]] || { echo 'Invalid GitHub proxy URL (HTTPS base URL required).' >&2; exit 1; }
|
||||
github_proxy=${github_proxy%/}
|
||||
export FRAMELY_GITHUB_PROXY=$github_proxy
|
||||
fi
|
||||
fetch_engine
|
||||
python3 "$work/bootstrap.py" --repo "$repo" "${args[@]}"
|
||||
@@ -43,6 +43,7 @@ const uint FLAG_PER_CHANNEL = 1u;
|
||||
const uint FLAG_SUPERSAMPLE = 2u;
|
||||
const uint FLAG_ROTATED_EYES = 4u;
|
||||
const uint FLAG_SHARP = 8u;
|
||||
const uint FLAG_SNAPSHOT = 16u;
|
||||
|
||||
// Fringes narrower than this, in output pixels, aren't worth extra reads.
|
||||
const float FRINGE_MIN = 0.3;
|
||||
@@ -212,6 +213,17 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
if ((p.flags & FLAG_SNAPSHOT) != 0u) {
|
||||
for (uint k = 0u; k < 8u; k++) {
|
||||
uint x = x0 + (k & 3u);
|
||||
uint y = y0 + (k >> 2);
|
||||
if (x >= p.out_size.x || y >= p.out_size.y) continue;
|
||||
uvec3 color = uvec3(clamp(px[k] * 255.0 + 0.5, 0.0, 255.0));
|
||||
nv12[y * p.out_size.x + x] = color.r | (color.g << 8) | (color.b << 16) | (255u << 24);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
uint row0 = 0u;
|
||||
uint row1 = 0u;
|
||||
for (uint dx = 0u; dx < 4u; dx++) {
|
||||
|
||||
@@ -93,6 +93,7 @@ pub struct Gpu {
|
||||
/// Where images handed to us by someone else get copied before converting.
|
||||
copy: Option<CopyTarget>,
|
||||
physical_device: vk::PhysicalDevice,
|
||||
snapshot: bool,
|
||||
}
|
||||
|
||||
/// How the shader reads the source.
|
||||
@@ -157,6 +158,10 @@ struct CopyTarget {
|
||||
const COPY_SOURCE: u32 = u32::MAX;
|
||||
|
||||
impl Gpu {
|
||||
pub fn new_snapshot(width: u32, height: u32, lut: &Lut) -> Result<Self> {
|
||||
let size = width.checked_mul(height).and_then(|n| n.checked_mul(4)).context("snapshot too large")?;
|
||||
Self::new_mode(Nv12Layout { width, height, y_stride: width * 4, uv_offset: 0, size }, 1, lut, Filter::Sharp, Priority::Low, None, true)
|
||||
}
|
||||
/// `extra` adds extensions someone else needs on our instance and device
|
||||
/// (SteamVR, to hand us its headset view).
|
||||
pub fn new(
|
||||
@@ -166,6 +171,18 @@ impl Gpu {
|
||||
filter: Filter,
|
||||
priority: Priority,
|
||||
extra: Option<&Extensions>,
|
||||
) -> Result<Self> {
|
||||
Self::new_mode(layout, target_count, lut, filter, priority, extra, false)
|
||||
}
|
||||
|
||||
fn new_mode(
|
||||
layout: Nv12Layout,
|
||||
target_count: usize,
|
||||
lut: &Lut,
|
||||
filter: Filter,
|
||||
priority: Priority,
|
||||
extra: Option<&Extensions>,
|
||||
snapshot: bool,
|
||||
) -> Result<Self> {
|
||||
let entry = unsafe { ash::Entry::load() }.context("loading Vulkan")?;
|
||||
let app = vk::ApplicationInfo::default()
|
||||
@@ -180,7 +197,7 @@ impl Gpu {
|
||||
}
|
||||
.context("creating Vulkan instance")?;
|
||||
|
||||
Self::with_instance(entry, instance, layout, target_count, lut, filter, priority, extra)
|
||||
Self::with_instance(entry, instance, layout, target_count, lut, filter, priority, extra, snapshot)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -193,6 +210,7 @@ impl Gpu {
|
||||
filter: Filter,
|
||||
priority: Priority,
|
||||
extra: Option<&Extensions>,
|
||||
snapshot: bool,
|
||||
) -> Result<Self> {
|
||||
let pdev = unsafe { instance.enumerate_physical_devices() }?
|
||||
.into_iter()
|
||||
@@ -282,7 +300,8 @@ impl Gpu {
|
||||
flags: (lut.per_channel as u32)
|
||||
| (((filter == Filter::Supersample) as u32) << 1)
|
||||
| ((lut.rotated_eyes as u32) << 2)
|
||||
| (((filter == Filter::Sharp) as u32) << 3),
|
||||
| (((filter == Filter::Sharp) as u32) << 3)
|
||||
| ((snapshot as u32) << 4),
|
||||
},
|
||||
layout,
|
||||
targets: Vec::new(),
|
||||
@@ -290,6 +309,7 @@ impl Gpu {
|
||||
tick: 0,
|
||||
copy: None,
|
||||
physical_device: pdev,
|
||||
snapshot,
|
||||
};
|
||||
gpu.create_pipeline(target_count)?;
|
||||
gpu.upload_lut(lut)?;
|
||||
@@ -441,7 +461,7 @@ impl Gpu {
|
||||
let buffer = d.create_buffer(
|
||||
&vk::BufferCreateInfo::default()
|
||||
.size(self.layout.size as u64)
|
||||
.usage(vk::BufferUsageFlags::STORAGE_BUFFER)
|
||||
.usage(vk::BufferUsageFlags::STORAGE_BUFFER | vk::BufferUsageFlags::TRANSFER_SRC)
|
||||
.push_next(&mut ext),
|
||||
None,
|
||||
)?;
|
||||
@@ -681,8 +701,8 @@ impl Gpu {
|
||||
.image(image)
|
||||
.subresource_range(color_range());
|
||||
let acquire_buffer = vk::BufferMemoryBarrier::default()
|
||||
.src_queue_family_index(foreign)
|
||||
.dst_queue_family_index(ours)
|
||||
.src_queue_family_index(if self.snapshot { vk::QUEUE_FAMILY_IGNORED } else { foreign })
|
||||
.dst_queue_family_index(if self.snapshot { vk::QUEUE_FAMILY_IGNORED } else { ours })
|
||||
.dst_access_mask(vk::AccessFlags::SHADER_WRITE)
|
||||
.buffer(target.buffer)
|
||||
.size(vk::WHOLE_SIZE);
|
||||
@@ -715,8 +735,8 @@ impl Gpu {
|
||||
.image(image)
|
||||
.subresource_range(color_range());
|
||||
let release_buffer = vk::BufferMemoryBarrier::default()
|
||||
.src_queue_family_index(ours)
|
||||
.dst_queue_family_index(foreign)
|
||||
.src_queue_family_index(if self.snapshot { vk::QUEUE_FAMILY_IGNORED } else { ours })
|
||||
.dst_queue_family_index(if self.snapshot { vk::QUEUE_FAMILY_IGNORED } else { foreign })
|
||||
.src_access_mask(vk::AccessFlags::SHADER_WRITE)
|
||||
.buffer(target.buffer)
|
||||
.size(vk::WHOLE_SIZE);
|
||||
@@ -749,6 +769,45 @@ impl Gpu {
|
||||
self.submit(&[cmd])
|
||||
}
|
||||
|
||||
/// Read back the one-shot RGBA target after convert has completed.
|
||||
pub fn snapshot_pixels(&self) -> Result<Vec<u8>> {
|
||||
anyhow::ensure!(self.snapshot, "not a snapshot target");
|
||||
let d = &self.device;
|
||||
let size = self.layout.size as u64;
|
||||
unsafe {
|
||||
let staging = d.create_buffer(&vk::BufferCreateInfo::default().size(size).usage(vk::BufferUsageFlags::TRANSFER_DST), None)?;
|
||||
let result = (|| -> Result<Vec<u8>> {
|
||||
let req = d.get_buffer_memory_requirements(staging);
|
||||
let ty = self.find_memory_type(req.memory_type_bits, vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT).context("no host-visible snapshot memory")?;
|
||||
let memory = d.allocate_memory(&vk::MemoryAllocateInfo::default().allocation_size(req.size).memory_type_index(ty), None)?;
|
||||
let result = (|| -> Result<Vec<u8>> {
|
||||
d.bind_buffer_memory(staging, memory, 0)?;
|
||||
let cmds = d.allocate_command_buffers(&vk::CommandBufferAllocateInfo::default().command_pool(self.cmd_pool).level(vk::CommandBufferLevel::PRIMARY).command_buffer_count(1))?;
|
||||
let cmd = cmds[0];
|
||||
let copied = (|| -> Result<Vec<u8>> {
|
||||
d.begin_command_buffer(cmd, &vk::CommandBufferBeginInfo::default())?;
|
||||
let barrier = vk::BufferMemoryBarrier::default().src_access_mask(vk::AccessFlags::SHADER_WRITE).dst_access_mask(vk::AccessFlags::TRANSFER_READ).buffer(self.targets[0].buffer).size(size);
|
||||
d.cmd_pipeline_barrier(cmd, vk::PipelineStageFlags::COMPUTE_SHADER, vk::PipelineStageFlags::TRANSFER, vk::DependencyFlags::empty(), &[], &[barrier], &[]);
|
||||
d.cmd_copy_buffer(cmd, self.targets[0].buffer, staging, &[vk::BufferCopy::default().size(size)]);
|
||||
d.end_command_buffer(cmd)?;
|
||||
d.queue_submit(self.queue, &[vk::SubmitInfo::default().command_buffers(&[cmd])], vk::Fence::null())?;
|
||||
d.queue_wait_idle(self.queue)?;
|
||||
let ptr = d.map_memory(memory, 0, size, vk::MemoryMapFlags::empty())?;
|
||||
let bytes = std::slice::from_raw_parts(ptr.cast::<u8>(), size as usize).to_vec();
|
||||
d.unmap_memory(memory);
|
||||
Ok(bytes)
|
||||
})();
|
||||
d.free_command_buffers(self.cmd_pool, &cmds);
|
||||
copied
|
||||
})();
|
||||
d.free_memory(memory, None);
|
||||
result
|
||||
})();
|
||||
d.destroy_buffer(staging, None);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs command buffers, waits for them, and returns the conversion
|
||||
/// shader's GPU time from the timestamps it wrote.
|
||||
fn submit(&self, cmds: &[vk::CommandBuffer]) -> Result<std::time::Duration> {
|
||||
|
||||
@@ -9,7 +9,7 @@ mod panel_geometry;
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use framely_capture::openvr::OpenVr;
|
||||
use std::{io::{self, Write}, os::fd::AsRawFd, path::Path, time::{Duration, Instant}};
|
||||
use std::{io::{self, Read, Write}, os::fd::AsRawFd, path::Path, time::{Duration, Instant}};
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Args {
|
||||
@@ -20,6 +20,10 @@ struct Args {
|
||||
#[arg(long, default_value_t = 0)] eye: usize,
|
||||
#[arg(long)] raw: bool,
|
||||
#[arg(long)] describe_view: bool,
|
||||
/// Emit one native-resolution RGBA frame (width, height, then pixels).
|
||||
#[arg(long)] snapshot: bool,
|
||||
/// Prepare the snapshot pipeline, then wait for a byte on stdin before sampling.
|
||||
#[arg(long)] snapshot_wait: bool,
|
||||
#[arg(long, value_enum, default_value_t = encoder::Codec::H264)] codec: encoder::Codec,
|
||||
#[arg(long)] fov: Option<f64>,
|
||||
#[arg(long, default_value_t = 0.)] center_x: f64,
|
||||
@@ -34,6 +38,9 @@ fn run(a: Args) -> Result<()> {
|
||||
anyhow::ensure!(a.fov.is_none_or(|v| v.is_finite() && (10.0..=150.0).contains(&v))
|
||||
&& [a.center_x, a.center_y].into_iter().all(|v| v.is_finite() && (-60.0..=60.0).contains(&v)), "Invalid field of view");
|
||||
anyhow::ensure!(!a.describe_view || !a.raw, "Raw view has no single-eye region");
|
||||
anyhow::ensure!(!a.snapshot || !a.describe_view, "Choose one capture mode");
|
||||
anyhow::ensure!(!a.snapshot_wait || a.snapshot, "Snapshot wait requires snapshot mode");
|
||||
if a.snapshot { return snapshot(&a); }
|
||||
let kms = if a.describe_view {None} else {Some(kms::Kms::open(Path::new("/dev/dri/card0"))?)};
|
||||
let height = if a.raw { let kms=kms.as_ref().unwrap(); ((a.width as f64 * kms.mode_size.1 as f64 / kms.mode_size.0 as f64).round() as u32 + 1) & !1 } else { a.height };
|
||||
anyhow::ensure!(height>=160&&height<=4096&&u64::from(a.width)*u64::from(height)<=8_847_360,"Raw output exceeds supported dimensions");
|
||||
@@ -86,6 +93,50 @@ fn run(a: Args) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(a: &Args) -> Result<()> {
|
||||
let kms = kms::Kms::open(Path::new("/dev/dri/card0"))?;
|
||||
let (width, height, map) = if a.raw {
|
||||
let (w, h) = kms.mode_size;
|
||||
(w, h, lut::raw(w, h, w, h))
|
||||
} else {
|
||||
let aspect = a.width as f64 / a.height as f64;
|
||||
let panel_w = kms.mode_size.0 / 2;
|
||||
let panel_h = kms.mode_size.1;
|
||||
let max_w = (panel_h as f64 * aspect).round().min(panel_w as f64) as u32;
|
||||
let max_h = (max_w as f64 / aspect).round().min(panel_h as f64) as u32;
|
||||
let base = lut::undistorted(&OpenVr::connect()?, a.eye, None, max_w, max_h, a.center_x, a.center_y)?;
|
||||
let full_tan = base.view.context("No eye projection")?.0;
|
||||
let selected_tan = a.fov.map(|f| (f.to_radians() / 2.0).tan()).unwrap_or(full_tan);
|
||||
let fraction = (selected_tan / full_tan).clamp(0.01, 1.0);
|
||||
let width = ((max_w as f64 * fraction).round() as u32).max(2);
|
||||
let height = ((max_h as f64 * fraction).round() as u32).max(2);
|
||||
(width, height, lut::undistorted(&OpenVr::connect()?, a.eye, a.fov, width, height, a.center_x, a.center_y)?)
|
||||
};
|
||||
anyhow::ensure!(width > 0 && height > 0 && u64::from(width) * u64::from(height) <= 9_331_200, "Invalid native snapshot size");
|
||||
let mut gpu = gpu::Gpu::new_snapshot(width, height, &map)?;
|
||||
if a.snapshot_wait {
|
||||
let mut trigger = [0u8; 1];
|
||||
io::stdin().read_exact(&mut trigger).context("Snapshot was cancelled")?;
|
||||
}
|
||||
// The editor overlay has been closed by the caller. Skip one complete
|
||||
// scanout before choosing the frame so its pixels cannot enter the PNG.
|
||||
kms.wait_vblank()?;
|
||||
kms.wait_vblank()?;
|
||||
let fb = kms.current_fb()?.context("Headset display is unavailable")?;
|
||||
let buf = kms.export(fb)?;
|
||||
kms::wait_idle(&buf.fd, Duration::from_millis(4));
|
||||
let id = kms::buffer_id(&buf.fd)?;
|
||||
gpu.add_source(buf, id)?;
|
||||
gpu.convert(fb, id, 0)?;
|
||||
let pixels = gpu.snapshot_pixels()?;
|
||||
let mut output = io::BufWriter::new(io::stdout().lock());
|
||||
output.write_all(&width.to_le_bytes())?;
|
||||
output.write_all(&height.to_le_bytes())?;
|
||||
output.write_all(&pixels)?;
|
||||
output.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
if let Err(e) = run(Args::parse()) { eprintln!("{e:#}"); std::process::exit(1); }
|
||||
|
||||
+419
-22
@@ -34,12 +34,15 @@
|
||||
#include "recording_indicator.h"
|
||||
#include "recording_region.h"
|
||||
#include "recording_region_depth.h"
|
||||
#include "system_radial.h"
|
||||
#include "framing_lifecycle.h"
|
||||
#include "plugin_navigation.h"
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glx.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <csignal>
|
||||
#include <chrono>
|
||||
@@ -118,6 +121,15 @@ static cef_frame_t* keyboard_frame=nullptr;
|
||||
static std::string keyboard_view;
|
||||
static KeyboardMenuGuard keyboard_menu;
|
||||
static uint64_t keyboard_token=0;static vr::VROverlayHandle_t keyboard_target=0;
|
||||
constexpr const char* frame_keyboard_window_key="framely.window.tooru.frame-keyboard.keyboard";
|
||||
struct ExternalKeyboard {
|
||||
bool active=false,hide_pending=false;
|
||||
vr::VROverlayHandle_t target=0;
|
||||
uint64_t token=0;
|
||||
std::string overlay_key,view_key;
|
||||
std::chrono::steady_clock::time_point hide_pending_until{};
|
||||
};
|
||||
static ExternalKeyboard external_keyboard;
|
||||
static std::mutex queue_mutex;
|
||||
static std::mutex visibility_mutex;
|
||||
static json visible_views=nullptr;
|
||||
@@ -140,6 +152,7 @@ struct View {
|
||||
vr::VROverlayHandle_t overlay=0,thumbnail=0;GLuint textures[2]{};int texture_slot=0;bool texture_ready[2]{};PaintDamage texture_damage[2];std::vector<uint8_t> pixels;
|
||||
};
|
||||
static std::map<std::string,std::unique_ptr<View>> views;
|
||||
static void close_view(View& v);
|
||||
static bool permission_trigger_held[2]{};
|
||||
static View* find_browser(cef_browser_t* b){int id=b->get_identifier(b);for(auto& [key,v]:views)if(v->browser_id==id)return v.get();return nullptr;}
|
||||
static Handler<cef_client_t> client;
|
||||
@@ -178,10 +191,87 @@ static std::string pending_recording_stop;
|
||||
static json recording_state=json::object();
|
||||
static json recording_region_state=json::object();
|
||||
static json framing_editor_state=nullptr;
|
||||
static json screenshot_editor_state=nullptr;
|
||||
static json screenshot_shortcut_preview=nullptr;
|
||||
static bool screenshot_radial_held=false;
|
||||
static bool system_radial_available=false;
|
||||
static std::atomic<bool> system_radial_enabled{true};
|
||||
static SystemRadial system_radial;
|
||||
static bool system_radial_preview_prepared=false;
|
||||
static bool system_radial_progress_visible=false;
|
||||
static vr::HmdVector3_t system_radial_origin{};
|
||||
static vr::HmdVector3_t system_radial_right{};
|
||||
static vr::HmdVector3_t system_radial_up{};
|
||||
static bool system_radial_origin_valid=false;
|
||||
static vr::HmdMatrix34_t system_radial_pose[2]{};
|
||||
static SystemRadial::Clock::time_point system_radial_pose_at[2]{};
|
||||
static vr::TrackedDeviceIndex_t system_radial_pose_device[2]{vr::k_unTrackedDeviceIndexInvalid,vr::k_unTrackedDeviceIndexInvalid};
|
||||
static std::chrono::steady_clock::time_point system_radial_sent{};
|
||||
static std::string pending_screenshot_hotkey,pending_screenshot_close,pending_system_action;
|
||||
static uint64_t system_action_sequence=0;
|
||||
static RecordingRegionOverlay screenshot_region_overlay{"framely.screenshot.region"};
|
||||
static std::string pending_region_close;
|
||||
static RecordingRegionOverlay recording_region_overlay;
|
||||
static RecordingIndicator recording_indicator;
|
||||
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;std::string stop_id,region_closed;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();stop_id=pending_recording_stop;region_closed=pending_region_close;payload=json{{"views",visible_views},{"closedCasts",closed_casts},{"stopRecording",stop_id},{"framingClosed",region_closed}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard<std::mutex> lock(visibility_mutex);if(pending_region_close==region_closed)pending_region_close.clear();if(pending_recording_stop==stop_id)pending_recording_stop.clear();closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
|
||||
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;std::string stop_id,region_closed,screenshot_closed,hotkey,system_action;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();stop_id=pending_recording_stop;region_closed=pending_region_close;screenshot_closed=pending_screenshot_close;hotkey=pending_screenshot_hotkey;system_action=pending_system_action;payload=json{{"views",visible_views},{"closedCasts",closed_casts},{"stopRecording",stop_id},{"framingClosed",region_closed},{"screenshotClosed",screenshot_closed},{"screenshotHotkey",hotkey},{"radialAction",system_action}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard<std::mutex> lock(visibility_mutex);if(pending_region_close==region_closed)pending_region_close.clear();if(pending_screenshot_close==screenshot_closed)pending_screenshot_close.clear();if(pending_screenshot_hotkey==hotkey)pending_screenshot_hotkey.clear();if(pending_system_action==system_action)pending_system_action.clear();if(pending_recording_stop==stop_id)pending_recording_stop.clear();closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
|
||||
static json service_call(const std::string& method,const json& params){
|
||||
const char* configured=std::getenv("FRAMELY_CONTROL_SOCKET");
|
||||
const std::string path=configured&&*configured?configured:"/run/framely/control.sock";
|
||||
sockaddr_un address{};address.sun_family=AF_UNIX;
|
||||
if(path.size()>=sizeof(address.sun_path))throw std::runtime_error("Control socket path is too long");
|
||||
std::strncpy(address.sun_path,path.c_str(),sizeof(address.sun_path)-1);
|
||||
const int fd=socket(AF_UNIX,SOCK_STREAM|SOCK_CLOEXEC,0);
|
||||
if(fd<0)throw std::runtime_error("Control socket failed");
|
||||
timeval timeout{2,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));
|
||||
if(connect(fd,reinterpret_cast<sockaddr*>(&address),sizeof(address))<0){close(fd);throw std::runtime_error("Control service unavailable");}
|
||||
const std::string request=json{{"method",method},{"params",params}}.dump()+"\n";
|
||||
size_t sent=0;
|
||||
while(sent<request.size()){
|
||||
const auto count=send(fd,request.data()+sent,request.size()-sent,MSG_NOSIGNAL);
|
||||
if(count<=0){close(fd);throw std::runtime_error("Control request failed");}
|
||||
sent+=static_cast<size_t>(count);
|
||||
}
|
||||
std::string response;char buffer[4096];
|
||||
while(response.find('\n')==std::string::npos){
|
||||
const auto count=recv(fd,buffer,sizeof(buffer),0);
|
||||
if(count<=0){close(fd);throw std::runtime_error("Control response failed");}
|
||||
response.append(buffer,static_cast<size_t>(count));
|
||||
if(response.size()>1024*1024){close(fd);throw std::runtime_error("Control response is too large");}
|
||||
}
|
||||
close(fd);
|
||||
auto reply=json::parse(response.substr(0,response.find('\n')));
|
||||
if(reply.contains("error"))throw std::runtime_error(reply["error"].get<std::string>());
|
||||
return reply.value("result",json(nullptr));
|
||||
}
|
||||
static bool frame_keyboard_command(const std::string& method,const json& params){
|
||||
try{
|
||||
auto result=service_call("plugin.call",json{{"plugin","tooru.frame-keyboard"},{"method",method},{"params",params}});
|
||||
if(result.is_null())return false;
|
||||
if(result.is_object()&&result.contains("ok")&&result["ok"].is_boolean()&&!result["ok"].get<bool>()){
|
||||
std::cerr<<"Frame keyboard "<<method<<" returned ok=false";
|
||||
if(result.contains("error")&&result["error"].is_string())std::cerr<<": "<<result["error"].get<std::string>();
|
||||
std::cerr<<"\n";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}catch(const std::exception& error){
|
||||
std::cerr<<"Frame keyboard command failed: "<<error.what()<<"\n";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
static bool plugin_handles_external_keyboard(){
|
||||
// Version 0.3 of the plugin listens to OpenVR global keyboard events itself.
|
||||
// Cache the capability briefly so a missing plugin still falls back quickly.
|
||||
static std::atomic<int> cached{-1};static std::atomic<long long> checked_at{0};
|
||||
const auto now=std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
if(cached.load()>=0&&now-checked_at.load()<5000)return cached.load()==1;
|
||||
bool enabled=false;
|
||||
try{
|
||||
auto result=service_call("plugin.call",json{{"plugin","tooru.frame-keyboard"},{"method","keyboardInterception"},{"params",json::object()}});
|
||||
enabled=result.is_object()&&result.value("enabled",false);
|
||||
}catch(...){}
|
||||
cached.store(enabled?1:0);checked_at.store(now);return enabled;
|
||||
}
|
||||
// Frame renders the native laser cursor in the default overlay sort group.
|
||||
// Raising a panel above that group can obscure the runtime cursor.
|
||||
// OpenVR owns and renders the laser cursor. Coordinates must remain in the
|
||||
@@ -320,26 +410,92 @@ static void show(View& v,bool shown){
|
||||
if(shown!=actual){auto e=shown?overlays->ShowOverlay(v.overlay):overlays->HideOverlay(v.overlay);if(e){std::cerr<<"Overlay visibility failed "<<v.key<<" "<<e<<"\n";return;}}
|
||||
if(shown!=v.shown){
|
||||
if(!shown){overlays->ClearOverlayCursorPositionOverride(v.overlay);if(v.key=="notifications"&&v.notification_click.active){cef_mouse_event_t outside{};outside.x=outside.y=-1;v.host->send_mouse_click_event(v.host,&outside,MBT_LEFT,1,1);v.notification_click.clear();}}
|
||||
if(v.key!="notifications")v.host->was_hidden(v.host,!shown);
|
||||
if(v.key!="notifications"&&v.key!="framely.system.radial"&&v.key!="framely.system.radial.progress")v.host->was_hidden(v.host,!shown);
|
||||
v.shown=shown;if(shown)v.host->invalidate(v.host,PET_VIEW);
|
||||
if(v.key=="menu"||v.key=="notifications")update_menu_modal();
|
||||
}
|
||||
}
|
||||
static void keyboard_state(bool open){if(keyboard_frame)execute(keyboard_frame,std::string("window.dispatchEvent(new CustomEvent('framely.keyboard',{detail:")+(open?"true":"false")+"}))");}
|
||||
static void close_keyboard(bool hide=true){keyboard_state(false);if(keyboard_open&&hide)overlays->HideKeyboard();keyboard_open=false;keyboard_close_pending=false;keyboard_toolbar=false;keyboard_chars=false;keyboard_done=false;keyboard_target=0;keyboard_menu.end(std::chrono::steady_clock::now());if(keyboard_view=="menu")update_menu_modal();if(keyboard_frame){keyboard_frame->base.release(&keyboard_frame->base);keyboard_frame=nullptr;}keyboard_view.clear();}
|
||||
static void external_keyboard_clear(bool notify_backend){
|
||||
const bool notify=notify_backend&&external_keyboard.active&&running;
|
||||
external_keyboard={};
|
||||
if(notify)std::thread([]{frame_keyboard_command("dashboardClose",json::object());}).detach();
|
||||
}
|
||||
static void close_external_keyboard(bool close_frame);
|
||||
static void external_keyboard_tick(std::chrono::steady_clock::time_point now){
|
||||
// Hiding SteamVR's keyboard emits a close event which must not tear down the
|
||||
// replacement. Bound that guard so a later real close is still observed.
|
||||
if(external_keyboard.active&&external_keyboard.hide_pending&&now>=external_keyboard.hide_pending_until)external_keyboard.hide_pending=false;
|
||||
// Closing the SteamVR dashboard also invalidates the X11 text target.
|
||||
if(external_keyboard.active&&!external_keyboard.hide_pending&&!overlays->IsDashboardVisible())close_external_keyboard(true);
|
||||
}
|
||||
static void close_external_keyboard(bool close_frame){
|
||||
const std::string view_key=external_keyboard.view_key;
|
||||
external_keyboard_clear(true);
|
||||
if(close_frame&&!view_key.empty()){
|
||||
auto found=views.find(view_key);
|
||||
if(found!=views.end()&&!found->second->closing)close_view(*found->second);
|
||||
}
|
||||
}
|
||||
static bool open_external_keyboard(const std::string& overlay_key,vr::VROverlayHandle_t target,uint64_t token){
|
||||
const json params={{"overlayKey",overlay_key},{"overlayHandle",target},{"token",token}};
|
||||
if(!frame_keyboard_command("dashboardOpen",params))return false;
|
||||
external_keyboard.active=true;
|
||||
external_keyboard.hide_pending=true;
|
||||
external_keyboard.target=target;
|
||||
external_keyboard.token=token;
|
||||
external_keyboard.overlay_key=overlay_key;
|
||||
external_keyboard.view_key=frame_keyboard_window_key;
|
||||
external_keyboard.hide_pending_until=std::chrono::steady_clock::now()+std::chrono::milliseconds(1500);
|
||||
overlays->HideKeyboard();
|
||||
try{
|
||||
service_call("window.open",json{{"plugin","tooru.frame-keyboard"},{"window","keyboard"}});
|
||||
}catch(const std::exception& error){
|
||||
std::cerr<<"Frame keyboard window failed: "<<error.what()<<"\n";
|
||||
external_keyboard_clear(true);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_overlay=0){
|
||||
// The runtime toolbar opens the keyboard without a host.keyboard request.
|
||||
if(e.eventType==vr::VREvent_KeyboardOpened_Global){
|
||||
auto target=e.data.keyboard.overlayHandle;
|
||||
if(keyboard_open&&target==keyboard_target)return true;
|
||||
View* owner=nullptr;for(auto&[key,v]:views)if(v->overlay==target&&!v->closing){owner=v.get();break;}
|
||||
if(!owner)return false;
|
||||
if(!owner){
|
||||
char key[1024]{};vr::EVROverlayError error{};overlays->GetOverlayKey(target,key,sizeof(key),&error);
|
||||
const std::string overlay_key=error?"":key;
|
||||
if(overlay_key.empty()||overlay_key.rfind("framely.",0)==0)return false;
|
||||
if(plugin_handles_external_keyboard())return true;
|
||||
if(external_keyboard.active&&external_keyboard.target==target){
|
||||
// A repeated Opened event during the native hide is part of the same
|
||||
// transition. After that grace period treat it as a second keyboard click.
|
||||
if(!external_keyboard.hide_pending)close_external_keyboard(true);
|
||||
overlays->HideKeyboard();
|
||||
return true;
|
||||
}
|
||||
if(!open_external_keyboard(overlay_key,target,e.data.keyboard.uUserValue))return false;
|
||||
return true;
|
||||
}
|
||||
if(owner->key==frame_keyboard_window_key){return true;}
|
||||
close_keyboard(false);owner->host->set_focus(owner->host,1);
|
||||
keyboard_frame=owner->browser->get_focused_frame(owner->browser);keyboard_view=owner->key;
|
||||
keyboard_target=target;keyboard_token=e.data.keyboard.uUserValue;keyboard_open=true;keyboard_toolbar=true;
|
||||
keyboard_menu.begin(owner->key=="menu");keyboard_state(true);return true;
|
||||
}
|
||||
if(e.eventType!=vr::VREvent_KeyboardCharInput&&e.eventType!=vr::VREvent_KeyboardDone&&e.eventType!=vr::VREvent_KeyboardClosed&&e.eventType!=vr::VREvent_KeyboardClosed_Global)return false;
|
||||
const bool external_target=external_keyboard.active&&e.data.keyboard.overlayHandle==external_keyboard.target;
|
||||
const bool external_close=e.eventType==vr::VREvent_KeyboardClosed||e.eventType==vr::VREvent_KeyboardClosed_Global;
|
||||
const bool external_input=!e.data.keyboard.uUserValue||!external_keyboard.token||e.data.keyboard.uUserValue==external_keyboard.token;
|
||||
if(external_target&&(external_close||external_input)){
|
||||
if(external_close){
|
||||
if(external_keyboard.hide_pending){external_keyboard.hide_pending=false;return true;}
|
||||
close_external_keyboard(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if(!keyboard_open)return true;
|
||||
if(e.data.keyboard.uUserValue&&e.data.keyboard.uUserValue!=keyboard_token)return true;
|
||||
if(e.data.keyboard.overlayHandle&&e.data.keyboard.overlayHandle!=keyboard_target)return true;
|
||||
@@ -349,8 +505,26 @@ static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_o
|
||||
}else {keyboard_done=keyboard_done||e.eventType==vr::VREvent_KeyboardDone;keyboard_close_pending=true;}
|
||||
return true;
|
||||
}
|
||||
static void close_view(View& v){if(v.closing)return;if(v.key.rfind("framely.cast.",0)==0){std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}if(v.key=="framely.framing"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_region_close=v.region_editor_id;}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu"||v.key=="framely.apk.permission")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
|
||||
static void close_view(View& v){if(v.closing)return;if(external_keyboard.active&&v.key==external_keyboard.view_key)external_keyboard_clear(true);if(v.key==frame_keyboard_window_key){std::lock_guard<std::mutex> lock(queue_mutex);queue.erase(std::remove_if(queue.begin(),queue.end(),[](const json& command){return command.value("kind",std::string{})=="window.open"&&command.value("plugin",std::string{})=="tooru.frame-keyboard";}),queue.end());if(running)std::thread([]{frame_keyboard_command("dashboardClose",json::object());}).detach();}if(v.key.rfind("framely.cast.",0)==0){std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.push_back(v.key.substr(13));}if(v.key=="framely.framing"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_region_close=v.region_editor_id;}if(v.key=="framely.screenshot"){std::lock_guard<std::mutex> lock(visibility_mutex);pending_screenshot_close=v.region_editor_id;}cancel_file_dialog(v);if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu"||v.key=="framely.apk.permission")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
|
||||
static void anchor_to_head(View& v,float width,float x=0,float y=0,float z=-1.35f){vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);if(!poses[0].bPoseIsValid)return;auto m=poses[0].mDeviceToAbsoluteTracking;for(int r=0;r<3;r++)m.m[r][3]+=m.m[r][0]*x+m.m[r][1]*y+m.m[r][2]*z;overlays->SetOverlayWidthInMeters(v.overlay,width);overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&m);}
|
||||
static void anchor_frame_keyboard(View& v){
|
||||
// A keyboard button must not move keyboard focus away from the edited app.
|
||||
// SteamVR owns the keyboard's spatial container and its native grab handle.
|
||||
vr::VROverlayHandle_t native=0;vr::HmdVector2_t scale{};float width=0;
|
||||
vr::HmdMatrix34_t pose{};
|
||||
if(!overlays->FindOverlay("valve.steam.gamepadui.keyboard",&native)&&overlays->IsOverlayVisible(native)&&
|
||||
!overlays->GetOverlayMouseScale(native,&scale)&&scale.v[0]>0&&
|
||||
!overlays->GetOverlayWidthInMeters(native,&width)&&width>0&&
|
||||
!overlays->GetTransformForOverlayCoordinates(native,vr::TrackingUniverseStanding,{{scale.v[0]*.5f,scale.v[1]*.5f}},&pose)){
|
||||
const float dy=(width*float(v.height)/v.width-width*scale.v[1]/scale.v[0])*.5f;
|
||||
for(int r=0;r<3;++r)pose.m[r][3]+=pose.m[r][1]*dy+pose.m[r][2]*.003f;
|
||||
overlays->SetOverlayWidthInMeters(v.overlay,width);
|
||||
overlays->SetOverlayTransformAbsolute(v.overlay,vr::TrackingUniverseStanding,&pose);
|
||||
float curvature=0,pitch=0;
|
||||
overlays->GetOverlayCurvature(native,&curvature);overlays->GetOverlayPreCurvePitch(native,&pitch);
|
||||
overlays->SetOverlayCurvature(v.overlay,curvature);overlays->SetOverlayPreCurvePitch(v.overlay,pitch);
|
||||
}else anchor_to_head(v,1.35f,0,-.38f,-1.2f);
|
||||
}
|
||||
static void anchor_permission(View& v){
|
||||
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
|
||||
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);
|
||||
@@ -380,15 +554,23 @@ static void anchor_notification(View& v){
|
||||
v.placement.transform=toast.transform;v.placement.width=toast.width;v.placement.valid=true;
|
||||
v.placement.curve(overlays,v.overlay,toast.width,toast.radius);
|
||||
}
|
||||
static View& create_view(const std::string& key,const std::string& title,const std::string& url,bool dock,int width,int height,const std::string& plugin="",float physical_width=0){
|
||||
static View& create_view(const std::string& key,const std::string& title,const std::string& url,bool dock,int width,int height,const std::string& plugin="",float physical_width=0,bool keyboard_surface=false){
|
||||
// Plugin windows always use Steam window controls; dock only controls the tab.
|
||||
const bool dashboard=dock||!plugin.empty()||key.rfind("framely.cast.",0)==0;
|
||||
keyboard_surface=keyboard_surface||key==frame_keyboard_window_key;
|
||||
const bool dashboard=!keyboard_surface&&(dock||!plugin.empty()||key.rfind("framely.cast.",0)==0);
|
||||
if(views.count(key)){auto& v=*views.at(key);if(v.dashboard&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="notifications"&&key!="launcher")show(v,true);return v;}
|
||||
// Use 1.5x for launcher artwork and 2x for scrolling panels. CEF readback,
|
||||
// texture upload and launcher mipmap generation scale with the pixel count.
|
||||
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dashboard;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications"||key=="framely.framing")?2:1;
|
||||
if(views.size()>=34)throw std::runtime_error("Window limit exceeded");auto v=std::make_unique<View>();v->key=key;v->plugin=plugin;v->title=title;v->dashboard=dashboard;v->width=width;v->height=height;v->render_scale=key=="launcher"?1.5f:(key=="framely.manager"||key=="menu"||key=="notifications"||key=="framely.framing"||key=="framely.screenshot"||key=="framely.system.radial"||key=="framely.system.radial.progress")?2:1;
|
||||
auto error=dashboard?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
|
||||
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,key=="framely.apk.permission");overlays->SetOverlaySortOrder(v->overlay,key=="framely.apk.permission"?100:0);if(key=="framely.framing")recording_region_foreground(overlays,v->overlay);if(key=="framely.apk.permission")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true);
|
||||
const bool radial=key=="framely.system.radial"||key=="framely.system.radial.progress";
|
||||
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"||radial?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,!radial);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,key==frame_keyboard_window_key||key=="framely.apk.permission"||radial);overlays->SetOverlaySortOrder(v->overlay,key==frame_keyboard_window_key?20:key=="framely.apk.permission"?100:radial?200:0);if(key=="framely.framing"||key=="framely.screenshot")recording_region_foreground(overlays,v->overlay);if(key=="framely.apk.permission")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_WantsModalBehavior,true);
|
||||
if(key=="framely.system.radial"){
|
||||
vr::VROverlayIntersectionMaskPrimitive_t mask{};
|
||||
mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Circle;
|
||||
mask.m_Primitive.m_Circle={width*.5f,height*.5f,260.f};
|
||||
if(auto e=overlays->SetOverlayIntersectionMask(v->overlay,&mask,1))std::cerr<<"Radial input mask failed "<<e<<"\n";
|
||||
}
|
||||
if(key=="framely.apk.permission"){
|
||||
vr::VROverlayIntersectionMaskPrimitive_t mask{};
|
||||
mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;
|
||||
@@ -398,17 +580,18 @@ static View& create_view(const std::string& key,const std::string& title,const s
|
||||
|
||||
// Keep the runtime's full dashboard controls, including its keyboard and close buttons.
|
||||
if(dashboard){
|
||||
overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_NoDashboardTab,!dock||key=="launcher");
|
||||
overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_NoDashboardTab,keyboard_surface||!dock||key=="launcher");
|
||||
auto result=overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MinimalControlBar,key=="launcher");
|
||||
if(result)std::cerr<<"Dashboard controls unavailable "<<key<<" "<<result<<"\n";
|
||||
for(auto flag:{vr::VROverlayFlags_EnableControlBarKeyboard,vr::VROverlayFlags_EnableControlBarClose}){
|
||||
result=overlays->SetOverlayFlag(v->overlay,flag,true);
|
||||
result=overlays->SetOverlayFlag(v->overlay,flag,!keyboard_surface||flag==vr::VROverlayFlags_EnableControlBarClose);
|
||||
if(result)std::cerr<<"Dashboard control unavailable "<<key<<" "<<result<<"\n";
|
||||
}
|
||||
}
|
||||
if(dashboard){auto pixels=icon(false,true);overlays->SetOverlayRaw(v->thumbnail,pixels.data(),128,128,4);overlays->SetOverlayWidthInMeters(v->overlay,physical_width>0?physical_width:(key=="framely.manager"?3.2f:key=="launcher"?3.6f:1.2f));}else if(key!="menu"&&key!="notifications"&&key!="launcher"){anchor_to_head(*v,physical_width>0?physical_width:1.0f);}
|
||||
if(keyboard_surface)anchor_frame_keyboard(*v);
|
||||
else if(dashboard){auto pixels=icon(false,true);overlays->SetOverlayRaw(v->thumbnail,pixels.data(),128,128,4);overlays->SetOverlayWidthInMeters(v->overlay,physical_width>0?physical_width:(key=="framely.manager"?3.2f:key=="launcher"?3.6f:1.2f));}else if(key!="menu"&&key!="notifications"&&key!="launcher"&&!radial){anchor_to_head(*v,physical_width>0?physical_width:1.0f);}
|
||||
cef_window_info_t info{};info.size=sizeof(info);info.windowless_rendering_enabled=1;info.runtime_style=CEF_RUNTIME_STYLE_ALLOY;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=browser_frame_rate();bs.background_color=0;std::cout<<"Browser frame rate "<<key<<" "<<bs.windowless_frame_rate<<"\n";cef_string_t uri{};str(uri,url);auto* browser=cef_browser_host_create_browser_sync(&info,&client.api,&uri,&bs,nullptr,nullptr);cef_string_utf16_clear(&uri);
|
||||
if(!browser){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);throw std::runtime_error("Browser creation failed");}v->browser=browser;v->browser_id=browser->get_identifier(browser);v->host=browser->get_host(browser);glGenTextures(2,v->textures);auto* ptr=v.get();views.emplace(key,std::move(v));ptr->host->notify_screen_info_changed(ptr->host);ptr->host->was_resized(ptr->host);ptr->host->was_hidden(ptr->host,key!="notifications");if(ptr->dashboard&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications"&&key!="launcher")show(*ptr,true);return *ptr;
|
||||
if(!browser){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);throw std::runtime_error("Browser creation failed");}v->browser=browser;v->browser_id=browser->get_identifier(browser);v->host=browser->get_host(browser);glGenTextures(2,v->textures);auto* ptr=v.get();views.emplace(key,std::move(v));ptr->host->notify_screen_info_changed(ptr->host);ptr->host->was_resized(ptr->host);ptr->host->was_hidden(ptr->host,key!="notifications"&&!radial);if(ptr->dashboard&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications"&&key!="launcher"&&!radial)show(*ptr,true);return *ptr;
|
||||
}
|
||||
static void plugin_thumbnail(View& view,const std::string& encoded){
|
||||
if(!view.thumbnail||encoded.empty()||encoded.size()>1400000)return;
|
||||
@@ -518,8 +701,8 @@ static void setup_entry_actions(){
|
||||
}
|
||||
static void page_scroll(float x,float y,bool active,vr::TrackedDeviceIndex_t device,std::chrono::steady_clock::time_point now){
|
||||
View* target=nullptr;
|
||||
if(!keyboard_open&&!launcher_open)for(auto&[key,v]:views){
|
||||
if(key=="launcher"||!v->pointer_valid||!capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay))||!overlays->IsHoverTargetOverlay(v->overlay))continue;
|
||||
if(!launcher_open)for(auto&[key,v]:views){
|
||||
if(key=="launcher"||(keyboard_open&&keyboard_view!=key)||!v->pointer_valid||!capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay))||!overlays->IsHoverTargetOverlay(v->overlay))continue;
|
||||
if(!target||(v->pointer_device==device&&target->pointer_device!=device)||(v->pointer_device==target->pointer_device&&v->pointer_at>target->pointer_at))target=v.get();
|
||||
}
|
||||
if(!target){page_scroll_stick.clear();page_scroll_target=0;return;}
|
||||
@@ -579,6 +762,203 @@ static void launcher_gaze(vr::IVRInput* input,bool allowed,const bool (&connecte
|
||||
}
|
||||
gaze_active=true;
|
||||
}
|
||||
static const char* radial_direction(SystemRadialDirection direction){
|
||||
switch(direction){
|
||||
case SystemRadialDirection::Up:return "up";
|
||||
case SystemRadialDirection::UpRight:return "upRight";
|
||||
case SystemRadialDirection::Right:return "right";
|
||||
case SystemRadialDirection::DownRight:return "downRight";
|
||||
case SystemRadialDirection::Down:return "down";
|
||||
case SystemRadialDirection::DownLeft:return "downLeft";
|
||||
case SystemRadialDirection::Left:return "left";
|
||||
case SystemRadialDirection::UpLeft:return "upLeft";
|
||||
default:return "center";
|
||||
}
|
||||
}
|
||||
static void radial_screenshot(bool active,bool capture){
|
||||
if(active==screenshot_radial_held)return;
|
||||
screenshot_radial_held=active;
|
||||
if(!active){
|
||||
screenshot_region_overlay.destroy();
|
||||
if(auto preview=views.find("framely.screenshot.preview");preview!=views.end())close_view(*preview->second);
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(visibility_mutex);
|
||||
if(active)pending_screenshot_hotkey="radialPress";
|
||||
else if(pending_screenshot_hotkey=="radialPress")pending_screenshot_hotkey=capture?"radialCapture":"";
|
||||
else pending_screenshot_hotkey=capture?"release":"cancel";
|
||||
}
|
||||
static void system_radial_laser(vr::VROverlayHandle_t overlay,bool enabled){
|
||||
auto method=enabled?vr::VROverlayInputMethod_Mouse:vr::VROverlayInputMethod_None;
|
||||
if(auto e=overlays->SetOverlayInputMethod(overlay,method))std::cerr<<"Radial laser input failed "<<e<<"\n";
|
||||
overlays->SetOverlayFlag(overlay,vr::VROverlayFlags_HideLaserIntersection,!enabled);
|
||||
overlays->SetOverlayFlag(overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,enabled);
|
||||
}
|
||||
static std::optional<SystemRadialDirection> system_radial_stick(const char* model,vr::VRInputValueHandle_t hand){
|
||||
vr::RenderModel_ControllerMode_State_t mode{};
|
||||
vr::RenderModel_ComponentState_t rest{},full_x{},full_y{},current{};
|
||||
vr::VRControllerState_t neutral{},sx{},sy{};sx.rAxis[0].x=1;sy.rAxis[0].y=1;
|
||||
auto* models=vr::VRRenderModels();float x=0,y=0;
|
||||
if(!models->GetComponentStateForDevicePath(model,"thumbstick",hand,&mode,¤t)||
|
||||
!models->GetComponentState(model,"thumbstick",&neutral,&mode,&rest)||
|
||||
!models->GetComponentState(model,"thumbstick",&sx,&mode,&full_x)||
|
||||
!models->GetComponentState(model,"thumbstick",&sy,&mode,&full_y)||
|
||||
!launcher_stick_axes(rest,full_x,full_y,current,x,y)||x*x+y*y<.35f*.35f)return std::nullopt;
|
||||
return SystemRadial::select(x,y);
|
||||
}
|
||||
static void system_radial_tick(){
|
||||
if(!system_radial_available||!views.count("framely.system.radial")||!views.count("framely.system.radial.progress"))return;
|
||||
auto& progress_view=*views.at("framely.system.radial.progress");
|
||||
if(!system_radial_enabled.load()){
|
||||
auto& view=*views.at("framely.system.radial");
|
||||
if(system_radial.active||system_radial_progress_visible||system_radial.open){
|
||||
show(view,false);show(progress_view,false);system_radial_laser(view.overlay,false);overlays->ClearOverlayTexture(view.overlay);
|
||||
system_radial={};system_radial_progress_visible=false;
|
||||
if(screenshot_radial_held)radial_screenshot(false,false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
auto now=SystemRadial::Clock::now();
|
||||
vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount]{};
|
||||
vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,poses,vr::k_unMaxTrackedDeviceCount);
|
||||
int first=system_radial.active?system_radial.hand:0,last=system_radial.active?first:1;
|
||||
for(int hand=first;hand<=last;hand++){
|
||||
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
|
||||
if(device!=system_radial_pose_device[hand]){system_radial_pose_device[hand]=device;system_radial_pose_at[hand]={};}
|
||||
if(device!=vr::k_unTrackedDeviceIndexInvalid&&poses[device].bPoseIsValid){system_radial_pose[hand]=poses[device].mDeviceToAbsoluteTracking;system_radial_pose_at[hand]=now;}
|
||||
const bool pose_recent=system_radial_pose_at[hand].time_since_epoch().count()&&now-system_radial_pose_at[hand]<std::chrono::milliseconds(750);
|
||||
bool valid=device!=vr::k_unTrackedDeviceIndexInvalid&&poses[0].bPoseIsValid&&hands[hand];
|
||||
bool down=false;
|
||||
char model[256]{};
|
||||
if(valid){
|
||||
vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));
|
||||
vr::RenderModel_ControllerMode_State_t mode{};
|
||||
vr::RenderModel_ComponentState_t state{};
|
||||
bool component=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_system",hands[hand],&mode,&state);
|
||||
down=component&&(state.uProperties&vr::VRComponentProperty_IsPressed);
|
||||
// Another overlay (notably the Frame keyboard) can own the active input
|
||||
// action while its panel is visible. The legacy controller state still
|
||||
// reports the physical button, so use it if the action-path state is idle.
|
||||
if(!down){
|
||||
vr::VRControllerState_t controller{};
|
||||
if(vr_system->GetControllerState(device,&controller,sizeof(controller))){
|
||||
vr::RenderModel_ComponentState_t raw{};
|
||||
down=bool(controller.ulButtonPressed&vr::ButtonMaskFromId(vr::k_EButton_System));
|
||||
if(!down&&vr::VRRenderModels()->GetComponentState(model,"button_system",&controller,&mode,&raw))down=bool(raw.uProperties&vr::VRComponentProperty_IsPressed);
|
||||
}
|
||||
}
|
||||
valid=component||down;
|
||||
}
|
||||
float right=0,up=0;
|
||||
if(valid&&system_radial.active&&pose_recent){
|
||||
const auto& point=system_radial_pose[hand];
|
||||
if(!system_radial_origin_valid){for(int i=0;i<3;i++)system_radial_origin.v[i]=point.m[i][3];system_radial_origin_valid=true;}
|
||||
for(int i=0;i<3;i++){
|
||||
float delta=point.m[i][3]-system_radial_origin.v[i];
|
||||
right+=delta*system_radial_right.v[i];
|
||||
up+=delta*system_radial_up.v[i];
|
||||
}
|
||||
}
|
||||
auto& view=*views.at("framely.system.radial");
|
||||
std::optional<SystemRadialDirection> hover;
|
||||
if(system_radial.open&&view.pointer_valid&&overlays->IsHoverTargetOverlay(view.overlay))hover=SystemRadial::hit(view.pointer.x,view.pointer.y);
|
||||
if(system_radial.open&&!pose_recent&&!hover&&valid)hover=system_radial_stick(model,hands[hand]);
|
||||
if(system_radial.open&&!pose_recent&&!hover)hover=system_radial.direction;
|
||||
auto event=system_radial.update(hand,valid,down,right,up,now,hover);
|
||||
if(event==SystemRadialEvent::Idle)continue;
|
||||
if(event==SystemRadialEvent::Begin){
|
||||
show(view,false);
|
||||
show(progress_view,false);
|
||||
system_radial_laser(view.overlay,false);
|
||||
view.pointer_valid=false;
|
||||
system_radial_preview_prepared=false;
|
||||
system_radial_progress_visible=false;
|
||||
const auto& point=system_radial_pose[hand];
|
||||
const auto& head=poses[0].mDeviceToAbsoluteTracking;
|
||||
system_radial_origin_valid=pose_recent;
|
||||
for(int i=0;i<3;i++){
|
||||
if(pose_recent)system_radial_origin.v[i]=point.m[i][3];
|
||||
system_radial_right.v[i]=head.m[i][0];
|
||||
system_radial_up.v[i]=head.m[i][1];
|
||||
}
|
||||
vr::HmdMatrix34_t transform{{{1,0,0,0},{0,1,0,0},{0,0,1,-.38f}}};
|
||||
overlays->SetOverlayWidthInMeters(progress_view.overlay,.68f);
|
||||
overlays->SetOverlayTransformTrackedDeviceRelative(progress_view.overlay,vr::k_unTrackedDeviceIndex_Hmd,&transform);
|
||||
overlays->SetOverlayWidthInMeters(view.overlay,.68f);
|
||||
overlays->ClearOverlayTexture(view.overlay);
|
||||
system_radial_sent={};
|
||||
}
|
||||
if(event==SystemRadialEvent::Open){
|
||||
show(progress_view,false);
|
||||
system_radial_progress_visible=false;
|
||||
if(pose_recent){
|
||||
const auto& point=system_radial_pose[hand];
|
||||
const auto& head=poses[0].mDeviceToAbsoluteTracking;
|
||||
auto transform=head;
|
||||
for(int i=0;i<3;i++){
|
||||
if(!system_radial_origin_valid)system_radial_origin.v[i]=point.m[i][3];
|
||||
system_radial_right.v[i]=head.m[i][0];
|
||||
system_radial_up.v[i]=head.m[i][1];
|
||||
transform.m[i][3]=point.m[i][3]-head.m[i][2]*.22f;
|
||||
}
|
||||
system_radial_origin_valid=true;
|
||||
overlays->SetOverlayTransformAbsolute(view.overlay,vr::TrackingUniverseStanding,&transform);
|
||||
}else{
|
||||
vr::HmdMatrix34_t transform{{{1,0,0,0},{0,1,0,0},{0,0,1,-.55f}}};
|
||||
overlays->SetOverlayTransformTrackedDeviceRelative(view.overlay,vr::k_unTrackedDeviceIndex_Hmd,&transform);
|
||||
}
|
||||
show(view,true);
|
||||
system_radial_laser(view.overlay,true);
|
||||
}
|
||||
if(event==SystemRadialEvent::Change){
|
||||
if(system_radial.direction==SystemRadialDirection::Right)radial_screenshot(true,false);
|
||||
else if(screenshot_radial_held)radial_screenshot(false,false);
|
||||
}
|
||||
if(event==SystemRadialEvent::Cancel||event==SystemRadialEvent::Commit){
|
||||
const auto direction=system_radial.direction;
|
||||
if(event==SystemRadialEvent::Cancel||direction!=SystemRadialDirection::Right){
|
||||
std::lock_guard<std::mutex> lock(visibility_mutex);
|
||||
if(pending_screenshot_hotkey.empty()||pending_screenshot_hotkey=="radialPrepare")pending_screenshot_hotkey="radialCancel";
|
||||
}
|
||||
show(view,false);
|
||||
show(progress_view,false);
|
||||
system_radial_laser(view.overlay,false);
|
||||
overlays->ClearOverlayTexture(view.overlay);
|
||||
system_radial_progress_visible=false;
|
||||
if(screenshot_radial_held)radial_screenshot(false,event==SystemRadialEvent::Commit&&direction==SystemRadialDirection::Right);
|
||||
if(event==SystemRadialEvent::Commit){
|
||||
if(direction==SystemRadialDirection::Center){
|
||||
if(auto* chaperone=vr::VRChaperone())chaperone->ResetZeroPose(vr::TrackingUniverseStanding);
|
||||
}else if(direction==SystemRadialDirection::Up)toggle_entry(true);
|
||||
else if(direction==SystemRadialDirection::Down){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back({{"kind","manager.open"}});}
|
||||
else if(direction==SystemRadialDirection::Left){std::lock_guard<std::mutex> lock(visibility_mutex);pending_system_action="record:"+std::to_string(++system_action_sequence);}
|
||||
else if(direction==SystemRadialDirection::Right){
|
||||
// A fast move can reach the sector between two host polls.
|
||||
std::lock_guard<std::mutex> lock(visibility_mutex);
|
||||
if(pending_screenshot_hotkey.empty())pending_screenshot_hotkey="radialCapture";
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if(system_radial.active&&!system_radial_preview_prepared&&system_radial.progress(now)>=.25f){
|
||||
std::lock_guard<std::mutex> lock(visibility_mutex);
|
||||
if(pending_screenshot_hotkey.empty()){pending_screenshot_hotkey="radialPrepare";system_radial_preview_prepared=true;}
|
||||
}
|
||||
if(system_radial.active&&!system_radial.open&&!system_radial_progress_visible&&system_radial.progress(now)>=.4f){
|
||||
show(progress_view,true);
|
||||
system_radial_progress_visible=true;
|
||||
}
|
||||
if(system_radial.active&&now-system_radial_sent>=std::chrono::milliseconds(33)){
|
||||
system_radial_sent=now;
|
||||
bool recording=false;{std::lock_guard<std::mutex> lock(queue_mutex);recording=recording_state.value("active",false);}
|
||||
json detail={{"progress",std::clamp((system_radial.progress(now)-.4f)/.6f,0.f,1.f)},{"open",system_radial.open},{"direction",radial_direction(system_radial.direction)},{"recording",recording}};
|
||||
for(auto* display:{views.at("framely.system.radial.progress").get(),views.at("framely.system.radial").get()}){
|
||||
auto* frame=display->browser->get_main_frame(display->browser);
|
||||
execute(frame,"window.dispatchEvent(new CustomEvent('framely.systemMenu',{detail:"+detail.dump()+"}))");
|
||||
frame->base.release(&frame->base);
|
||||
}
|
||||
}
|
||||
}
|
||||
static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
for(int hand=0;hand<2;hand++){framing_trigger_known[hand]=false;framing_trigger_down[hand]=false;}
|
||||
const bool pointer_available=available||recording_indicator.active();
|
||||
@@ -689,7 +1069,20 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
if(gaze_allowed&&gaze_active)launcher_outside_at={};
|
||||
if(launcher_open&&launcher_outside_at.time_since_epoch().count()&&now>=launcher_outside_at){launcher_outside_at={};if(!keyboard_open&&!launcher_dock_interaction())launcher_back();}
|
||||
}
|
||||
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="cast.framing.open"){auto key="framely.framing";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto geometry=c.at("geometry");RecordingRegionPlane plane;auto view=geometry.at("view");if(!recording_region_plane(view,geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Recording frame",origin+"/cast/framing",false,1600,1600);v.region_editor_id=geometry.at("id");v.region_geometry=geometry;update_framing_placement(v);menu_open=false;hide_launcher(false);}else if(kind=="cast.framing.reference"){auto found=views.find("framely.framing");if(found!=views.end()&&!found->second->closing&&found->second->region_editor_id==c.value("id",std::string{})){double cx=c.at("centerTanX"),cy=c.at("centerTanY");if(std::isfinite(cx)&&std::isfinite(cy)&&std::abs(cx)<=1.733&&std::abs(cy)<=1.733){found->second->region_geometry["view"]["centerTanX"]=cx;found->second->region_geometry["view"]["centerTanY"]=cy;}}}else if(kind=="cast.framing.close"){if(views.count("framely.framing")&&views.at("framely.framing")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.framing"));}else if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu"Line truncated
|
||||
static void commands(){std::deque<json> commands;{std::lock_guard<std::mutex> lock(queue_mutex);commands.swap(queue);}for(const auto& c:commands){try{auto kind=c.value("kind","");if(kind=="cast.screenshot.open"){
|
||||
auto key="framely.screenshot";
|
||||
if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}
|
||||
auto id=c.at("id").get<std::string>();bool spatial=c.value("spatial",false);
|
||||
if(spatial){auto geometry=c.at("geometry");RecordingRegionPlane plane;if(!recording_region_plane(geometry.at("view"),geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Screenshot frame",origin+"/cast/screenshot",false,1600,1600);v.region_editor_id=id;v.region_geometry=geometry;update_framing_placement(v);}
|
||||
else{auto& v=create_view(key,"Screenshot frame",origin+"/cast/screenshot",false,1120,780,"",1.25f);v.region_editor_id=id;}
|
||||
menu_open=false;hide_launcher(false);
|
||||
}else if(kind=="cast.screenshot.close"){
|
||||
if(views.count("framely.screenshot")&&views.at("framely.screenshot")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.screenshot"));
|
||||
}else if(kind=="cast.screenshot.shortcutPreview.open"){
|
||||
if(screenshot_radial_held){auto key="framely.screenshot.preview";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}double aspect=c.value("aspect",1.0);if(!std::isfinite(aspect)||aspect<.3||aspect>4)aspect=1.0;int width=960,height=std::clamp(int(960/aspect),240,960);auto& v=create_view(key,"Screenshot preview",origin+"/cast/screenshot/shortcut",false,width,height,"",.95f);overlays->SetOverlayInputMethod(v.overlay,vr::VROverlayInputMethod_None);}
|
||||
}else if(kind=="cast.screenshot.shortcutPreview.close"){
|
||||
if(auto preview=views.find("framely.screenshot.preview");preview!=views.end())close_view(*preview->second);
|
||||
}else if(kind=="cast.framing.open"){auto key="framely.framing";if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto geometry=c.at("geometry");RecordingRegionPlane plane;auto view=geometry.at("view");if(!recording_region_plane(view,geometry.at("referenceTan"),0,0,1,plane))continue;auto& v=create_view(key,"Recording frame",origin+"/cast/framing",false,1600,1600);v.region_editor_id=geometry.at("id");v.region_geometry=geometry;update_framing_placement(v);menu_open=false;hide_launcher(false);}else if(kind=="cast.framing.reference"){auto found=views.find("framely.framing");if(found!=views.end()&&!found->second->closing&&found->second->region_editor_id==c.value("id",std::string{})){double cx=c.at("centerTanX"),cy=c.at("centerTanY");if(std::isfinite(cx)&&std::isfinite(cy)&&std::abs(cx)<=1.733&&std::abs(cy)<=1.733){found->second->region_geometry["view"]["centerTanX"]=cx;found->second->region_geometry["view"]["centerTanY"]=cy;}}}else if(kind=="cast.framing.close"){if(views.count("framely.framing")&&views.at("framely.framing")->region_editor_id==c.value("id",std::string{}))close_view(*views.at("framely.framing"));}else if(kind=="feedback.settings"){ui_haptics=c.value("haptics",true);ui_sounds=c.value("sounds",false);if(!ui_sounds)ui_audio.silence();}else if(kind=="file.dialog.complete"){for(auto& [key,view]:views){auto& v=*view;if(!v.file_dialog||v.file_dialog_id!=c.value("id",std::string{}))continue;auto paths=c.value("paths",json::array());if(paths.empty()){cancel_file_dialog(v);break;}if((!v.file_multiple&&paths.size()!=1)||paths.size()>64)break;auto* callback=v.file_dialog;v.file_dialog=nullptr;v.file_dialog_id.clear();auto list=cef_string_list_alloc();for(const auto& path:paths){cef_string_t value{};str(value,path.get<std::string>());cef_string_list_append(list,&value);cef_string_utf16_clear(&value);}callback->cont(callback,list);cef_string_list_free(list);callback->base.release(&callback->base);break;}}else if(kind=="gaze.correction"){gaze_correction=c.value("calibration",json(nullptr));}else if(kind=="gaze.target"){gaze_target_id=c.value("id",std::string{});gaze_target_u=c.value("x",.5f);gaze_target_v=1-c.value("y",.5f);gaze_preview=c.value("preview",json(nullptr));gaze_target_cached=false;}else if(kind=="launcher.calibrate"){gaze_target_id.clear();gaze_target_cached=false;gaze_preview=nullptr;launcher_calibrating=c.value("active",true);if(launcher_calibrating&&!launcher_open){if(!views.count("launcher"))create_view("launcher","Framely launcher",origin+"/launcher#calibrate",true,1440,800);toggle_entry(true);}if(views.count("launcher")){auto& v=*views.at("launcher");auto* frame=v.browser->get_main_frame(v.browser);execute(frame,std::string("location.hash=")+(launcher_calibrating?"'#calibrate'":"''")+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}}else if(kind=="launcher.close"){if(c.contains("launcherSession")&&!c["launcherSession"].is_null()&&c["launcherSession"]!=launcher_session)continue;bool handoff=c.value("handoff",false);hide_launcher(!handoff,handoff);}else if(kind=="menu.open"){hide_launcher(false);auto& menu=create_view("menu","Framely",origin+"/",false,600,840);navigate(menu,"#plugin:"+c.at("plugin").get<std::string>());menu_open=true;launcher_open=false;menu_button.opened(std::chrono::steady_clock::now());}else if(kind=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing){auto& view=*views.at(key);bool activation=c.value("feedback","hover")=="activate";if(activation&&key=="launcher"&&gaze_active&&launcher_gaze_allowed()){auto now=std::chrono::steady_clock::now();if(now-view.activation_at>=std::chrono::milliseconds(80)){view.activation_at=now;if(ui_sounds)ui_audio.play(true);}}else hover_haptic(view.overlay,activation?view.activation_at:view.haptic_at,activation);}}else if(kind=="manager.open"){hide_launcher(false);if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto page=c.value("page",std::string{});auto& manager=create_view("framely.manager","Framely",origin+"/manager"+((manager_navigation_page(page)||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(manager_navigation_page(page)){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"))Line truncated
|
||||
}
|
||||
static void release_framing_pointer(View& v){
|
||||
if(!v.framing_left_down)return;v.framing_left_down=false;
|
||||
@@ -697,7 +1090,7 @@ static void release_framing_pointer(View& v){
|
||||
auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.pointerRelease'))");frame->base.release(&frame->base);
|
||||
}
|
||||
static void reconcile_framing_pointer(View& v){
|
||||
if(v.key!="framely.framing"||!v.framing_left_down)return;
|
||||
if((v.key!="framely.framing"&&!(v.key=="framely.screenshot"&&v.region_geometry.contains("view")))||!v.framing_left_down)return;
|
||||
bool known=true,held=false,found=false,owner=false;
|
||||
for(int hand=0;hand<2;hand++){
|
||||
auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);
|
||||
@@ -710,7 +1103,7 @@ static void reconcile_framing_pointer(View& v){
|
||||
}
|
||||
if((owner||found)&&known&&!held)release_framing_pointer(v);
|
||||
}
|
||||
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;if(menu_open&&v.key!="menu"&&e.eventType==vr::VREvent_MouseButtonDown&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now()))menu_open=false;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="framely.framing")release_framing_pointer(v);if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="framely.framing")mouse.modifiers=v.framing_left_down?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if(v.key=="framely.framing"&&button==MBT_LEFT)v.framing_left_down=e.eventType==vr::VREvent_MouseButtonDown;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(v.key=="framely.framing")release_framing_pointer(v);if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key.rfind("framely.cast.",0)!=0&&v.key!="framely.apk.permission"&&v.key!="framely.framing")close_view(v);break;default:break;}}
|
||||
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing)return;if(v.key=="framely.system.radial"&&e.eventType==vr::VREvent_FocusLeave)v.pointer_valid=false;if((v.key=="notifications"||v.key=="launcher"||v.key=="menu")&&!v.shown&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_open&&e.eventType!=vr::VREvent_OverlayClosed)return;if(v.key=="launcher"&&!launcher_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;if(menu_open&&v.key!="menu"&&e.eventType==vr::VREvent_MouseButtonDown&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now()))menu_open=false;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!gaze_active&&!launcher_dock_interaction()&&!overlays->IsHoverTargetOverlay(v.overlay))launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))mouse.modifiers=v.framing_left_down?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{if(v.key!=frame_keyboard_window_key)v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view")))&&button==MBT_LEFT)v.framing_left_down=e.eventType==vr::VREvent_MouseButtonDown;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||(keyboard_open&&keyboard_view!=v.key)||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if((v.key=="framely.framing"||(v.key=="framely.screenshot"&&v.region_geometry.contains("view"))))release_framing_pointer(v);if(!keyboard_open&&!v.dashboard&&v.key!=frame_keyboard_window_key&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher"&&v.key!="framely.system.radial"&&v.key.rfind("framely.cast.",0)!=0&&v.key!="framely.apk.permission"&&v.key!="framely.framing"&&v.key!="framely.screenshot")close_view(v);break;default:break;}}
|
||||
|
||||
int main(int argc,char** argv){
|
||||
std::cout.setf(std::ios::unitbuf);std::string initial;int port=0;for(int i=1;i+1<argc;i++){std::string s=argv[i];if(s=="--url")initial=argv[++i];else if(s=="--port")port=std::stoi(argv[++i]);else if(s=="--runtime")runtime=argv[++i];}
|
||||
@@ -724,7 +1117,7 @@ int main(int argc,char** argv){
|
||||
vr::VR_Shutdown();
|
||||
// Frame's compositor requires the Overlay application type for visible UI.
|
||||
// The session agent and service gate this renderer on all native services.
|
||||
vr_system=vr::VR_Init(&vr_error,vr::VRApplication_Overlay);if(vr_error){std::cerr<<"SteamVR renderer unavailable "<<vr_error<<"\n";return 3;}overlays=vr::VROverlay();setup_entry_actions();
|
||||
vr_system=vr::VR_Init(&vr_error,vr::VRApplication_Overlay);if(vr_error){std::cerr<<"SteamVR renderer unavailable "<<vr_error<<"\n";return 3;}overlays=vr::VROverlay();setup_entry_actions();system_radial_available=std::getenv("FRAMELY_STEAM_MENU_ENABLED")&&std::strcmp(std::getenv("FRAMELY_STEAM_MENU_ENABLED"),"1")==0;
|
||||
xdisplay=XOpenDisplay(nullptr);if(!xdisplay){vr::VR_Shutdown();return 4;}int n=0;int attrs[]={GLX_DRAWABLE_TYPE,GLX_PBUFFER_BIT,GLX_RENDER_TYPE,GLX_RGBA_BIT,None};auto* configs=glXChooseFBConfig(xdisplay,DefaultScreen(xdisplay),attrs,&n);if(!configs||!n){vr::VR_Shutdown();return 4;}int pba[]={GLX_PBUFFER_WIDTH,16,GLX_PBUFFER_HEIGHT,16,None};glsurface=glXCreatePbuffer(xdisplay,configs[0],pba);glcontext=glXCreateNewContext(xdisplay,configs[0],GLX_RGBA_TYPE,nullptr,True);XFree(configs);if(!glcontext||!glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext)){vr::VR_Shutdown();return 4;}
|
||||
cef_settings_t settings{};settings.size=sizeof(settings);settings.windowless_rendering_enabled=1;std::string locale;for(const char* key:{"LC_ALL","LC_MESSAGES","LANG"}){const char* value=std::getenv(key);if(value&&*value){locale=value;break;}}auto suffix=locale.find_first_of(".@");if(suffix!=std::string::npos)locale.erase(suffix);std::replace(locale.begin(),locale.end(),'_','-');if(locale=="C"||locale=="POSIX"||locale.empty())locale="en-US";str(settings.accept_language_list,locale+",en-US,en");str(settings.root_cache_path,runtime+"/cef-cache");str(settings.log_file,runtime+"/cef.log");if(!cef_initialize(&args,&settings,nullptr,nullptr)){vr::VR_Shutdown();return 5;}
|
||||
render_handler.api.get_view_rect=[](cef_render_handler_t*,cef_browser_t* b,cef_rect_t* r){auto* v=find_browser(b);*r={0,0,v?v->width:600,v?v->height:840};};
|
||||
@@ -748,11 +1141,12 @@ int main(int argc,char** argv){
|
||||
client.api.get_dialog_handler=[](cef_client_t*){return dialog_handler.acquire();};client.api.get_render_handler=[](cef_client_t*){return render_handler.acquire();};client.api.get_life_span_handler=[](cef_client_t*){return life_handler.acquire();};client.api.get_display_handler=[](cef_client_t*){return display_handler.acquire();};client.api.get_request_handler=[](cef_client_t*){return request_handler.acquire();};
|
||||
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);DockIconTexture icon_texture;icon_texture.init();if(!icon_texture.submit(button,pixels)){icon_texture.destroy();overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayInputMethod(button,vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{dock_canvas_width,dock_canvas_height}};overlays->SetOverlayMouseScale(button,&scale);vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;mask.m_Primitive.m_Rectangle={dock_icon_left,0,128,128};if(overlays->SetOverlayIntersectionMask(button,&mask,1)){std::cerr<<"Dock input mask unavailable\n";overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayFlag(button,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(button,0);overlays->SetOverlayFlag(button,vr::VROverlayFlags_HideLaserIntersection,false);
|
||||
try{create_view("menu","Framely",initial,false,600,840);}catch(const std::exception& e){std::cerr<<e.what()<<"\n";running=0;}
|
||||
if(system_radial_available)try{create_view("framely.system.radial.progress","Framely radial progress",initial+"?view=radial-progress",false,720,720);create_view("framely.system.radial","Framely radial menu",initial+"?view=radial",false,720,720);}catch(const std::exception& e){std::cerr<<"Radial menu unavailable: "<<e.what()<<"\n";system_radial_available=false;}
|
||||
const char* steam_root=std::getenv("FRAMELY_STEAM_ROOT");if(!ui_audio.prepare(steam_root?steam_root:""))std::cerr<<"Steam UI sounds unavailable\n";
|
||||
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);recording_state=data.value("recording",json::object());recording_region_state=data.value("recordingRegion",json::object());framing_editor_state=data.value("framingEditor",json(nullptr));if(!recording_state.is_object())recording_state=json::object();auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",false)}});last_feedback=feedback;}auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));const auto launcher=data.value("launcher",json::object());launcher_prioritize_ui.store(launcher.value("prioritizeUi",true));system_radial_enabled.store(launcher.value("systemRadialMenu",true));std::lock_guard<std::mutex> lock(queue_mutex);recording_state=data.value("recording",json::object());recording_region_state=data.value("recordingRegion",json::object());framing_editor_state=data.value("framingEditor",json(nullptr));screenshot_editor_state=data.value("screenshotEditor",json(nullptr));screenshot_shortcut_preview=data.value("screenshotShortcutPreview",json(nullptr));if(!recording_state.is_object())recording_state=json::object();auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",false)}});last_feedback=feedback;}auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
std::thread priority_worker([]{LauncherPriority priority;while(running){priority.update(launcher_priority_active.load());std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;int icon_progress=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
|
||||
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();json recording,region,framing;{std::lock_guard<std::mutex> lock(queue_mutex);recording=recording_state;region=recording_region_state;framing=framing_editor_state;}if(auto editor=views.find("framely.framing");editor!=views.end()&&framing_editor_should_close(framing,editor->second->region_editor_id))close_view(*editor->second);recording_indicator.tick(overlays,recording.value("active",false)&&recording.value("indicatorVisible",true)?recording.value("id",std::string{}):std::string{},recording.value("elapsedSeconds",uint64_t(0)));if(auto editor=views.find("framely.framing");editor!=views.end())update_framing_placement(*editor->second);if(region.value("visible",false)&®ion.contains("view")){auto presentation=recording_region_presentation(region["view"]);recording_region_overlay.tick(overlays,region,presentation.distance,&presentation.view);}else recording_region_overlay.tick(overlays,region);vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}observe_dashboard(global);keyboard_event(global);}launcher_priority_active.store(launcher_open&&launcher_prioritize_ui.load());vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
|
||||
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();json recording,region,framing,screenshot_editor,screenshot_preview;{std::lock_guard<std::mutex> lock(queue_mutex);recording=recording_state;region=recording_region_state;framing=framing_editor_state;screenshot_editor=screenshot_editor_state;screenshot_preview=screenshot_shortcut_preview;}if(auto editor=views.find("framely.framing");editor!=views.end()&&framing_editor_should_close(framing,editor->second->region_editor_id))close_view(*editor->second);if(auto editor=views.find("framely.screenshot");editor!=views.end()&&framing_editor_should_close(screenshot_editor,editor->second->region_editor_id))close_view(*editor->second);recording_indicator.tick(overlays,recording.value("active",false)&&recording.value("indicatorVisible",true)?recording.value("id",std::string{}):std::string{},recording.value("elapsedSeconds",uint64_t(0)));if(auto editor=views.find("framely.framing");editor!=views.end())update_framing_placement(*editor->second);if(auto editor=views.find("framely.screenshot");editor!=views.end()&&editor->second->region_geometry.is_object()&&editor->second->region_geometry.contains("view"))update_framing_placement(*editor->second);if(screenshot_radial_held&&screenshot_preview.is_object()&&screenshot_preview.value("visible",false))screenshot_region_overlay.tick(overlays,screenshot_preview);else screenshot_region_overlay.destroy();if(region.value("visible",false)&®ion.contains("view")){auto presentation=recording_region_presentation(region["view"]);recording_region_overlay.tick(overlays,region,presentation.distance,&presentation.view);}else recording_region_overlay.tick(overlays,region);external_keyboard_tick(std::chrono::steady_clock::now());vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}observe_dashboard(global);keyboard_event(global);}launcher_priority_active.store(launcher_open&&launcher_prioritize_ui.load());vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
|
||||
if(visible&&(!was_visible||dock!=last_dock)){uv={};anchored_at={};sampled_at={};}last_dock=dock;
|
||||
// Width and aspect are only ray-solver seeds; physical size comes from intersections.
|
||||
float width=0,height=0;bool fresh=visible&&!overlays->GetOverlayWidthInMeters(dock,&width)&&std::isfinite(width)&&width>0&&width<10&&std::isfinite(mouse_scale.v[0])&&std::isfinite(mouse_scale.v[1])&&mouse_scale.v[0]>0&&mouse_scale.v[1]>0;
|
||||
@@ -768,7 +1162,7 @@ int main(int argc,char** argv){
|
||||
if(!main_overlay)main_overlay=active_main_window();
|
||||
if(main_overlay&&!overlays->GetOverlayMouseScale(main_overlay,&main_scale)&&!overlays->GetTransformForOverlayCoordinates(main_overlay,vr::TrackingUniverseStanding,{{main_scale.v[0]*.5f,main_scale.v[1]*.5f}},&main_pose))main=&main_pose;
|
||||
if(evidence.radius>0?dock_menu_placement(anchor,size,evidence.radius,evidence.pre_curve_pitch,popup):menu_placement(anchor,size,popup,0,main)){v.placement.apply(overlays,v.overlay,popup.transform,popup.width);v.placement.curve(overlays,v.overlay,popup.width,popup.radius);show(v,menu_open);}else show(v,false);}}else if(!keyboard_menu.holds(now)){if(overlays->IsOverlayVisible(button))overlays->ClearOverlayCursorPositionOverride(button);overlays->HideOverlay(button);if(views.count("menu"))show(*views.at("menu"),false);}
|
||||
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher"&&key.rfind("framely.cast.",0)!=0&&key!="framely.apk.permission")close_view(*v);}was_visible=visible;
|
||||
if(was_visible&&!visible&&!keyboard_open&&!keyboard_menu.holds(now)){menu_button.pressed=false;menu_open=false;if(!overlays->IsDashboardVisible())hide_launcher(false,true);for(auto&[key,v]:views)if(!v->dashboard&&key!=frame_keyboard_window_key&&key!="menu"&&key!="notifications"&&key!="launcher"&&key!="framely.system.radial"&&key!="framely.system.radial.progress"&&key.rfind("framely.cast.",0)!=0&&key!="framely.apk.permission")close_view(*v);}was_visible=visible;
|
||||
if(launcher_close_at.time_since_epoch().count()&&now>=launcher_close_at){launcher_close_at={};if(!launcher_open)finish_launcher_close();}
|
||||
// The runtime owns dashboard placement, curvature, visibility and switching.
|
||||
// Never move the launcher into standing space or hide another app's overlay.
|
||||
@@ -816,6 +1210,7 @@ int main(int argc,char** argv){
|
||||
activate_entry(entry_hold.tick(std::chrono::steady_clock::now(),anchor_ok&&entry_connected,entry_inside));
|
||||
for(auto&[key,v]:views)v->stick_scrolling=false;
|
||||
entry_actions(button,anchor_ok);
|
||||
system_radial_tick();
|
||||
// Some dashboard runtimes do not emit Modal_Cancel for clicks on their own
|
||||
// overlays. Observe the same controller click edge as a fallback, without
|
||||
// consuming the click destined for the other surface.
|
||||
@@ -831,7 +1226,7 @@ int main(int argc,char** argv){
|
||||
if(menu_open&&!keyboard_open&&menu_button.can_cancel(std::chrono::steady_clock::now())&&(pressed&click)&&views.count("menu")&&!overlays->IsHoverTargetOverlay(views.at("menu")->overlay)&&!overlays->IsHoverTargetOverlay(button))menu_open=false;
|
||||
}
|
||||
if(launcher_open){bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held;launcher_dismiss.observe(held);}
|
||||
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){if(!active&&key=="launcher"){if(v->launcher_pointer.active){cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;v->host->send_mouse_move_event(v->host,&outside,0);outside.modifiers=0;v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);}v->launcher_pointer.clear();auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->base.release(&frame->base);}v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;if(key!="launcher")for(auto&[other,t]:views)if(!t->dashboard&&other!="menu"&&other!="notifications"&&other!="launcher"&&other.rfind("framely.cast.",0)!=0&&other!="framely.apk.permission"&&!keyboard_open)close_view(*t);}}
|
||||
for(auto&[key,v]:views){if(v->closing)continue;if(v->dashboard){bool active=overlays->IsDashboardVisible()&&overlays->IsActiveDashboardOverlay(v->overlay)&&overlays->IsOverlayVisible(v->overlay);if(active!=v->shown){if(!active&&key=="launcher"){if(v->launcher_pointer.active){cef_mouse_event_t outside{};outside.modifiers=EVENTFLAG_LEFT_MOUSE_BUTTON;v->host->send_mouse_move_event(v->host,&outside,0);outside.modifiers=0;v->host->send_mouse_click_event(v->host,&outside,MBT_LEFT,1,1);}v->launcher_pointer.clear();auto* frame=v->browser->get_main_frame(v->browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.cancelInput'))");frame->base.release(&frame->base);}v->host->was_hidden(v->host,!active);v->shown=active;}if(!active)v->active_seen=false;if(active&&!v->active_seen){v->active_seen=true;if(key!="launcher")for(auto&[other,t]:views)if(!t->dashboard&&other!=frame_keyboard_window_key&&other!="menu"&&other!="notifications"&&other!="launcher"&&other!="framely.system.radial"&&other!="framely.system.radial.progress"&&other.rfind("framely.cast.",0)!=0&&other!="framely.apk.permission"&&!keyboard_open)close_view(*t);}}
|
||||
reconcile_framing_pointer(*v);
|
||||
while(overlays->PollNextOverlayEvent(v->overlay,&event,sizeof(event)))overlay_input(*v,event);
|
||||
if(!v->cast_frame.path.empty()){glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);if(v->cast_frame.update())v->dirty=true;}
|
||||
@@ -910,5 +1305,7 @@ int main(int argc,char** argv){
|
||||
}
|
||||
recording_indicator.destroy();
|
||||
recording_region_overlay.destroy();
|
||||
screenshot_region_overlay.destroy();
|
||||
if(external_keyboard.active){frame_keyboard_command("dashboardClose",json::object());external_keyboard={};}
|
||||
close_keyboard();for(auto&[key,v]:views)close_view(*v);auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(5);while(std::chrono::steady_clock::now()<deadline){cef_do_message_loop_work();bool all=true;for(auto&[key,v]:views)all&=v->closed;if(all)break;std::this_thread::sleep_for(std::chrono::milliseconds(5));}poller.join();priority_worker.join();for(auto&[key,v]:views)if(!v->closed){std::cerr<<"Browser shutdown timed out\n";_Exit(7);}for(auto&[key,v]:views){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);glDeleteTextures(2,v->textures);}views.clear();overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();glXMakeContextCurrent(xdisplay,None,None,nullptr);glXDestroyContext(xdisplay,glcontext);glXDestroyPbuffer(xdisplay,glsurface);XCloseDisplay(xdisplay);return 0;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keep SteamVR's click and double-click actions while Framely handles hold."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
APP = "openvr.component.vrcompositor"
|
||||
CONTROLLER = "frame_controller"
|
||||
BASE = "http://127.0.0.1:27062"
|
||||
HEADERS = {
|
||||
"Origin": BASE,
|
||||
"Referer": BASE + "/dashboard/controllerbinding.html",
|
||||
}
|
||||
DIRECTORY = Path.home() / ".local/share/framely/input"
|
||||
BINDING = DIRECTORY / "steam-system-menu.json"
|
||||
META = DIRECTORY / "steam-system-menu-source.json"
|
||||
|
||||
|
||||
def request(path, params=None, body=None):
|
||||
if params:
|
||||
path += "?" + urlencode(params)
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
headers = dict(HEADERS)
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
with urlopen(Request(BASE + path, data=data, headers=headers), timeout=3) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def current_url():
|
||||
data = request("/input/getactions.json", {"app_key": APP})
|
||||
return data["current_binding_url"][CONTROLLER]
|
||||
|
||||
|
||||
def select(url):
|
||||
result = request(
|
||||
"/input/selectconfig.action",
|
||||
body={"app_key": APP, "controller_type": CONTROLLER, "url": url},
|
||||
)
|
||||
if not result.get("success"):
|
||||
raise RuntimeError(f"SteamVR rejected controller binding: {result}")
|
||||
|
||||
|
||||
def source_binding(url):
|
||||
result = request(
|
||||
"/input/loadbindingfromurl.json",
|
||||
{"binding_url": url, "controller_type": CONTROLLER, "app_key": APP},
|
||||
)
|
||||
if "binding_config" not in result:
|
||||
raise RuntimeError(f"SteamVR could not load controller binding: {result}")
|
||||
return result["binding_config"]
|
||||
|
||||
|
||||
def without_recenter(binding):
|
||||
# Preserve every other compositor action, including click, double and
|
||||
# screenshot chords. A user's unrelated quick-recenter sources survive.
|
||||
result = json.loads(json.dumps(binding))
|
||||
sources = result.get("bindings", {}).get("/actions/quickrecenter", {}).get("sources", [])
|
||||
kept = [
|
||||
source for source in sources
|
||||
if source.get("path") not in (
|
||||
"/user/hand/left/input/system",
|
||||
"/user/hand/right/input/system",
|
||||
) or "long" not in source.get("inputs", {})
|
||||
]
|
||||
result["bindings"]["/actions/quickrecenter"]["sources"] = kept
|
||||
return result, len(kept) != len(sources)
|
||||
|
||||
|
||||
def atomic_json(path, value):
|
||||
DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".new")
|
||||
with temporary.open("w") as out:
|
||||
json.dump(value, out, ensure_ascii=False, separators=(",", ":"))
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def main():
|
||||
active = current_url()
|
||||
ours = BINDING.resolve().as_uri()
|
||||
metadata = json.loads(META.read_text()) if META.exists() else {}
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--restore":
|
||||
if active == ours and metadata.get("source_url"):
|
||||
select(metadata["source_url"])
|
||||
return 10
|
||||
return 0
|
||||
source_url = metadata.get("source_url") if active == ours else active
|
||||
if not source_url:
|
||||
raise RuntimeError("SteamVR did not report a Frame controller binding")
|
||||
source = source_binding(source_url)
|
||||
patched, changed = without_recenter(source)
|
||||
if not changed:
|
||||
return 0
|
||||
digest = hashlib.sha256(json.dumps(source, sort_keys=True).encode()).hexdigest()
|
||||
if active == ours and metadata.get("source_hash") == digest and BINDING.exists():
|
||||
return 0
|
||||
atomic_json(BINDING, patched)
|
||||
atomic_json(META, {"source_url": source_url, "source_hash": digest})
|
||||
select(ours)
|
||||
return 10 # SteamVR caches the old long-press action until it restarts.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as error:
|
||||
print(f"SteamVR system-menu binding: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <vector>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <utility>
|
||||
#include "vendor/json.hpp"
|
||||
using RegionJson=nlohmann::json;
|
||||
struct RecordingRegionPlane {vr::HmdMatrix34_t transform{};float width=0;};
|
||||
@@ -46,7 +47,9 @@ inline std::vector<unsigned char> recording_region_pixels(int w,int h,const std:
|
||||
}
|
||||
class RecordingRegionOverlay {
|
||||
vr::IVROverlay* overlays=nullptr;vr::VROverlayHandle_t handle=0;std::string last,last_pixels;
|
||||
std::string key;
|
||||
public:
|
||||
explicit RecordingRegionOverlay(std::string overlay_key="framely.recording.region"):key(std::move(overlay_key)){}
|
||||
void destroy(){if(handle&&overlays)overlays->DestroyOverlay(handle);handle=0;last.clear();last_pixels.clear();}
|
||||
void tick(vr::IVROverlay* api,const RegionJson& state,double distance=2.,const RegionJson* live_view=nullptr){
|
||||
overlays=api;
|
||||
@@ -56,7 +59,7 @@ public:
|
||||
const auto& view=live_view?*live_view:state.at("view");double aspect=view.at("aspect");
|
||||
RecordingRegionPlane plane;if(!recording_region_plane(view,view.at("tanHalfHorizontal"),view.at("centerTanX"),view.at("centerTanY"),aspect,plane,distance)){destroy();return;}
|
||||
if(!handle){
|
||||
if(overlays->CreateOverlay("framely.recording.region","Framely recording region",&handle))return;
|
||||
if(overlays->CreateOverlay(key.c_str(),"Framely capture region",&handle))return;
|
||||
overlays->SetOverlayInputMethod(handle,vr::VROverlayInputMethod_None);
|
||||
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,false);
|
||||
overlays->SetOverlayFlag(handle,vr::VROverlayFlags_HideLaserIntersection,true);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
|
||||
enum class SystemRadialDirection {Center,Up,UpRight,Right,DownRight,Down,DownLeft,Left,UpLeft};
|
||||
enum class SystemRadialEvent {Idle,Begin,Open,Change,Cancel,Commit};
|
||||
|
||||
struct SystemRadial {
|
||||
using Clock=std::chrono::steady_clock;
|
||||
bool active=false,open=false;
|
||||
int hand=-1;
|
||||
Clock::time_point began{};
|
||||
SystemRadialDirection direction=SystemRadialDirection::Center;
|
||||
static constexpr auto hold=std::chrono::seconds(1);
|
||||
static constexpr float deadzone=.09f;
|
||||
float progress(Clock::time_point now) const {
|
||||
return active?std::clamp(std::chrono::duration<float>(now-began).count(),0.f,1.f):0.f;
|
||||
}
|
||||
SystemRadialEvent update(int input_hand,bool valid,bool down,float right,float up,Clock::time_point now,std::optional<SystemRadialDirection> hover=std::nullopt) {
|
||||
if(!active) {
|
||||
if(!valid||!down)return SystemRadialEvent::Idle;
|
||||
active=true;open=false;hand=input_hand;began=now;direction=SystemRadialDirection::Center;
|
||||
return SystemRadialEvent::Begin;
|
||||
}
|
||||
if(input_hand!=hand)return SystemRadialEvent::Idle;
|
||||
if(!valid) {active=false;open=false;hand=-1;return SystemRadialEvent::Cancel;}
|
||||
if(!down) {
|
||||
const bool commit=open||now-began>=hold;
|
||||
active=false;open=false;hand=-1;
|
||||
return commit?SystemRadialEvent::Commit:SystemRadialEvent::Cancel;
|
||||
}
|
||||
if(!open&&now-began>=hold){open=true;return SystemRadialEvent::Open;}
|
||||
if(open) {
|
||||
auto next=hover.value_or(select(right,up));
|
||||
if(next!=direction){direction=next;return SystemRadialEvent::Change;}
|
||||
}
|
||||
return SystemRadialEvent::Idle;
|
||||
}
|
||||
static SystemRadialDirection select(float right,float up) {
|
||||
if(right*right+up*up<deadzone*deadzone)return SystemRadialDirection::Center;
|
||||
constexpr float sector=.78539816339f;
|
||||
int index=(int)std::lround(std::atan2(right,up)/sector);
|
||||
if(index<0)index+=8;
|
||||
switch(index%8) {
|
||||
case 0:return SystemRadialDirection::Up;
|
||||
case 1:return SystemRadialDirection::UpRight;
|
||||
case 2:return SystemRadialDirection::Right;
|
||||
case 3:return SystemRadialDirection::DownRight;
|
||||
case 4:return SystemRadialDirection::Down;
|
||||
case 5:return SystemRadialDirection::DownLeft;
|
||||
case 6:return SystemRadialDirection::Left;
|
||||
default:return SystemRadialDirection::UpLeft;
|
||||
}
|
||||
}
|
||||
// Match the four visible 124x82 slots and 112px center in the 720px overlay.
|
||||
// A ray inside the painted button always selects it, including its border.
|
||||
static std::optional<SystemRadialDirection> hit(float x,float y) {
|
||||
const float dx=x-360.f,dy=y-360.f;
|
||||
if(dx*dx+dy*dy<=56.f*56.f)return SystemRadialDirection::Center;
|
||||
if(std::abs(dx)<=62.f&&std::abs(dy+177.f)<=41.f)return SystemRadialDirection::Up;
|
||||
if(std::abs(dx-177.f)<=62.f&&std::abs(dy)<=41.f)return SystemRadialDirection::Right;
|
||||
if(std::abs(dx)<=62.f&&std::abs(dy-177.f)<=41.f)return SystemRadialDirection::Down;
|
||||
if(std::abs(dx+177.f)<=62.f&&std::abs(dy)<=41.f)return SystemRadialDirection::Left;
|
||||
return std::nullopt;
|
||||
}
|
||||
};
|
||||
@@ -10,7 +10,7 @@
|
||||
// never spawns a process or reads files on a hover/click.
|
||||
class SteamUiSounds {
|
||||
struct Spec { int format, channels, freq; };
|
||||
struct Clip { void* stream=nullptr; uint8_t* data=nullptr; uint32_t size=0; } clips[2];
|
||||
struct Clip { void* stream=nullptr; uint8_t* data=nullptr; uint32_t size=0; } clips[3];
|
||||
void* library=nullptr;
|
||||
bool initialized=false;
|
||||
bool (*init)(uint32_t)=nullptr;
|
||||
@@ -39,14 +39,15 @@ class SteamUiSounds {
|
||||
if(!library)return false;
|
||||
if(!(bind(init,"SDL_InitSubSystem")&&bind(quit,"SDL_QuitSubSystem")&&bind(load,"SDL_LoadWAV")&&bind(open,"SDL_OpenAudioDeviceStream")&&bind(resume,"SDL_ResumeAudioStreamDevice")&&bind(put,"SDL_PutAudioStreamData")&&bind(clear,"SDL_ClearAudioStream")&&bind(gain,"SDL_SetAudioStreamGain")&&bind(destroy,"SDL_DestroyAudioStream")&&bind(release,"SDL_free"))){shutdown();return false;}
|
||||
if(!init(0x10)){shutdown();return false;}initialized=true;
|
||||
const char* names[]={"deck_ui_typing.wav","deck_ui_typing.wav"};
|
||||
for(int i=0;i<2;++i){Spec spec{};auto& clip=clips[i];
|
||||
const char* names[]={"deck_ui_typing.wav","deck_ui_typing.wav","camera1.wav"};
|
||||
for(int i=0;i<3;++i){Spec spec{};auto& clip=clips[i];
|
||||
if(!load((root+"/steamui/sounds/"+names[i]).c_str(),&spec,&clip.data,&clip.size)||!clip.size||clip.size>1024*1024){shutdown();return false;}
|
||||
clip.stream=open(0xffffffffu,&spec,nullptr,nullptr);
|
||||
if(!clip.stream||!gain(clip.stream,i==0?.30f:.45f)||!resume(clip.stream)){shutdown();return false;}
|
||||
if(!clip.stream||!gain(clip.stream,i==0?.30f:i==1?.45f:.55f)||!resume(clip.stream)){shutdown();return false;}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
void silence(){if(!initialized)return;for(auto& clip:clips)clear(clip.stream);}
|
||||
void silence(){if(!initialized)return;for(int i=0;i<2;i++)clear(clips[i].stream);}
|
||||
void play(bool activation=false){if(!initialized)return;silence();auto& clip=clips[activation?1:0];put(clip.stream,clip.data,int(clip.size));}
|
||||
void screenshot(){if(!initialized)return;auto& clip=clips[2];clear(clip.stream);put(clip.stream,clip.data,int(clip.size));}
|
||||
};
|
||||
@@ -5,6 +5,9 @@ root=/var/lib/framely
|
||||
previous=$(cat "$root/previous-release")
|
||||
[[ $previous == releases/* && $previous != *..* && -d $root/$previous ]] || { echo 'No previous release.' >&2; exit 1; }
|
||||
current=$(readlink "$root/current")
|
||||
if [[ ! -f $root/$previous/share/input/steam-system-menu.py && -f $root/current/system-menu-restore.sh ]]; then
|
||||
bash "$root/current/system-menu-restore.sh"
|
||||
fi
|
||||
systemctl stop framely-session.service framely.service
|
||||
ln -s "$previous" "$root/current.rollback"
|
||||
mv -Tf "$root/current.rollback" "$root/current"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
root=/var/lib/framely
|
||||
helper=$root/current/share/input/steam-system-menu.py
|
||||
[[ -f $helper && -f $root/steam-user ]] || exit 0
|
||||
steam_user=$(cat "$root/steam-user")
|
||||
steam_uid=$(id -u "$steam_user")
|
||||
steam_home=$(getent passwd "$steam_user" | cut -d: -f6)
|
||||
set +e
|
||||
runuser -u "$steam_user" -- env HOME="$steam_home" XDG_RUNTIME_DIR="/run/user/$steam_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$steam_uid/bus" python3 "$helper" --restore
|
||||
result=$?
|
||||
set -e
|
||||
if [[ $result == 10 ]]; then
|
||||
systemctl --user --machine="$steam_user@" restart steamvr.service
|
||||
elif [[ $result != 0 ]]; then
|
||||
echo 'Could not restore the original SteamVR system-button binding; start SteamVR and retry.' >&2
|
||||
exit "$result"
|
||||
fi
|
||||
@@ -13,6 +13,9 @@ restore_session() {
|
||||
exit "$code"
|
||||
}
|
||||
trap restore_session ERR
|
||||
if [[ -f $root/current/system-menu-restore.sh ]]; then
|
||||
bash "$root/current/system-menu-restore.sh"
|
||||
fi
|
||||
systemctl start framely.service
|
||||
ready=false
|
||||
for attempt in {1..30}; do
|
||||
|
||||
@@ -8,7 +8,7 @@ const listeners=new Set<(event:unknown)=>void>();
|
||||
function request(op:string,params:unknown={}):Promise<any>{const id=next++;return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{pending.delete(id);reject(new Error('Framely request timed out'));},90000);pending.set(id,{resolve,reject,timer});parent.postMessage({channel:'framely.plugin',id,op,params},'*');});}
|
||||
window.addEventListener('message',e=>{if(e.source!==parent||e.data?.channel!=='framely.reply')return;const data=e.data;if(data.event){listeners.forEach(f=>f(data.event));return;}const p=pending.get(data.id);if(!p)return;pending.delete(data.id);clearTimeout(p.timer);if(data.error)p.reject(new Error(data.error));else p.resolve(data.result);});
|
||||
(window as any).__framelyBridge={request,subscribe:(f:(e:unknown)=>void)=>{listeners.add(f);return()=>listeners.delete(f);}};
|
||||
installKeyboard(params=>request('keyboard',params));
|
||||
installKeyboard(params=>request('keyboard',params),(method,params)=>request('keyboard',{bridge:{method,params}}));
|
||||
|
||||
window.addEventListener('pagehide',()=>{for(const p of pending.values()){clearTimeout(p.timer);p.reject(new Error('Plugin page closed'));}pending.clear();});
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Host SDK adapter. Reuses installKeyboard's editor; no layout/window changes.
|
||||
export type KeyboardBridgeCall=(method:string,params:Record<string,unknown>)=>Promise<any>;
|
||||
export function createKeyboardBridge(call:KeyboardBridgeCall){
|
||||
let field:HTMLElement|null=null,context='',active=false,stopped=false;
|
||||
const client=`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
let serial=0,timer:ReturnType<typeof setTimeout>|undefined;
|
||||
const valid=()=>!!field?.isConnected&&document.activeElement===field&&!field.closest('[inert]')&&!(field as HTMLInputElement).disabled&&!(field as HTMLInputElement).readOnly;
|
||||
const close=()=>{const old=context;active=false;context='';if(timer)clearTimeout(timer);if(old)void call('webInputClose',{context:old}).catch(()=>{});};
|
||||
const key=(op:any)=>{
|
||||
if(op.kind==='text'){(window as any).__framelyEditKeyboard(op.text);return true;}
|
||||
const input=field as HTMLInputElement|HTMLTextAreaElement;
|
||||
const map:Record<string,string>={BackSpace:'\b',Left:'\x1b[D',Right:'\x1b[C',Up:'\x1b[A',Down:'\x1b[B'};
|
||||
if(!op.ctrl&&!op.alt&&map[op.key]){(window as any).__framelyEditKeyboard(map[op.key]);return true;}
|
||||
if(op.ctrl&&op.key.toLowerCase()==='a'&&typeof input.select==='function'){input.select();return true;}
|
||||
if(op.ctrl||op.alt)return false;
|
||||
if(['Home','End','Delete'].includes(op.key)&&typeof input.value==='string'){
|
||||
const a=input.selectionStart??input.value.length,b=input.selectionEnd??a;
|
||||
if(op.key==='Delete'){
|
||||
if(a===b){const next=Array.from(new (Intl as any).Segmenter(undefined,{granularity:'grapheme'}).segment(input.value.slice(a)))[0] as any;input.setSelectionRange(a,a+(next?.segment.length??0));}
|
||||
if((input.selectionStart??0)!==(input.selectionEnd??0))(window as any).__framelyEditKeyboard('\b');
|
||||
}else{const p=op.key==='Home'?0:input.value.length;input.setSelectionRange(op.shift?a:p,p);}
|
||||
return true;
|
||||
}
|
||||
const event={key:op.key==='Return'?'Enter':op.key==='Escape'?'Escape':op.key,code:op.key==='Return'?'Enter':op.key,bubbles:true,cancelable:true,shiftKey:!!op.shift};
|
||||
const allowed=field!.dispatchEvent(new KeyboardEvent('keydown',event));
|
||||
if(op.key==='Return'&&allowed){
|
||||
if(field instanceof HTMLTextAreaElement)(window as any).__framelyEditKeyboard('\n');
|
||||
else if(field instanceof HTMLInputElement&&field.form)field.form.requestSubmit();
|
||||
}
|
||||
if(op.key==='Tab'&&allowed){const fields=Array.from(document.querySelectorAll<HTMLElement>('input:not([disabled]),textarea:not([disabled]),button:not([disabled]),select:not([disabled]),[tabindex="0"]')).filter(e=>e.getClientRects().length>0&&!e.closest('[inert]'));const index=fields.indexOf(field!);fields[(index+(op.shift?-1:1)+fields.length)%fields.length]?.focus();}
|
||||
field?.dispatchEvent(new KeyboardEvent('keyup',event));return true;
|
||||
};
|
||||
const poll=async()=>{
|
||||
const token=context;if(!active||!token||stopped)return;
|
||||
if(!valid()){close();return;}
|
||||
try{
|
||||
const response=await call('webInputPoll',{context:token});
|
||||
if(context!==token||!active)return;
|
||||
if(!response.active){close();return;}
|
||||
for(const edit of response.edits??[]){
|
||||
let delivered=false;
|
||||
if(edit.context===token&&context===token&&valid()&&Date.now()<edit.expiresAt){
|
||||
const claim=await call('webInputClaim',{context:token,id:edit.id,generation:edit.generation});
|
||||
if(claim.accepted&&context===token&&valid()&&Date.now()<edit.expiresAt)delivered=key(edit.operation);
|
||||
}
|
||||
await call('webInputAck',{context:token,id:edit.id,generation:edit.generation,delivered});
|
||||
}
|
||||
}catch{close();return;}
|
||||
if(context===token&&active)timer=setTimeout(poll,20);
|
||||
};
|
||||
const lost=(event:FocusEvent)=>{if(event.target===field)close();};
|
||||
const dispose=()=>{stopped=true;close();field=null;document.removeEventListener('focusout',lost,true);window.removeEventListener('pagehide',dispose);window.removeEventListener('blur',close);};
|
||||
document.addEventListener('focusout',lost,true);window.addEventListener('pagehide',dispose);window.addEventListener('blur',close);
|
||||
const start=async(target:HTMLElement,password:boolean):Promise<boolean>=>{
|
||||
if(stopped||document.activeElement!==target)return false;
|
||||
if(active&&field===target&&valid())return true;
|
||||
close();field=target;context=`${client}-${++serial}`;
|
||||
const token=context;
|
||||
try{
|
||||
const response=await call('webInputOpen',{context:token,client,sequence:serial,expiresAt:Date.now()+900,password});
|
||||
if(context!==token||!valid()){if(response.ok)void call('webInputClose',{context:token});return true;}
|
||||
active=!!response.ok;if(active){void poll();return true;}context='';return false;
|
||||
}catch{if(context!==token||stopped||!valid())return true;context='';return false;}
|
||||
|
||||
};
|
||||
return Object.assign(start,{dispose});
|
||||
}
|
||||
+11
-5
@@ -1,18 +1,23 @@
|
||||
import {createKeyboardBridge,type KeyboardBridgeCall} from './keyboard-bridge';
|
||||
type Editable=HTMLInputElement|HTMLTextAreaElement|HTMLElement;
|
||||
export function installKeyboard(open:(params:{existing:string;password:boolean;multiline:boolean})=>Promise<unknown>){
|
||||
export function installKeyboard(open:(params:{existing:string;password:boolean;multiline:boolean})=>Promise<unknown>,bridgeCall?:KeyboardBridgeCall,options:{requestOnFocus?:boolean}={}){
|
||||
const bridge=bridgeCall?createKeyboardBridge(bridgeCall):null;
|
||||
let field:Editable|null=null,last:Editable|null=null,openedAt=0;
|
||||
const request=(target:EventTarget|null)=>{
|
||||
const embedded=target instanceof HTMLElement&&target.matches('[data-framely-editor-input][contenteditable="true"]');
|
||||
if(!embedded&&(!(target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement)||target.disabled||target.readOnly))return;
|
||||
if(!(target instanceof HTMLElement))return;
|
||||
if(target instanceof HTMLInputElement&&!['text','password','url','search','email','number','tel'].includes(target.type))return;
|
||||
field=target;const now=performance.now();if(last===target&&now-openedAt<300)return;last=target;openedAt=now;
|
||||
if(document.activeElement!==target)return;
|
||||
field=target;const now=performance.now();if(!bridge&&last===target&&now-openedAt<300)return;last=target;openedAt=now;
|
||||
const params={existing:target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement?target.value:'',password:target instanceof HTMLInputElement&&target.type==='password',multiline:embedded||target instanceof HTMLTextAreaElement};
|
||||
if(embedded)target.dispatchEvent(new CustomEvent('framely.keyboard.open',{detail:params}));
|
||||
void open(params).catch(console.error);
|
||||
void (async()=>{if(bridge&&await bridge(target,params.password))return;if(document.activeElement!==target)return;await open(bridge?{...params,existing:''}:params);})().catch(console.error);
|
||||
};
|
||||
document.addEventListener('focusin',e=>request(e.target));
|
||||
document.addEventListener('pointerdown',e=>request(e.target));
|
||||
const focus=(e:FocusEvent)=>{if(options.requestOnFocus!==false)request(e.target);};
|
||||
const keyboardFocus=(e:KeyboardEvent)=>{if(e.key==='Tab'&&options.requestOnFocus===false)request(document.activeElement);};
|
||||
const pointer=(e:PointerEvent)=>{const target=e.target;setTimeout(()=>request(target),0);};
|
||||
document.addEventListener('focusin',focus);document.addEventListener('pointerdown',pointer);document.addEventListener('keyup',keyboardFocus);
|
||||
(window as any).__framelyCommitKeyboard=(value:string)=>{
|
||||
if(!field?.isConnected)return;
|
||||
if(field.closest('[inert]')||((field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement)&&(field.disabled||field.readOnly)))return;
|
||||
@@ -59,4 +64,5 @@ export function installKeyboard(open:(params:{existing:string;password:boolean;m
|
||||
(window as any).__framelyCommitKeyboard(updated);
|
||||
try{field.setSelectionRange(position,position);}catch{}
|
||||
};
|
||||
return {activate:()=>request(document.activeElement),dispose:()=>{bridge?.dispose();field=null;last=null;document.removeEventListener('focusin',focus);document.removeEventListener('pointerdown',pointer);document.removeEventListener('keyup',keyboardFocus);}};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod settings;
|
||||
pub mod runtime;
|
||||
mod recording;
|
||||
pub mod screenshot;
|
||||
pub mod region;
|
||||
#[cfg(test)]
|
||||
mod region_device_test;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! One-frame screenshots. The capture path is independent of the cast encoder.
|
||||
use super::settings::{Eye, Output, Settings, Source};
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use image::{DynamicImage, ImageBuffer, ImageFormat, Rgba};
|
||||
use std::{fs, io::{BufWriter, Write}, os::unix::process::CommandExt, path::{Path, PathBuf}, process::{Child, Command, Output as ProcessOutput, Stdio}, time::{SystemTime, UNIX_EPOCH}};
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Crop {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
impl Crop {
|
||||
pub fn validate(self) -> Result<Self> {
|
||||
ensure!([self.x,self.y,self.width,self.height].into_iter().all(f64::is_finite)
|
||||
&& self.x >= 0. && self.y >= 0. && self.width > 0. && self.height > 0.
|
||||
&& self.x + self.width <= 1.000001 && self.y + self.height <= 1.000001,
|
||||
"截图取景范围无效");
|
||||
Ok(self)
|
||||
}
|
||||
pub fn apply(self, image: DynamicImage) -> Result<DynamicImage> {
|
||||
let self_ = self.validate()?;
|
||||
let width = image.width(); let height = image.height();
|
||||
let x = (self_.x * width as f64).round() as u32;
|
||||
let y = (self_.y * height as f64).round() as u32;
|
||||
let w = (self_.width * width as f64).round() as u32;
|
||||
let h = (self_.height * height as f64).round() as u32;
|
||||
ensure!(w > 0 && h > 0 && x + w <= width && y + h <= height,"截图取景范围无效");
|
||||
Ok(image.crop_imm(x,y,w,h))
|
||||
}
|
||||
}
|
||||
|
||||
fn screen_process(settings: &Settings, binaries: &Path, panel_socket: &Path, wait: bool) -> Result<Child> {
|
||||
let eye = if settings.output==Output::Raw {0} else {match settings.eye {Eye::Left=>0,Eye::Right=>1,Eye::SteamVR=>super::runtime::dominant_eye()?}};
|
||||
let stream=std::os::unix::net::UnixStream::connect(panel_socket)?;
|
||||
use std::os::fd::AsRawFd;
|
||||
let fd=stream.as_raw_fd();
|
||||
let mut command=Command::new(binaries.join("framely-capture"));
|
||||
command.args(["--snapshot","--width",&settings.width.to_string(),"--height",&settings.height.to_string(),"--eye",&eye.to_string()]);
|
||||
if wait {command.arg("--snapshot-wait");}
|
||||
if settings.output==Output::Raw {command.arg("--raw");}
|
||||
else {
|
||||
if let Some(fov)=settings.horizontal_fov {command.arg(format!("--fov={fov}"));}
|
||||
command.arg(format!("--center-x={}",settings.center_x)).arg(format!("--center-y={}",settings.center_y));
|
||||
}
|
||||
command.env("FRAMELY_GRAB_FD","3").stdin(if wait {Stdio::piped()} else {Stdio::null()}).stdout(Stdio::piped()).stderr(Stdio::piped()).process_group(0);
|
||||
unsafe {command.pre_exec(move || {
|
||||
if fd!=3 && libc::dup2(fd,3)<0 {return Err(std::io::Error::last_os_error());}
|
||||
if libc::fcntl(3,libc::F_SETFD,0)<0 {return Err(std::io::Error::last_os_error());}
|
||||
Ok(())
|
||||
});}
|
||||
let child=command.spawn().context("无法启动屏幕截图采集")?;
|
||||
drop(stream);
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
fn screen_image(output: ProcessOutput) -> Result<DynamicImage> {
|
||||
ensure!(output.status.success(),"屏幕截图失败:{}",String::from_utf8_lossy(&output.stderr).trim());
|
||||
ensure!(output.stdout.len()>=8,"截图采集没有返回画面");
|
||||
let width=u32::from_le_bytes(output.stdout[0..4].try_into()?);
|
||||
let height=u32::from_le_bytes(output.stdout[4..8].try_into()?);
|
||||
ensure!(width>0&&height>0&&u64::from(width)*u64::from(height)<=9_331_200
|
||||
&& output.stdout.len()==8+width as usize*height as usize*4,"截图采集数据长度无效");
|
||||
let pixels=ImageBuffer::<Rgba<u8>,_>::from_raw(width,height,output.stdout[8..].to_vec()).context("截图像素无效")?;
|
||||
Ok(DynamicImage::ImageRgba8(pixels))
|
||||
}
|
||||
|
||||
fn screen(settings: &Settings, binaries: &Path, panel_socket: &Path) -> Result<DynamicImage> {
|
||||
screen_image(screen_process(settings,binaries,panel_socket,false)?.wait_with_output()?)
|
||||
}
|
||||
|
||||
pub struct PreparedScreen { child: Option<Child> }
|
||||
impl PreparedScreen {
|
||||
pub fn start(settings: &Settings, binaries: &Path, panel_socket: &Path) -> Result<Self> {
|
||||
ensure!(settings.source==Source::Screen,"仅屏幕采集支持预热截图");
|
||||
Ok(Self{child:Some(screen_process(settings,binaries,panel_socket,true)?)})
|
||||
}
|
||||
pub fn capture(mut self) -> Result<DynamicImage> {
|
||||
self.child.as_mut().context("截图采集程序已退出")?.stdin.take().context("无法触发截图")?.write_all(&[1])?;
|
||||
let child=self.child.take().unwrap();
|
||||
screen_image(child.wait_with_output()?)
|
||||
}
|
||||
}
|
||||
impl Drop for PreparedScreen {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut child)=self.child.take() {let _=child.kill();let _=child.wait();}
|
||||
}
|
||||
}
|
||||
|
||||
fn steamvr() -> Result<DynamicImage> {
|
||||
let output=Command::new("ffmpeg").args(["-hide_banner","-loglevel","error","-nostdin","-f","v4l2","-i","/dev/video99","-frames:v","1","-f","image2pipe","-c:v","png","pipe:1"])
|
||||
.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).output().context("无法启动 SteamVR 截图采集")?;
|
||||
ensure!(output.status.success(),"SteamVR 截图失败:{}",String::from_utf8_lossy(&output.stderr).trim());
|
||||
let image=image::load_from_memory_with_format(&output.stdout,ImageFormat::Png)?;
|
||||
ensure!(image.width()>0&&image.height()>0&&u64::from(image.width())*u64::from(image.height())<=9_331_200,"SteamVR 截图尺寸无效");
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
pub fn capture(settings: &Settings, binaries: &Path, panel_socket: Option<&Path>, crop: Option<Crop>) -> Result<DynamicImage> {
|
||||
let image=match settings.source {
|
||||
Source::Screen=>screen(settings,binaries,panel_socket.context("缺少屏幕采集助手连接")?)?,
|
||||
Source::SteamVR=>steamvr()?,
|
||||
};
|
||||
match crop {Some(crop)=>crop.apply(image),None=>Ok(image)}
|
||||
}
|
||||
|
||||
pub fn save(image: &DynamicImage, directory: &Path) -> Result<PathBuf> {
|
||||
fs::create_dir_all(directory)?;
|
||||
let timestamp=SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
|
||||
let suffix=hex::encode(rand::random::<[u8;4]>());
|
||||
let name=format!("framely-{timestamp}-{suffix}.png");
|
||||
let path=directory.join(name);
|
||||
let temporary=directory.join(format!(".framely-{timestamp}-{suffix}.part"));
|
||||
let result=(|| -> Result<()> {
|
||||
let file=fs::OpenOptions::new().write(true).create_new(true).open(&temporary)?;
|
||||
let mut writer=BufWriter::new(file);
|
||||
image.write_to(&mut writer,ImageFormat::Png)?;
|
||||
use std::io::Write;
|
||||
writer.flush()?;
|
||||
writer.into_inner()?.sync_all()?;
|
||||
fs::rename(&temporary,&path)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err(){let _=fs::remove_file(&temporary);}
|
||||
result?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -31,6 +31,14 @@ pub enum Codec {
|
||||
H264,
|
||||
H265,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ScreenshotShortcut {
|
||||
#[default]
|
||||
LeftView,
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
}
|
||||
impl Codec {
|
||||
pub fn name(self) -> &'static str { match self { Self::H264 => "h264", Self::H265 => "h265" } }
|
||||
pub fn rtp(self) -> &'static str { match self { Self::H264 => "H264/90000", Self::H265 => "H265/90000" } }
|
||||
@@ -58,6 +66,10 @@ pub struct Settings {
|
||||
pub receiver_name: String,
|
||||
pub recording_region_visible: bool,
|
||||
pub recording_indicator_visible: bool,
|
||||
#[serde(skip_serializing)]
|
||||
pub screenshot_shortcut_enabled: bool,
|
||||
#[serde(skip_serializing)]
|
||||
pub screenshot_shortcut: ScreenshotShortcut,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
@@ -80,6 +92,8 @@ impl Default for Settings {
|
||||
receiver_name: "Framely".into(),
|
||||
recording_region_visible: false,
|
||||
recording_indicator_visible: true,
|
||||
screenshot_shortcut_enabled: false,
|
||||
screenshot_shortcut: ScreenshotShortcut::LeftView,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +105,8 @@ impl Settings {
|
||||
capture.receiver_name.clone_from(&other.receiver_name);
|
||||
capture.recording_region_visible = other.recording_region_visible;
|
||||
capture.recording_indicator_visible = other.recording_indicator_visible;
|
||||
capture.screenshot_shortcut_enabled = other.screenshot_shortcut_enabled;
|
||||
capture.screenshot_shortcut = other.screenshot_shortcut;
|
||||
capture == *other
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
|
||||
+92
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -198,6 +198,8 @@ impl Default for FeedbackSettings {
|
||||
pub struct LauncherSettings {
|
||||
#[serde(default = "yes")]
|
||||
pub prioritize_ui: bool,
|
||||
#[serde(default)]
|
||||
pub system_radial_menu: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub primary_trigger: bool,
|
||||
#[serde(default = "yes")]
|
||||
@@ -264,6 +266,7 @@ impl Default for LauncherSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prioritize_ui: true,
|
||||
system_radial_menu: false,
|
||||
primary_trigger: true,
|
||||
menu_auto_close: true,
|
||||
menu_timeout_seconds: 10,
|
||||
@@ -865,6 +868,8 @@ pub struct Database {
|
||||
#[serde(default)]
|
||||
pub launcher: LauncherSettings,
|
||||
#[serde(default)]
|
||||
pub system_radial_default_off_applied: bool,
|
||||
#[serde(default)]
|
||||
pub gaze_calibration: Option<GazeCalibration>,
|
||||
#[serde(default)]
|
||||
pub steam_favorites: std::collections::BTreeSet<u32>,
|
||||
|
||||
+6
-1
@@ -159,6 +159,11 @@ impl Service {
|
||||
fs::rename(root.join("state.json.tmp"), root.join("state.json"))?;
|
||||
fs::remove_file(root.join("install-transaction.json"))?;
|
||||
}
|
||||
let radial_default_migrated = !db.system_radial_default_off_applied;
|
||||
if radial_default_migrated {
|
||||
db.launcher.system_radial_menu = false;
|
||||
db.system_radial_default_off_applied = true;
|
||||
}
|
||||
let initialized_defaults = crate::subscriptions::initialize_defaults(&mut db)?;
|
||||
crate::subscriptions::initialize(&mut db)?;
|
||||
// Rehydrate presentation after a state-schema downgrade from immutable payloads.
|
||||
@@ -228,7 +233,7 @@ impl Service {
|
||||
visibility_pending: BTreeSet::new(),
|
||||
launch_contexts: BTreeMap::new(),
|
||||
};
|
||||
if initialized_defaults {
|
||||
if initialized_defaults || radial_default_migrated {
|
||||
service.save()?;
|
||||
}
|
||||
Ok(service)
|
||||
|
||||
+402
-9
@@ -13,6 +13,27 @@ use std::{
|
||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
static APK_STORAGE_API_GATE: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn apply_system_menu_binding(helper: &Path, enabled: bool) -> Result<bool> {
|
||||
ensure!(helper.is_file(), "缺少 Steam 键绑定助手");
|
||||
let mut command=Command::new("python3");
|
||||
command.arg(helper);
|
||||
if !enabled {command.arg("--restore");}
|
||||
let status=command.status().context("无法更新 Steam 键绑定")?;
|
||||
ensure!(status.success()||status.code()==Some(10),"Steam 键绑定更新失败:{status}");
|
||||
Ok(status.code()==Some(10))
|
||||
}
|
||||
|
||||
fn schedule_steamvr_restart() -> Result<()> {
|
||||
let unit=format!("framely-system-menu-{}",cast_random_key());
|
||||
let output=Command::new("systemd-run")
|
||||
.args(["--user","--quiet","--collect","--on-active=2s"])
|
||||
.arg(format!("--unit={unit}"))
|
||||
.args(["/usr/bin/systemctl","--user","restart","steamvr.service"])
|
||||
.output().context("无法安排 SteamVR 重启")?;
|
||||
ensure!(output.status.success(),"无法安排 SteamVR 重启:{}",String::from_utf8_lossy(&output.stderr).trim());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct FrameAccess {
|
||||
plugin: String,
|
||||
version: String,
|
||||
@@ -36,8 +57,10 @@ struct Agent {
|
||||
terminals: crate::terminal::Terminals,
|
||||
files: Arc<crate::manager_files::Files>,
|
||||
catalog_cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
|
||||
casting: Mutex<crate::cast::runtime::Casting>,
|
||||
casting: Arc<Mutex<crate::cast::runtime::Casting>>,
|
||||
screenshot: Arc<Mutex<ScreenshotState>>,
|
||||
recording_overlay_cache: Mutex<Value>,
|
||||
radial_last: Mutex<String>,
|
||||
watch_access: Mutex<crate::cast::watch_access::Access>,
|
||||
receivers: Mutex<crate::cast::receiver::Receivers>,
|
||||
}
|
||||
@@ -46,6 +69,53 @@ struct EventLog {
|
||||
sequence: u64,
|
||||
entries: VecDeque<Value>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct ScreenshotState {
|
||||
id: String,
|
||||
open: bool,
|
||||
closed: bool,
|
||||
settings: Option<crate::cast::settings::Settings>,
|
||||
geometry: Value,
|
||||
preview: Option<Vec<u8>>,
|
||||
preview_aspect: Option<f64>,
|
||||
hotkey_active: bool,
|
||||
hotkey_preview: Value,
|
||||
hotkey_settings: Option<crate::cast::settings::Settings>,
|
||||
hotkey_thumbnail: Option<Vec<u8>>,
|
||||
hotkey_warm_geometry: Option<(crate::cast::settings::Settings, Value)>,
|
||||
hotkey_warm_pending: Option<crate::cast::settings::Settings>,
|
||||
hotkey_warm_capture: Option<(crate::cast::settings::Settings, crate::cast::screenshot::PreparedScreen)>,
|
||||
hotkey_warm_capture_pending: Option<crate::cast::settings::Settings>,
|
||||
}
|
||||
impl ScreenshotState {
|
||||
fn status(&self) -> Value { json!({"id":self.id,"open":self.open}) }
|
||||
fn editor(&self) -> Result<Value> {
|
||||
ensure!(self.open,"截图取景窗口已关闭");
|
||||
Ok(json!({"id":self.id,"settings":self.settings,"geometry":self.geometry,"spatial":self.geometry.is_object(),"preview":if self.preview.is_some(){Some(format!("/cast/screenshot/preview/{}",self.id))}else{None},"previewAspect":self.preview_aspect}))
|
||||
}
|
||||
fn acknowledge(&mut self,id:&str) {if self.id==id {self.closed=true;self.open=false;self.preview=None;}}
|
||||
}
|
||||
fn capture_hotkey_image(
|
||||
settings: &crate::cast::settings::Settings,
|
||||
binaries: &Path,
|
||||
panel: Option<&Path>,
|
||||
state: &Arc<Mutex<ScreenshotState>>,
|
||||
) -> Result<image::DynamicImage> {
|
||||
let deadline=std::time::Instant::now()+Duration::from_millis(1400);
|
||||
let prepared=loop {
|
||||
let mut state=state.lock().unwrap();
|
||||
if state.hotkey_warm_capture.as_ref().is_some_and(|(old,_)|old==settings) {
|
||||
break state.hotkey_warm_capture.take().map(|(_,capture)|capture);
|
||||
}
|
||||
if state.hotkey_warm_capture_pending.as_ref()!=Some(settings)||std::time::Instant::now()>=deadline {break None;}
|
||||
drop(state);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
if let Some(prepared)=prepared {
|
||||
if let Ok(image)=prepared.capture() {return Ok(image);}
|
||||
}
|
||||
crate::cast::screenshot::capture(settings,binaries,panel,None)
|
||||
}
|
||||
impl EventLog {
|
||||
fn append(&mut self, mut event: Value) {
|
||||
self.sequence += 1;
|
||||
@@ -76,6 +146,29 @@ fn push(q: &Mutex<VecDeque<Value>>, v: Value) {
|
||||
}
|
||||
q.push_back(v);
|
||||
}
|
||||
fn radial_record(casting: Arc<Mutex<crate::cast::runtime::Casting>>, socket: PathBuf) {
|
||||
std::thread::spawn(move || {
|
||||
let result=(||->Result<()> {
|
||||
let mut cast=casting.lock().unwrap();
|
||||
if cast.recording_status()["active"]==true {
|
||||
cast.stop_recording()?;
|
||||
} else {
|
||||
let status=ipc::call(&socket,"status",json!({}))?;
|
||||
ensure!(status["agreement"]["accepted"]==true,"请先同意用户协议和隐私声明");
|
||||
let settings:crate::cast::settings::Settings=serde_json::from_value(status["database"]["casting"].clone())?;
|
||||
let panel=if settings.source==crate::cast::settings::Source::Screen {
|
||||
let response=ipc::call(&socket,"cast.panel.start",json!({}))?;
|
||||
Some(PathBuf::from(response["socket"].as_str().context("Missing panel socket")?))
|
||||
}else{None};
|
||||
cast.record(&settings,panel.as_deref(),&crate::steam::home()?.join("Videos/framely"))?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error)=result {
|
||||
let _=ipc::call(&socket,"cast.notification",json!({"notification":{"id":format!("cast.radial.{}",cast_random_key()),"title":"录屏失败","body":format!("{error:#}"),"durationMs":5000,"actions":[]}}));
|
||||
}
|
||||
});
|
||||
}
|
||||
impl Agent {
|
||||
fn send_page(&self, r: Request, remote: bool, guest: bool, network_cookie: Option<String>) -> Result<()> {
|
||||
let data = fs::read(self.assets.join("index.html"))?;
|
||||
@@ -422,11 +515,15 @@ impl Agent {
|
||||
}
|
||||
if path == "/host/poll" {
|
||||
ensure!(!remote && self.native(&r), "Native authentication failed");
|
||||
let mut hotkey_event=String::new();
|
||||
let mut radial_action=String::new();
|
||||
if r.method() == &Method::Post {
|
||||
let mut bytes = Vec::new();
|
||||
r.as_reader().take(32769).read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.len() <= 32768, "Visibility report too large");
|
||||
let views: Value = serde_json::from_slice(&bytes)?;
|
||||
hotkey_event=views["screenshotHotkey"].as_str().unwrap_or("").to_owned();
|
||||
radial_action=views["radialAction"].as_str().unwrap_or("").to_owned();
|
||||
// Empty acknowledgements are sent on ordinary native polls.
|
||||
// Do not wait for the casting lock while describe/start is
|
||||
// running: the native HTTP timeout is intentionally short.
|
||||
@@ -436,6 +533,9 @@ impl Agent {
|
||||
if let Some(id)=views["framingClosed"].as_str().filter(|id|!id.is_empty()) {
|
||||
self.casting.lock().unwrap().region.acknowledge_editor_closed(id);
|
||||
}
|
||||
if let Some(id)=views["screenshotClosed"].as_str().filter(|id|!id.is_empty()) {
|
||||
self.screenshot.lock().unwrap().acknowledge(id);
|
||||
}
|
||||
if views["views"].is_object() {
|
||||
self.core("host.ui.visibility",views["views"].clone())?;
|
||||
if let Some(ids)=views["closedCasts"].as_array(){ensure!(ids.len()<=32,"Too many closed casts");for id in ids{if let Some(id)=id.as_str(){let _=self.receivers.lock().unwrap().control(id,"stop",json!({}));}}}
|
||||
@@ -443,6 +543,10 @@ impl Agent {
|
||||
self.core("host.ui.visibility", views)?;
|
||||
}
|
||||
}
|
||||
if radial_action.starts_with("record:") && *self.radial_last.lock().unwrap()!=radial_action {
|
||||
*self.radial_last.lock().unwrap()=radial_action.clone();
|
||||
radial_record(Arc::clone(&self.casting),self.socket.clone());
|
||||
}
|
||||
let mut queue = self.commands.lock().unwrap();
|
||||
let mut commands = Vec::new();
|
||||
let mut size = 0;
|
||||
@@ -463,9 +567,149 @@ impl Agent {
|
||||
.ok()
|
||||
.map(|v| v["database"].clone())
|
||||
.unwrap_or(json!({}));
|
||||
if !hotkey_event.is_empty() {let event=hotkey_event.as_str();
|
||||
match event {
|
||||
"radialPrepare" => {
|
||||
let settings:Result<crate::cast::settings::Settings,_>=serde_json::from_value(database["casting"].clone());
|
||||
if let Ok(settings)=settings {
|
||||
if settings.source==crate::cast::settings::Source::Screen {
|
||||
let mut state=self.screenshot.lock().unwrap();
|
||||
let ready=state.hotkey_warm_capture.as_ref().is_some_and(|(old,_)|old==&settings);
|
||||
let pending=state.hotkey_warm_capture_pending.as_ref()==Some(&settings);
|
||||
if !ready&&!pending {
|
||||
state.hotkey_warm_capture=None;
|
||||
state.hotkey_warm_capture_pending=Some(settings.clone());
|
||||
drop(state);
|
||||
let panel=self.core("cast.panel.start",json!({})).ok().and_then(|value|value["socket"].as_str().map(PathBuf::from));
|
||||
let binaries=self.casting.lock().unwrap().binaries.clone();
|
||||
let state=Arc::clone(&self.screenshot);
|
||||
let capture_settings=settings.clone();
|
||||
std::thread::spawn(move||{
|
||||
let capture=panel.as_deref().and_then(|path|crate::cast::screenshot::PreparedScreen::start(&capture_settings,&binaries,path).ok());
|
||||
let mut state=state.lock().unwrap();
|
||||
if state.hotkey_warm_capture_pending.as_ref()==Some(&capture_settings) {
|
||||
state.hotkey_warm_capture_pending=None;
|
||||
state.hotkey_warm_capture=capture.map(|capture|(capture_settings,capture));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if crate::cast::region::supported(&settings) {
|
||||
let mut state=self.screenshot.lock().unwrap();
|
||||
let pending=state.hotkey_warm_pending.as_ref()==Some(&settings);
|
||||
if !pending {
|
||||
state.hotkey_warm_geometry=None;
|
||||
state.hotkey_warm_pending=Some(settings.clone());
|
||||
drop(state);
|
||||
let state=Arc::clone(&self.screenshot);
|
||||
let binaries=self.casting.lock().unwrap().binaries.clone();
|
||||
std::thread::spawn(move||{
|
||||
let mut region=crate::cast::region::Region::default();region.editing=true;
|
||||
let geometry=region.refresh(&settings,&binaries,None).and_then(|_|region.editor());
|
||||
let mut state=state.lock().unwrap();
|
||||
if state.hotkey_warm_pending.as_ref()==Some(&settings) {
|
||||
state.hotkey_warm_pending=None;
|
||||
if let Ok(geometry)=geometry {
|
||||
if state.hotkey_active&&state.hotkey_settings.as_ref()==Some(&settings) {
|
||||
state.hotkey_preview=json!({"visible":true,"view":geometry["view"],"settings":settings});
|
||||
}
|
||||
state.hotkey_warm_geometry=Some((settings,geometry));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"radialCancel" => {
|
||||
let mut state=self.screenshot.lock().unwrap();
|
||||
state.hotkey_warm_capture_pending=None;
|
||||
state.hotkey_warm_capture=None;
|
||||
},
|
||||
"radialPress" | "radialCapture" => {
|
||||
let settings:Result<crate::cast::settings::Settings,_>=serde_json::from_value(database["casting"].clone());
|
||||
if let Ok(settings)=settings {
|
||||
let mut state=self.screenshot.lock().unwrap();
|
||||
if !state.open&&!state.hotkey_active {
|
||||
state.hotkey_active=true;state.hotkey_settings=Some(settings.clone());state.hotkey_preview=Value::Null;state.hotkey_thumbnail=None;
|
||||
drop(state);
|
||||
if crate::cast::region::supported(&settings) {
|
||||
let warm={let state=self.screenshot.lock().unwrap();state.hotkey_warm_geometry.as_ref().filter(|(old,_)|old==&settings).map(|(_,geometry)|geometry.clone())};
|
||||
let warm_pending={self.screenshot.lock().unwrap().hotkey_warm_pending.as_ref()==Some(&settings)};
|
||||
if let Some(geometry)=warm {
|
||||
let mut state=self.screenshot.lock().unwrap();
|
||||
if state.hotkey_active {state.hotkey_preview=json!({"visible":true,"view":geometry["view"],"settings":settings});}
|
||||
} else if !warm_pending {
|
||||
let state=Arc::clone(&self.screenshot);
|
||||
let binaries=self.casting.lock().unwrap().binaries.clone();
|
||||
std::thread::spawn(move||{
|
||||
let mut region=crate::cast::region::Region::default();region.editing=true;
|
||||
if region.refresh(&settings,&binaries,None).is_ok() {
|
||||
if let Ok(geometry)=region.editor() {
|
||||
let mut state=state.lock().unwrap();
|
||||
if state.hotkey_active {state.hotkey_preview=json!({"visible":true,"view":geometry["view"],"settings":settings});}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let state=Arc::clone(&self.screenshot);
|
||||
let queue=Arc::clone(&self.commands);
|
||||
let binaries=self.casting.lock().unwrap().binaries.clone();
|
||||
let panel=if settings.source==crate::cast::settings::Source::Screen {self.core("cast.panel.start",json!({})).ok().and_then(|value|value["socket"].as_str().map(PathBuf::from))}else{None};
|
||||
std::thread::spawn(move||{
|
||||
if let Ok(image)=crate::cast::screenshot::capture(&settings,&binaries,panel.as_deref(),None) {
|
||||
let mut bytes=std::io::Cursor::new(Vec::new());
|
||||
if image.thumbnail(960,540).write_to(&mut bytes,image::ImageFormat::Png).is_ok() {
|
||||
let mut state=state.lock().unwrap();
|
||||
if state.hotkey_active {state.hotkey_thumbnail=Some(bytes.into_inner());queue.lock().unwrap().push_back(json!({"kind":"cast.screenshot.shortcutPreview.open","aspect":image.width() as f64/image.height() as f64}));}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"release"|"cancel" => {
|
||||
let settings={let mut state=self.screenshot.lock().unwrap();state.hotkey_preview=Value::Null;state.hotkey_thumbnail=None;let active=state.hotkey_active;state.hotkey_active=false;if event=="cancel" {state.hotkey_warm_capture_pending=None;state.hotkey_warm_capture=None;}if active&&event=="release" {state.hotkey_settings.take()}else{state.hotkey_settings=None;None}};
|
||||
self.commands.lock().unwrap().push_back(json!({"kind":"cast.screenshot.shortcutPreview.close"}));
|
||||
if let Some(settings)=settings {
|
||||
let binaries=self.casting.lock().unwrap().binaries.clone();
|
||||
let control=self.socket.clone();
|
||||
let queue=Arc::clone(&self.commands);
|
||||
let state=Arc::clone(&self.screenshot);
|
||||
let panel=if settings.source==crate::cast::settings::Source::Screen {self.core("cast.panel.start",json!({})).ok().and_then(|value|value["socket"].as_str().map(PathBuf::from))}else{None};
|
||||
std::thread::spawn(move||{
|
||||
let result=(||->Result<PathBuf>{let image=capture_hotkey_image(&settings,&binaries,panel.as_deref(),&state)?;crate::cast::screenshot::save(&image,&crate::steam::home()?.join("Pictures/framely"))})();
|
||||
if result.is_ok(){push(&queue,json!({"kind":"cast.screenshot.sound"}));}
|
||||
let (title,body)=match result {Ok(path)=>("截图已保存",path.display().to_string()),Err(error)=>("截图失败",format!("{error:#}"))};
|
||||
let _=ipc::call(&control,"cast.notification",json!({"notification":{"id":format!("cast.screenshot.{}",cast_random_key()),"title":title,"body":body,"durationMs":5000,"actions":[]}}));
|
||||
});
|
||||
}
|
||||
},
|
||||
_=>{}
|
||||
}
|
||||
if event=="radialCapture" {
|
||||
// Selection and release may happen before the next native poll.
|
||||
let settings={let mut state=self.screenshot.lock().unwrap();state.hotkey_preview=Value::Null;state.hotkey_thumbnail=None;state.hotkey_active=false;state.hotkey_settings.take()};
|
||||
if let Some(settings)=settings {
|
||||
let binaries=self.casting.lock().unwrap().binaries.clone();
|
||||
let control=self.socket.clone();
|
||||
let queue=Arc::clone(&self.commands);
|
||||
let state=Arc::clone(&self.screenshot);
|
||||
let panel=if settings.source==crate::cast::settings::Source::Screen {self.core("cast.panel.start",json!({})).ok().and_then(|value|value["socket"].as_str().map(PathBuf::from))}else{None};
|
||||
std::thread::spawn(move||{
|
||||
let result=(||->Result<PathBuf>{let image=capture_hotkey_image(&settings,&binaries,panel.as_deref(),&state)?;crate::cast::screenshot::save(&image,&crate::steam::home()?.join("Pictures/framely"))})();
|
||||
if result.is_ok(){push(&queue,json!({"kind":"cast.screenshot.sound"}));}
|
||||
let (title,body)=match result {Ok(path)=>("截图已保存",path.display().to_string()),Err(error)=>("截图失败",format!("{error:#}"))};
|
||||
let _=ipc::call(&control,"cast.notification",json!({"notification":{"id":format!("cast.screenshot.{}",cast_random_key()),"title":title,"body":body,"durationMs":5000,"actions":[]}}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return send_json(
|
||||
r,
|
||||
{let (mut recording,region,framing)=if let Ok(cast)=self.casting.try_lock() {let status=cast.recording_status();*self.recording_overlay_cache.lock().unwrap()=status.clone();(status,cast.region_status(),cast.region.editor_state())}else{(self.recording_overlay_cache.lock().unwrap().clone(),json!({"visible":false}),Value::Null)};recording["indicatorVisible"]=json!(database["casting"]["recordingIndicatorVisible"].as_bool().unwrap_or(true));json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"],"recording":recording,"recordingRegion":region,"framingEditor":framing})},
|
||||
{let (mut recording,region,framing)=if let Ok(cast)=self.casting.try_lock() {let status=cast.recording_status();*self.recording_overlay_cache.lock().unwrap()=status.clone();(status,cast.region_status(),cast.region.editor_state())}else{(self.recording_overlay_cache.lock().unwrap().clone(),json!({"visible":false}),Value::Null)};recording["indicatorVisible"]=json!(database["casting"]["recordingIndicatorVisible"].as_bool().unwrap_or(true));let screenshot=self.screenshot.lock().unwrap();json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"],"recording":recording,"recordingRegion":region,"framingEditor":framing,"screenshotEditor":screenshot.status(),"screenshotShortcutPreview":screenshot.hotkey_preview})},
|
||||
);
|
||||
}
|
||||
if let Some(rel) = path.strip_prefix("/plugin-assets/") {
|
||||
@@ -605,7 +849,11 @@ impl Agent {
|
||||
))
|
||||
.with_header(header(
|
||||
"Location",
|
||||
if r.url().ends_with("?view=manager") {
|
||||
if r.url().ends_with("?view=radial") {
|
||||
"/system/radial"
|
||||
} else if r.url().ends_with("?view=radial-progress") {
|
||||
"/system/radial/progress"
|
||||
} else if r.url().ends_with("?view=manager") {
|
||||
"/manager"
|
||||
} else if r.url().ends_with("?view=notifications") {
|
||||
"/notifications"
|
||||
@@ -965,9 +1213,32 @@ impl Agent {
|
||||
};
|
||||
return send_json(r, v);
|
||||
}
|
||||
if let Some(id)=path.strip_prefix("/cast/screenshot/preview/") {
|
||||
ensure!(r.method()==&Method::Get,"Invalid screenshot preview method");
|
||||
let state=self.screenshot.lock().unwrap();
|
||||
ensure!(state.open&&state.id==id,"截图预览已失效");
|
||||
let bytes=state.preview.as_ref().context("截图预览不可用")?.clone();
|
||||
r.respond(Response::from_data(bytes).with_header(header("Content-Type","image/png")).with_header(header("Cache-Control","no-store")))?;
|
||||
return Ok(());
|
||||
}
|
||||
if path == "/cast/screenshot/shortcut/preview.png" {
|
||||
let state=self.screenshot.lock().unwrap();
|
||||
ensure!(state.hotkey_active,"截图预览已失效");
|
||||
let bytes=state.hotkey_thumbnail.as_ref().context("截图预览不可用")?.clone();
|
||||
r.respond(Response::from_data(bytes).with_header(header("Content-Type","image/png")).with_header(header("Cache-Control","no-store")))?;
|
||||
return Ok(());
|
||||
}
|
||||
if path == "/cast/screenshot/shortcut" {
|
||||
r.respond(Response::from_string("<!doctype html><html><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><style>html,body{margin:0;width:100%;height:100%;background:#080b0e}body{display:grid;place-items:center}img{display:block;max-width:100%;max-height:100%;object-fit:contain}</style><img src='/cast/screenshot/shortcut/preview.png' alt='截图区域'></html>")
|
||||
.with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Cache-Control","no-store")))?;
|
||||
return Ok(());
|
||||
}
|
||||
if path == "/"
|
||||
|| path == "/system/radial"
|
||||
|| path == "/system/radial/progress"
|
||||
|| path == "/cast/watch"
|
||||
|| path == "/cast/framing"
|
||||
|| path == "/cast/screenshot"
|
||||
|| path.starts_with("/cast/window/")
|
||||
|| path == "/apk/permission"
|
||||
|| path == "/manager"
|
||||
@@ -1077,6 +1348,85 @@ impl Agent {
|
||||
if let Err(error)=self.core("cast.region.visible.save",json!({"enabled":enabled})) {let _=cast.refresh_region(&previous);return Err(error);}
|
||||
Ok(json!({"enabled":enabled}))
|
||||
},
|
||||
"cast.screenshot.open" => {
|
||||
let settings:crate::cast::settings::Settings=serde_json::from_value(self.core("status",json!({}))?["database"]["casting"].clone())?;
|
||||
{let state=self.screenshot.lock().unwrap();ensure!(!state.open&&!state.hotkey_active,"请先完成当前截图");}
|
||||
let id=cast_random_key();
|
||||
let spatial=crate::cast::region::supported(&settings);
|
||||
let (geometry,preview,preview_aspect)=if spatial {
|
||||
let binaries=self.casting.lock().unwrap().binaries.clone();
|
||||
let mut region=crate::cast::region::Region::default();
|
||||
region.editing=true;region.editor_id=id.clone();region.refresh(&settings,&binaries,None)?;
|
||||
(region.editor()?,None,None)
|
||||
} else {
|
||||
let panel=if settings.source==crate::cast::settings::Source::Screen {
|
||||
Some(PathBuf::from(self.core("cast.panel.start",json!({}))?["socket"].as_str().context("Missing panel socket")?))
|
||||
}else{None};
|
||||
let binaries=self.casting.lock().unwrap().binaries.clone();
|
||||
let image=crate::cast::screenshot::capture(&settings,&binaries,panel.as_deref(),None)?;
|
||||
let mut bytes=std::io::Cursor::new(Vec::new());
|
||||
image.thumbnail(960,960).write_to(&mut bytes,image::ImageFormat::Png)?;
|
||||
(Value::Null,Some(bytes.into_inner()),Some(image.width() as f64/image.height() as f64))
|
||||
};
|
||||
let mut state=self.screenshot.lock().unwrap();
|
||||
ensure!(!state.open&&!state.hotkey_active,"请先完成当前截图");
|
||||
state.id=id.clone();state.open=true;state.closed=false;state.settings=Some(settings);state.geometry=geometry.clone();state.preview=preview;
|
||||
state.preview_aspect=preview_aspect;
|
||||
self.commands.lock().unwrap().push_back(json!({"kind":"cast.screenshot.open","id":id,"geometry":geometry,"spatial":spatial}));
|
||||
state.editor()
|
||||
},
|
||||
"cast.screenshot.get" => self.screenshot.lock().unwrap().editor(),
|
||||
"cast.screenshot.cancel" => {
|
||||
let id=p["id"].as_str().context("Missing screenshot session")?;
|
||||
let mut state=self.screenshot.lock().unwrap();
|
||||
ensure!(state.open&&state.id==id,"截图取景窗口已关闭");
|
||||
state.open=false;state.preview=None;
|
||||
self.commands.lock().unwrap().push_back(json!({"kind":"cast.screenshot.close","id":id}));
|
||||
Ok(json!(true))
|
||||
},
|
||||
"cast.screenshot.capture" => {
|
||||
let id=p["id"].as_str().context("Missing screenshot session")?.to_owned();
|
||||
let settings={
|
||||
let mut state=self.screenshot.lock().unwrap();
|
||||
ensure!(state.open&&state.id==id,"截图取景窗口已关闭");
|
||||
let mut settings=state.settings.clone().context("Missing screenshot settings")?;
|
||||
if crate::cast::region::supported(&settings) {
|
||||
let aspect=p["aspect"].as_f64().context("Missing screenshot aspect")?;
|
||||
ensure!(aspect.is_finite()&&(0.25..=4.).contains(&aspect),"截图比例无效");
|
||||
settings.width=1920;
|
||||
settings.height=(((1920.0/aspect).round() as u32).clamp(480,4096)+1)&!1;
|
||||
settings.horizontal_fov=Some(p["horizontalFov"].as_f64().context("Missing screenshot FOV")?);
|
||||
settings.center_x=p["centerX"].as_f64().context("Missing screenshot center")?;
|
||||
settings.center_y=p["centerY"].as_f64().context("Missing screenshot center")?;
|
||||
settings.validate()?;
|
||||
}
|
||||
state.open=false;state.preview=None;
|
||||
settings
|
||||
};
|
||||
self.commands.lock().unwrap().push_back(json!({"kind":"cast.screenshot.close","id":id}));
|
||||
let deadline=std::time::Instant::now()+Duration::from_secs(2);
|
||||
loop {
|
||||
if self.screenshot.lock().unwrap().closed {break;}
|
||||
ensure!(std::time::Instant::now()<deadline,"截图窗口未能及时关闭");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let crop=if crate::cast::region::supported(&settings){None}else{Some(serde_json::from_value(p["crop"].clone())?)};
|
||||
let panel=if settings.source==crate::cast::settings::Source::Screen {
|
||||
Some(PathBuf::from(self.core("cast.panel.start",json!({}))?["socket"].as_str().context("Missing panel socket")?))
|
||||
}else{None};
|
||||
let binaries=self.casting.lock().unwrap().binaries.clone();
|
||||
let result=(||->Result<Value>{
|
||||
let image=crate::cast::screenshot::capture(&settings,&binaries,panel.as_deref(),crop)?;
|
||||
let path=crate::cast::screenshot::save(&image,&crate::steam::home()?.join("Pictures/framely"))?;
|
||||
Ok(json!({"path":path,"width":image.width(),"height":image.height()}))
|
||||
})();
|
||||
if let Err(error)=&result {
|
||||
let _=self.core("cast.notification",json!({"notification":{"id":format!("cast.screenshot.{}",cast_random_key()),"title":"截图失败","body":error.to_string(),"durationMs":5000,"actions":[]}}));
|
||||
} else {
|
||||
push(&self.commands,json!({"kind":"cast.screenshot.sound"}));
|
||||
}
|
||||
result
|
||||
},
|
||||
"cast.framing.open" => {
|
||||
let settings:crate::cast::settings::Settings=serde_json::from_value(self.core("status",json!({}))?["database"]["casting"].clone())?;
|
||||
ensure!(crate::cast::region::supported(&settings),"画幅调整仅支持屏幕采集的单眼画面");
|
||||
@@ -1685,6 +2035,26 @@ impl Agent {
|
||||
}
|
||||
Ok(json!(true))
|
||||
}
|
||||
"launcher.settings.save" => {
|
||||
let next: LauncherSettings=serde_json::from_value(p.clone())?;
|
||||
next.validate()?;
|
||||
let previous: LauncherSettings=serde_json::from_value(self.core("status",json!({}))?["database"]["launcher"].clone())?;
|
||||
if next.system_radial_menu==previous.system_radial_menu {return self.core(method,p);}
|
||||
let helper=self.assets.parent().unwrap_or(&self.assets).join("input/steam-system-menu.py");
|
||||
let changed=apply_system_menu_binding(&helper,next.system_radial_menu)?;
|
||||
if let Err(error)=self.core(method,p) {
|
||||
if changed {let _=apply_system_menu_binding(&helper,previous.system_radial_menu);}
|
||||
return Err(error);
|
||||
}
|
||||
if changed {
|
||||
if let Err(error)=schedule_steamvr_restart() {
|
||||
let _=self.core(method,serde_json::to_value(&previous)?);
|
||||
let _=apply_system_menu_binding(&helper,previous.system_radial_menu);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(json!(true))
|
||||
}
|
||||
"status"
|
||||
| "storage.plugin.status"
|
||||
| "storage.plugin.start"
|
||||
@@ -1706,7 +2076,6 @@ impl Agent {
|
||||
| "plugin.launch"
|
||||
| "plugin.launcher.action"
|
||||
| "feedback.settings.save"
|
||||
| "launcher.settings.save"
|
||||
| "launcher.gaze.calibration.get"
|
||||
| "launcher.gaze.calibration.save"
|
||||
| "launcher.order.save"
|
||||
@@ -2160,6 +2529,7 @@ pub fn serve(
|
||||
.context("Missing HTTP address")?
|
||||
.port();
|
||||
let origin = format!("http://127.0.0.1:{port}");
|
||||
let system_menu_binding = assets.parent().unwrap_or(&assets).join("input/steam-system-menu.py");
|
||||
let web_key = random_key();
|
||||
let native_key = random_key();
|
||||
let runtime = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
@@ -2174,8 +2544,10 @@ pub fn serve(
|
||||
token: ipc::call(&socket, "cast.watchAccess.get", json!({}))?["token"].as_str().map(str::to_owned),
|
||||
..Default::default()
|
||||
}),
|
||||
casting: Mutex::new(crate::cast::runtime::Casting::new(runtime.join("cast"), crate::cast::runtime::binary_directory(&assets))),
|
||||
recording_overlay_cache: Mutex::new(json!({"active":false})),
|
||||
casting: Arc::new(Mutex::new(crate::cast::runtime::Casting::new(runtime.join("cast"), crate::cast::runtime::binary_directory(&assets)))),
|
||||
screenshot: Arc::default(),
|
||||
recording_overlay_cache: Mutex::new(json!({"active":false})),
|
||||
radial_last: Mutex::new(String::new()),
|
||||
receivers: Mutex::new(crate::cast::receiver::Receivers::new(runtime.join("receive"), crate::cast::runtime::binary_directory(&assets))),
|
||||
socket,
|
||||
state,
|
||||
@@ -2308,8 +2680,24 @@ pub fn serve(
|
||||
if !http_only {
|
||||
let run = runtime.clone();
|
||||
let url = format!("{origin}/boot/{web_key}");
|
||||
let binding_socket=agent.socket.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
wait_for_steam_session();
|
||||
let mut system_menu_ready = false;
|
||||
if system_menu_binding.exists() {
|
||||
let enabled=ipc::call(&binding_socket,"status",json!({})).ok()
|
||||
.and_then(|status|status["database"]["launcher"]["systemRadialMenu"].as_bool()).unwrap_or(false);
|
||||
match apply_system_menu_binding(&system_menu_binding,enabled) {
|
||||
Ok(true) => {
|
||||
if Command::new("systemctl").args(["--user", "restart", "steamvr.service"]).status().is_ok_and(|s|s.success()) {
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Ok(false) => system_menu_ready = enabled,
|
||||
Err(error) => eprintln!("SteamVR system menu binding failed: {error:#}"),
|
||||
}
|
||||
}
|
||||
let result = Command::new(&native)
|
||||
.arg("--url")
|
||||
.arg(&url)
|
||||
@@ -2326,6 +2714,7 @@ pub fn serve(
|
||||
"--change-stack-guard-on-fork=disable",
|
||||
])
|
||||
.env("FRAMELY_NATIVE_TOKEN", &native_key)
|
||||
.env("FRAMELY_STEAM_MENU_ENABLED", if system_menu_ready {"1"} else {"0"})
|
||||
.env("FRAMELY_STEAM_ROOT", {
|
||||
let home = PathBuf::from(std::env::var_os("HOME").unwrap_or_default());
|
||||
let steam = home.join(".local/share/Steam");
|
||||
@@ -2346,7 +2735,9 @@ pub fn serve(
|
||||
for r in server.incoming_requests() {
|
||||
let a = agent.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = a.handle(r);
|
||||
if let Err(error) = a.handle(r) {
|
||||
eprintln!("UI request failed: {error:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
@@ -2709,8 +3100,10 @@ mod tests {
|
||||
fn agent(server: &Server, root: &Path) -> Arc<Agent> {
|
||||
Arc::new(Agent {
|
||||
watch_access: Mutex::default(),
|
||||
casting: Mutex::new(crate::cast::runtime::Casting::new(root.join("cast"), root.join("media"))),
|
||||
recording_overlay_cache: Mutex::new(json!({"active":false})),
|
||||
casting: Arc::new(Mutex::new(crate::cast::runtime::Casting::new(root.join("cast"), root.join("media")))),
|
||||
screenshot: Arc::default(),
|
||||
recording_overlay_cache: Mutex::new(json!({"active":false})),
|
||||
radial_last: Mutex::new(String::new()),
|
||||
receivers: Mutex::new(crate::cast::receiver::Receivers::new(root.join("receive"), root.join("media"))),
|
||||
socket: root.join("control.sock"),
|
||||
state: root.join("state"),
|
||||
|
||||
@@ -2165,6 +2165,30 @@ fn launcher_trigger_defaults_preserve_explicit_preferences() {
|
||||
assert!(!restored.primary_trigger);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_menu_defaults_off_and_legacy_setting_migrates_once() {
|
||||
assert!(!LauncherSettings::default().system_radial_menu);
|
||||
let missing: LauncherSettings = serde_json::from_value(json!({})).unwrap();
|
||||
assert!(!missing.system_radial_menu);
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let state = root.path().join("state");
|
||||
std::fs::create_dir_all(&state).unwrap();
|
||||
std::fs::write(
|
||||
state.join("state.json"),
|
||||
serde_json::to_vec(&json!({"plugins":{},"sources":[],"safeMode":false,"launcher":{"systemRadialMenu":true}})).unwrap(),
|
||||
).unwrap();
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
let mut core = Service::load(&state, uid).unwrap();
|
||||
assert!(!core.db.launcher.system_radial_menu);
|
||||
assert!(core.db.system_radial_default_off_applied);
|
||||
|
||||
core.handle("agreement.accept", json!({"version":AGREEMENT_VERSION,"userAgreement":true,"privacyStatement":true})).unwrap();
|
||||
core.handle("launcher.settings.save", json!({"systemRadialMenu":true})).unwrap();
|
||||
let loaded = Service::load(&state, uid).unwrap();
|
||||
assert!(loaded.db.launcher.system_radial_menu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_gaze_calibration_validates_persists_and_clears() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#include "system_radial.h"
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
int main(){
|
||||
using Clock=SystemRadial::Clock;
|
||||
auto start=Clock::now();
|
||||
SystemRadial menu;
|
||||
assert(menu.update(0,true,true,0,0,start)==SystemRadialEvent::Begin);
|
||||
assert(menu.update(0,true,false,0,0,start+std::chrono::milliseconds(120))==SystemRadialEvent::Cancel);
|
||||
assert(menu.update(1,true,true,0,0,start+std::chrono::seconds(2))==SystemRadialEvent::Begin);
|
||||
assert(menu.update(1,true,true,0,0,start+std::chrono::seconds(3))==SystemRadialEvent::Open);
|
||||
assert(menu.update(1,true,true,0,.15f,start+std::chrono::milliseconds(3100))==SystemRadialEvent::Change);
|
||||
assert(menu.direction==SystemRadialDirection::Up);
|
||||
assert(menu.update(1,true,false,0,.15f,start+std::chrono::milliseconds(3200))==SystemRadialEvent::Commit);
|
||||
assert(SystemRadial::select(.2f,0)==SystemRadialDirection::Right);
|
||||
assert(SystemRadial::select(-.2f,0)==SystemRadialDirection::Left);
|
||||
assert(SystemRadial::select(0,-.2f)==SystemRadialDirection::Down);
|
||||
assert(SystemRadial::select(.03f,.01f)==SystemRadialDirection::Center);
|
||||
assert(SystemRadial::hit(475,319)==SystemRadialDirection::Right);
|
||||
assert(SystemRadial::hit(599,401)==SystemRadialDirection::Right);
|
||||
assert(SystemRadial::hit(360,183)==SystemRadialDirection::Up);
|
||||
assert(SystemRadial::hit(183,360)==SystemRadialDirection::Left);
|
||||
assert(SystemRadial::hit(360,537)==SystemRadialDirection::Down);
|
||||
assert(!SystemRadial::hit(470,315));
|
||||
SystemRadial pointer;
|
||||
assert(pointer.update(0,true,true,0,0,start)==SystemRadialEvent::Begin);
|
||||
assert(pointer.update(0,true,true,0,0,start+std::chrono::seconds(1))==SystemRadialEvent::Open);
|
||||
assert(pointer.update(0,true,true,0,0,start+std::chrono::milliseconds(1050),SystemRadial::hit(475,319))==SystemRadialEvent::Change);
|
||||
assert(pointer.direction==SystemRadialDirection::Right);
|
||||
std::cout<<"system radial: passed\n";
|
||||
}
|
||||
@@ -7,6 +7,11 @@ 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
|
||||
|
||||
spec = importlib.util.spec_from_file_location('bootstrap', pathlib.Path(__file__).parents[1] / 'tools/bootstrap.py')
|
||||
bootstrap = importlib.util.module_from_spec(spec)
|
||||
@@ -14,6 +19,141 @@ 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'
|
||||
checksums = pathlib.Path(tmp) / 'SHA256SUMS'
|
||||
archive.touch()
|
||||
checksums.touch()
|
||||
with patch.object(bootstrap.os, 'uname', return_value=SimpleNamespace(machine='aarch64')), \
|
||||
patch.object(bootstrap.os, 'geteuid', return_value=0), \
|
||||
patch.object(bootstrap, 'confirm', side_effect=AssertionError('unexpected prompt')), \
|
||||
patch.object(bootstrap, 'verify_archive'), \
|
||||
patch.object(bootstrap, 'root_operation') as operation:
|
||||
for action in ('install', 'update'):
|
||||
bootstrap.main([action, '--archive', str(archive), '--checksums', str(checksums)])
|
||||
self.assertEqual(operation.call_count, 2)
|
||||
|
||||
def test_github_proxy_option_reaches_engine_downloads(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
archive = pathlib.Path(tmp) / 'package.tar.gz'
|
||||
checksums = pathlib.Path(tmp) / 'SHA256SUMS'
|
||||
archive.touch()
|
||||
checksums.touch()
|
||||
with patch.dict(bootstrap.os.environ, {}, clear=True), \
|
||||
patch.object(bootstrap.os, 'uname', return_value=SimpleNamespace(machine='aarch64')), \
|
||||
patch.object(bootstrap.os, 'geteuid', return_value=0), \
|
||||
patch.object(bootstrap, 'verify_archive'), \
|
||||
patch.object(bootstrap, 'root_operation'):
|
||||
bootstrap.main(['install', '--archive', str(archive), '--checksums', str(checksums),
|
||||
'--github-proxy', 'https://gh-proxy.com/'])
|
||||
self.assertEqual(bootstrap.configured_github_proxy(), 'https://gh-proxy.com')
|
||||
|
||||
def test_explicit_uninstall_still_requires_confirmation(self):
|
||||
with patch.object(bootstrap.os, 'uname', return_value=SimpleNamespace(machine='aarch64')), \
|
||||
patch.object(bootstrap, 'confirm', return_value=False) as confirmation:
|
||||
with self.assertRaisesRegex(ValueError, 'Cancelled'):
|
||||
bootstrap.main(['uninstall'])
|
||||
confirmation.assert_called_once()
|
||||
|
||||
def test_confirmation_distinguishes_no_terminal_from_decline(self):
|
||||
with patch('builtins.open', side_effect=OSError('no tty')):
|
||||
with self.assertRaisesRegex(ValueError, 'No interactive terminal.*--yes'):
|
||||
bootstrap.confirm('install')
|
||||
with patch('builtins.open', mock_open(read_data='')):
|
||||
with self.assertRaisesRegex(ValueError, 'No confirmation input.*--yes'):
|
||||
bootstrap.confirm('install')
|
||||
with patch('builtins.open', mock_open(read_data='n\n')):
|
||||
self.assertFalse(bootstrap.confirm('install'))
|
||||
with patch('builtins.open', mock_open(read_data='Y\n')):
|
||||
self.assertTrue(bootstrap.confirm('install'))
|
||||
|
||||
def test_external_checksums_match_exact_name_and_reject_tampering(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
@@ -85,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()
|
||||
@@ -21,6 +21,23 @@ spec.loader.exec_module(network)
|
||||
|
||||
|
||||
class FallbackTests(unittest.TestCase):
|
||||
def test_explicit_proxy_routes_github_requests_without_second_proxy(self):
|
||||
class Opener:
|
||||
def open(self, request, timeout):
|
||||
return request.full_url
|
||||
with patch.dict(os.environ, {'FRAMELY_GITHUB_PROXY':'https://gh-proxy.com/'}, clear=False), \
|
||||
patch.object(network.urllib.request, 'build_opener', return_value=Opener()):
|
||||
self.assertEqual(network.fetch_once('https://api.github.com/repos/a/b/releases/latest'),
|
||||
'https://gh-proxy.com/https://api.github.com/repos/a/b/releases/latest')
|
||||
self.assertEqual(network.fetch_once('https://example.org/file'), 'https://example.org/file')
|
||||
calls = []
|
||||
with self.assertRaises(ConnectionResetError):
|
||||
network.github_attempt('https://github.com/a', lambda fallback: calls.append(fallback) or (_ for _ in ()).throw(ConnectionResetError()))
|
||||
self.assertEqual(calls, [False])
|
||||
for invalid in ('http://gh-proxy.com', 'https://gh-proxy.com/../bad', 'https://gh-proxy.com?x=1'):
|
||||
with self.assertRaises(ValueError):
|
||||
network.github_proxy_url(invalid)
|
||||
|
||||
def test_unreachable_network_retries_once_but_local_io_errors_do_not(self):
|
||||
url = 'https://github.com/a/b/releases/download/v1/archive'
|
||||
for code in (errno.ENETDOWN, errno.ENETUNREACH, errno.EHOSTUNREACH):
|
||||
@@ -125,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__':
|
||||
|
||||
@@ -27,6 +27,27 @@ class InstallEntryTests(unittest.TestCase):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(calls, {})
|
||||
|
||||
def test_explicit_github_proxy_covers_bootstrap_and_engine(self):
|
||||
args = ['install', '--github-proxy', 'https://gh-proxy.com/']
|
||||
result, calls = self.run_entry(args)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(calls['url'], 'https://gh-proxy.com/https://github.com/TestOwner/framely/releases/latest/download/bootstrap.py')
|
||||
self.assertEqual(calls['proxy'], 'https://gh-proxy.com')
|
||||
self.assertEqual(calls['args'], ['--repo', 'TestOwner/framely', *args])
|
||||
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)
|
||||
@@ -35,12 +56,13 @@ class InstallEntryTests(unittest.TestCase):
|
||||
curl.write_text('''#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys
|
||||
args = sys.argv[1:]
|
||||
pathlib.Path(os.environ['FRAMELY_ENTRY_LOG']).write_text(json.dumps({'url': args[-1]}))
|
||||
pathlib.Path(os.environ['FRAMELY_ENTRY_LOG']).write_text(json.dumps({'url': args[-1], 'proxy': os.environ.get('FRAMELY_GITHUB_PROXY', ''), '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)
|
||||
env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'],
|
||||
FRAMELY_REPOSITORY='TestOwner/framely', FRAMELY_ENTRY_LOG=str(log))
|
||||
env.pop('FRAMELY_GITHUB_PROXY', None)
|
||||
result = subprocess.run(['bash', str(pathlib.Path(__file__).parents[1] / 'install.sh'), *args],
|
||||
env=env, text=True, capture_output=True, timeout=10)
|
||||
return result, json.loads(log.read_text()) if log.exists() else {}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,37 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"steam_system_menu",
|
||||
Path(__file__).parents[1] / "native/input/steam-system-menu.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
class BindingTests(unittest.TestCase):
|
||||
def test_removes_only_system_long_recenter(self):
|
||||
binding = {
|
||||
"bindings": {
|
||||
"/actions/system": {"sources": [{
|
||||
"path": "/user/hand/left/input/system",
|
||||
"inputs": {"click": {"output": "dashboard"}, "double": {"output": "room"}},
|
||||
}]},
|
||||
"/actions/quickrecenter": {"sources": [
|
||||
{"path": "/user/hand/left/input/system", "inputs": {"long": {"output": "recenter"}}},
|
||||
{"path": "/user/hand/right/input/system", "inputs": {"long": {"output": "recenter"}}},
|
||||
{"path": "/user/hand/left/input/grip", "inputs": {"long": {"output": "recenter"}}},
|
||||
]},
|
||||
}
|
||||
}
|
||||
patched, changed = module.without_recenter(binding)
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(len(patched["bindings"]["/actions/quickrecenter"]["sources"]), 1)
|
||||
self.assertEqual(patched["bindings"]["/actions/system"], binding["bindings"]["/actions/system"])
|
||||
self.assertEqual(len(binding["bindings"]["/actions/quickrecenter"]["sources"]), 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,11 +14,11 @@ static int loads=0, writes=0, streams=0, cleared=0;
|
||||
extern "C" {
|
||||
bool SDL_InitSubSystem(uint32_t flags){assert(flags==0x10);return true;}
|
||||
void SDL_QuitSubSystem(uint32_t flags){assert(flags==0x10);assert(streams==0);}
|
||||
bool SDL_LoadWAV(const char* path,void* spec,uint8_t** data,uint32_t* len){assert(strstr(path,"/steamui/sounds/deck_ui_typing.wav"));++loads;*data=(uint8_t*)malloc(4);*len=4;return true;}
|
||||
bool SDL_LoadWAV(const char* path,void* spec,uint8_t** data,uint32_t* len){assert(strstr(path,"/steamui/sounds/deck_ui_typing.wav")||strstr(path,"/steamui/sounds/camera1.wav"));++loads;*data=(uint8_t*)malloc(4);*len=4;return true;}
|
||||
void* SDL_OpenAudioDeviceStream(uint32_t id,const void*,void* callback,void* userdata){assert(id==0xffffffffu&&!callback&&!userdata);++streams;return malloc(1);}
|
||||
bool SDL_SetAudioStreamGain(void*,float gain){assert(gain==.30f||gain==.45f);return true;}
|
||||
bool SDL_SetAudioStreamGain(void*,float gain){assert(gain==.30f||gain==.45f||gain==.55f);return true;}
|
||||
bool SDL_ResumeAudioStreamDevice(void*){return true;}
|
||||
bool SDL_PutAudioStreamData(void*,const void*,int len){assert(loads==2&&len==4);++writes;return true;}
|
||||
bool SDL_PutAudioStreamData(void*,const void*,int len){assert(loads==3&&len==4);++writes;return true;}
|
||||
bool SDL_ClearAudioStream(void*){++cleared;return true;}
|
||||
void SDL_DestroyAudioStream(void* stream){--streams;free(stream);}
|
||||
void SDL_free(void* data){free(data);}
|
||||
@@ -36,9 +36,9 @@ int main(int argc,char** argv){
|
||||
assert(sounds.prepare(argv[1]));
|
||||
auto lib=dlopen((std::string(argv[1])+"/steamrtarm64/libSDL3.so.0").c_str(),RTLD_NOW);
|
||||
auto counts=reinterpret_cast<int(*)(int)>(dlsym(lib,"counts"));
|
||||
sounds.play();sounds.play(true);sounds.silence();
|
||||
assert(counts(0)==2&&counts(1)==2&&counts(2)==6);
|
||||
sounds.shutdown();sounds.play();assert(counts(1)==2);dlclose(lib);
|
||||
sounds.play();sounds.play(true);sounds.screenshot();sounds.silence();
|
||||
assert(counts(0)==3&&counts(1)==3&&counts(2)==7);
|
||||
sounds.shutdown();sounds.play();assert(counts(1)==3);dlclose(lib);
|
||||
}
|
||||
''')
|
||||
subprocess.run(['g++','-std=c++17','-I'+str(root/'native'),str(test),'-ldl','-o',str(tmp/'test')],check=True)
|
||||
|
||||
+334
-26
@@ -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':
|
||||
@@ -57,6 +172,35 @@ def fallback_enabled():
|
||||
return device_proxy_settings().get('githubAutoFallback', True)
|
||||
|
||||
|
||||
def github_proxy_url(value):
|
||||
value = value.strip().rstrip('/')
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if (not value or any(ord(char) <= 32 for char in value)
|
||||
or parsed.scheme != 'https' or not parsed.hostname
|
||||
or parsed.username or parsed.password or parsed.query or parsed.fragment
|
||||
or '..' in parsed.path.split('/')):
|
||||
raise ValueError('GitHub proxy must be an HTTPS base URL')
|
||||
try:
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError('Invalid GitHub proxy port') from error
|
||||
return value
|
||||
|
||||
|
||||
def configured_github_proxy():
|
||||
settings = device_proxy_settings()
|
||||
value = os.environ.get('FRAMELY_GITHUB_PROXY', settings.get('github', '') if settings.get('githubEnabled') else '')
|
||||
return github_proxy_url(value) if value.strip() else ''
|
||||
|
||||
|
||||
def 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
|
||||
@@ -95,9 +239,9 @@ def github_attempt(url, operation, enabled=None):
|
||||
try:
|
||||
return operation(False)
|
||||
except Exception as primary:
|
||||
if not (fallback_enabled() if enabled is None else enabled) or not public_github(url) or not retryable(primary):
|
||||
if configured_github_proxy() or not (fallback_enabled() if enabled is None else enabled) or not public_github(url) or not retryable(primary):
|
||||
raise
|
||||
print('GitHub connection failed; trying gh-proxy.com once', file=sys.stderr, flush=True)
|
||||
log_failure(f'GitHub 请求失败({network_diagnostic(primary)}),正在尝试 gh-proxy.com')
|
||||
try:
|
||||
return operation(True)
|
||||
except Exception as secondary:
|
||||
@@ -109,12 +253,10 @@ def fetch_once(url, fallback=False):
|
||||
if urllib.parse.urlparse(url).scheme != 'https':
|
||||
raise ValueError('HTTPS is required')
|
||||
settings = device_proxy_settings()
|
||||
manual = os.environ.get('FRAMELY_GITHUB_PROXY', settings.get('github', '') if settings.get('githubEnabled') else '').strip().rstrip('/')
|
||||
manual = configured_github_proxy()
|
||||
if fallback:
|
||||
url = GITHUB_FALLBACK + url
|
||||
elif manual and public_github(url):
|
||||
if urllib.parse.urlsplit(manual).scheme != 'https':
|
||||
raise ValueError('GitHub proxy must use HTTPS')
|
||||
url = manual + '/' + url
|
||||
handlers = [HTTPSRedirect()]
|
||||
if 'FRAMELY_HTTP_PROXY' in os.environ or settings.get('httpEnabled'):
|
||||
@@ -262,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():
|
||||
@@ -280,9 +518,12 @@ def confirm(message):
|
||||
with open('/dev/tty', 'r+') as terminal:
|
||||
terminal.write(message + ' [y/N]: ')
|
||||
terminal.flush()
|
||||
return terminal.readline().strip().lower() == 'y'
|
||||
except OSError:
|
||||
return False
|
||||
answer = terminal.readline()
|
||||
except OSError as error:
|
||||
raise ValueError('No interactive terminal for confirmation; run in a terminal or pass --yes') from error
|
||||
if not answer:
|
||||
raise ValueError('No confirmation input; run in a terminal or pass --yes')
|
||||
return answer.strip().lower() == 'y'
|
||||
|
||||
|
||||
def root_operation(args):
|
||||
@@ -312,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')
|
||||
@@ -324,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')
|
||||
@@ -348,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)
|
||||
@@ -363,14 +606,32 @@ def main(argv=None):
|
||||
parser.add_argument('--checksums', type=pathlib.Path, help='External SHA256SUMS for the archive')
|
||||
parser.add_argument('--user', default=os.environ.get('SUDO_USER', 'steamos'))
|
||||
parser.add_argument('--yes', action='store_true', help='Confirm the selected operation')
|
||||
parser.add_argument('--github-proxy', help='HTTPS URL prefix for GitHub downloads, such as https://gh-proxy.com')
|
||||
parser.add_argument('--progress-json', action='store_true', help=argparse.SUPPRESS)
|
||||
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
|
||||
if args.action is None:
|
||||
with open('/dev/tty', 'r+') as terminal:
|
||||
terminal.write('1 Install 2 Update 3 Repair 4 Rollback 5 Uninstall\nChoose [1-5]: ')
|
||||
@@ -379,8 +640,10 @@ def main(argv=None):
|
||||
args.action = choices.get(terminal.readline().strip())
|
||||
if args.action is None:
|
||||
parser.error('Invalid action')
|
||||
if not args.yes and not confirm(f'{args.action}: ' + ('disable and uninstall ALL plugins, then remove Framely (saved data retained)' if args.action == 'uninstall' else 'continue with Framely maintenance')):
|
||||
if not args.yes and not (explicit_action and args.action in ('install', 'update')) and not confirm(f'{args.action}: ' + ('disable and uninstall ALL plugins, then remove Framely (saved data retained)' if args.action == 'uninstall' else 'continue with Framely maintenance')):
|
||||
raise ValueError('Cancelled')
|
||||
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)
|
||||
@@ -389,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)
|
||||
@@ -412,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)
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$base"
|
||||
mkdir -p target/tests
|
||||
for test in gamepad_mapping gamepad_steam gamepad_direct entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority recording_indicator recording_region framing_lifecycle recording_region_depth; do
|
||||
for test in gamepad_mapping gamepad_steam gamepad_direct entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority recording_indicator recording_region framing_lifecycle recording_region_depth system_radial; do
|
||||
"${CXX:-g++}" -std=c++17 -O2 -pthread -Inative -Inative/vendor/openvr "tests/$test.cpp" -ldl -o "target/tests/$test"
|
||||
"target/tests/$test"
|
||||
done
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
+3
-2
@@ -2,8 +2,9 @@ import {t} from './i18n';
|
||||
import {installKeyboard} from '../../sdk/src/keyboard';
|
||||
import {installHoverFeedback} from '../../sdk/src/hover';
|
||||
export async function api<T=any>(method:string,params:unknown={},signal?:AbortSignal):Promise<T>{const r=await fetch('/api',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({method,params}),signal});if(r.status===401&&r.headers.get('X-Framely-Reauthenticate')==='1')location.replace('/manager');if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));return v.result;}
|
||||
export function viewKey(){const p=location.pathname.split('/');return p[1]==='cast'&&p[2]==='framing'?'framely.framing':p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='cast'&&p[2]==='window'?`framely.cast.${p[3]}`:p[1]==='apk'&&p[2]==='permission'?'framely.apk.permission':p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';}
|
||||
installKeyboard(params=>api('host.keyboard',{view:viewKey(),...params}));
|
||||
export function viewKey(){const p=location.pathname.split('/');return p[1]==='cast'&&p[2]==='framing'?'framely.framing':p[1]==='cast'&&p[2]==='screenshot'?'framely.screenshot':p[1]==='manager'?'framely.manager':p[1]==='window'?`framely.window.${p[2]}.${p[3]}`:p[1]==='cast'&&p[2]==='window'?`framely.cast.${p[3]}`:p[1]==='apk'&&p[2]==='permission'?'framely.apk.permission':p[1]==='notifications'?'notifications':p[1]==='launcher'?'launcher':'menu';}
|
||||
export const nativeKeyboardHost=['localhost','127.0.0.1','[::1]'].includes(location.hostname);
|
||||
installKeyboard(params=>nativeKeyboardHost?api('host.keyboard',{view:viewKey(),...params}):Promise.resolve(),(method,params)=>nativeKeyboardHost?api('plugin.call',{plugin:'tooru.frame-keyboard',method,params:{...params,view:viewKey()}}):Promise.resolve({ok:false}),{requestOnFocus:viewKey()!=='launcher'});
|
||||
|
||||
|
||||
installHoverFeedback(feedback=>{void api('host.haptic',{view:viewKey(),feedback}).catch(console.error);});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {t} from './i18n';
|
||||
import {FrameGesture,fitMinimumFrame,minimumFrameWidth,resizeFrameCentered,type MinimumFrame} from './cast-framing-gesture';
|
||||
import type {FrameLimiter} from './cast-region';
|
||||
|
||||
export function FramingEditor({settings,view,onChange,onError,spatial=false,minimumSize={width:0,height:0},limit,moveHandle}:{settings:CastSettings;view:ReferenceView|null;onChange:(patch:Partial<CastSettings>)=>void;onError:(error:string)=>void;spatial?:boolean;minimumSize?:MinimumFrame;limit?:FrameLimiter;moveHandle?:React.RefObject<HTMLElement|null>}){
|
||||
export function FramingEditor({settings,view,onChange,onError,spatial=false,previewUrl,minimumSize={width:0,height:0},limit,moveHandle}:{settings:CastSettings;view:ReferenceView|null;onChange:(patch:Partial<CastSettings>)=>void;onError:(error:string)=>void;spatial?:boolean;previewUrl?:string;minimumSize?:MinimumFrame;limit?:FrameLimiter;moveHandle?:React.RefObject<HTMLElement|null>}){
|
||||
const stage=useRef<HTMLDivElement>(null),box=useRef<HTMLDivElement>(null),zoom=useRef<HTMLInputElement>(null),readout=useRef<HTMLOutputElement>(null);
|
||||
const current=useRef<FrameRect>({x:0,y:0,width:1,height:1}),gesture=useRef(new FrameGesture()),pointer=useRef<number|null>(null),release=useRef<(id?:number)=>void>(()=>{});
|
||||
const capture=useRef<HTMLElement|null>(null),external=useRef({down:(_e:PointerEvent)=>{},move:(_e:PointerEvent)=>{}});
|
||||
@@ -41,8 +41,8 @@ export function FramingEditor({settings,view,onChange,onError,spatial=false,mini
|
||||
function end(e:React.PointerEvent<HTMLDivElement>){finish(e.pointerId);}
|
||||
function keyboard(e:React.KeyboardEvent<HTMLDivElement>){if(!view)return;const delta=e.shiftKey?.05:.01,r=current.current;const changes:Record<string,Partial<FrameRect>>={ArrowLeft:{x:r.x-delta},ArrowRight:{x:r.x+delta},ArrowUp:{y:r.y-delta},ArrowDown:{y:r.y+delta}};if(changes[e.key]){e.preventDefault();draw(fitFrame({...r,...changes[e.key]},aspect,view.aspect));commit();}}
|
||||
return <div className="cast-framing-editor">
|
||||
<div className="cast-framing-stage" ref={stage}>
|
||||
{!spatial&&<CastVideo active controls={false} aspectRatio={1} onError={onError}/>}
|
||||
<div className="cast-framing-stage" ref={stage} style={previewUrl&&view?{aspectRatio:String(view.aspect)}:undefined}>
|
||||
{previewUrl?<img src={previewUrl} alt={t('截图取景预览')} draggable={false} className="cast-screenshot-preview-image"/>:!spatial&&<CastVideo active controls={false} aspectRatio={1} onError={onError}/>}
|
||||
{view&&<div ref={box} className="cast-crop-box" tabIndex={0} role="group" aria-label={t('取景框,使用方向键移动')} onKeyDown={keyboard} onPointerDown={down} onPointerMove={move} onPointerUp={end} onPointerCancel={end} onLostPointerCapture={end}>
|
||||
<div className="cast-crop-grid" aria-hidden="true"/>
|
||||
{['nw','ne','sw','se'].map(corner=><span key={corner} data-corner={corner} data-framely-interactive="" className={`cast-crop-handle ${corner}`} aria-hidden="true"/>)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {IconCast,IconPlayerPlay,IconPlayerStop,IconArrowUpRight,IconDeviceVisionPro,IconCrop} from '@tabler/icons-react';
|
||||
import {IconCast,IconPlayerPlay,IconPlayerStop,IconArrowUpRight,IconDeviceVisionPro,IconCrop,IconCamera} from '@tabler/icons-react';
|
||||
import {api} from './api';
|
||||
import {t} from './i18n';
|
||||
import {Switch,SwitchRow} from './switch';
|
||||
@@ -17,7 +17,7 @@ export function CastQuick({config,refresh}:{config:CastSettings;refresh:()=>Prom
|
||||
<div className={`cast-quick-stream${running?' is-live':''}`} aria-busy={!runtime}>
|
||||
<div className="cast-quick-source"><div><h2>{t('头显画面')}</h2><span className="cast-quick-state" role="status">{!runtime?t('正在连接'):running?t('正在串流'):t('未开始串流')}</span></div><IconDeviceVisionPro size={36} stroke={1.5}/></div>
|
||||
<p className={`cast-quick-specs${!runtime?' is-loading':''}`} title={t('投屏设置')}><span>{config.width} × {config.height}</span><span>{config.codec==='h265'?'H.265':'H.264'}</span><span>{config.fps} FPS</span></p>
|
||||
<div className="cast-stream-actions"><button className={running?'':'primary'} disabled={busy||!runtime} onClick={()=>void run(()=>api(running?'cast.stop':'cast.start'))}>{running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void run(()=>api(runtime?.recording?.active?'cast.record.stop':'cast.record.start'))}/></div>
|
||||
<div className="cast-stream-actions"><button className={running?'':'primary'} disabled={busy||!runtime} onClick={()=>void run(()=>api(running?'cast.stop':'cast.start'))}>{running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void run(()=>api(runtime?.recording?.active?'cast.record.stop':'cast.record.start'))}/><button disabled={busy||!runtime} onClick={()=>void run(()=>api('cast.screenshot.open'))}><IconCamera size={21}/>{t('截图')}</button></div>
|
||||
</div>
|
||||
{runtime?.recording?.error&&<p className="error" role="alert">{runtime.recording.error}</p>}
|
||||
<div className="cast-quick-framing"><div className="cast-quick-framing-row"><button disabled={busy||!runtime||!!runtime.recording?.active||!!runtime.preview||!!runtime.recordingRegion?.editing||config.source!=='screen'||config.output!=='eye'} onClick={()=>void run(()=>api('cast.framing.open'))}><IconCrop size={21}/>{t('画幅调整')}</button>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.cast-screenshot-spatial .cast-framing-stage{position:absolute;inset:0;width:100%;height:100%;max-width:none}
|
||||
.cast-screenshot-flat{min-height:100dvh;display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:24px;align-items:center;padding:26px;background:#111315;color:#f2f5f7}
|
||||
.cast-screenshot-flat-stage{min-width:0;display:grid;place-items:center;max-height:calc(100dvh - 52px);overflow:hidden}
|
||||
.cast-screenshot-flat-stage>img{display:none}
|
||||
.cast-screenshot-flat-stage .cast-framing-editor{width:100%;max-width:min(100%,1100px)}
|
||||
.cast-screenshot-flat-stage .cast-framing-stage{max-width:100%;max-height:calc(100dvh - 180px)}
|
||||
.cast-screenshot-preview-image{width:100%;height:100%;object-fit:fill;display:block}
|
||||
.cast-screenshot-flat-controls{padding:24px;border:1px solid #333b42;border-radius:14px;background:#1d2226;box-shadow:0 20px 50px #0005}
|
||||
.cast-screenshot-flat-controls h1{font-size:24px;margin:0 0 12px}.cast-screenshot-flat-controls p{color:#b8c5cd;font-size:14px;line-height:1.6}
|
||||
.cast-screenshot-flat-controls footer{display:flex;gap:10px;margin-top:24px}.cast-screenshot-flat-controls footer button{flex:1}
|
||||
@media(max-width:760px){.cast-screenshot-flat{grid-template-columns:1fr;padding:16px;gap:8px}.cast-screenshot-flat-stage .cast-framing-stage{max-height:56dvh}.cast-screenshot-flat-controls{padding:16px}}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React,{useEffect,useMemo,useRef,useState} from 'react';
|
||||
import {IconArrowsMove,IconCamera} from '@tabler/icons-react';
|
||||
import {api} from './api';
|
||||
import {t} from './i18n';
|
||||
import {Select} from './localized-select';
|
||||
import {FramingEditor} from './cast-framing-editor';
|
||||
import {frameFromSettings,frameRatios,type ReferenceView} from './cast-framing';
|
||||
import {normalizeRegion} from './cast-region';
|
||||
import {FramingLimits,framingMenuScale,type VisibilityGrid} from './cast-framing-limits';
|
||||
import type {CastSettings} from './casting';
|
||||
import './cast-spatial-framing.css';
|
||||
import './cast-screenshot.css';
|
||||
|
||||
type Editor={id:string;settings:CastSettings;geometry:null|{referenceTan:number;view:ReferenceView&{visibility:VisibilityGrid;eye:0|1}};spatial:boolean;preview:string|null;previewAspect:number|null};
|
||||
export function ScreenshotFraming(){
|
||||
const [editor,setEditor]=useState<Editor|null>(null),[draft,setDraft]=useState<CastSettings|null>(null),[sourceAspect,setSourceAspect]=useState(1),[error,setError]=useState(''),[busy,setBusy]=useState(false),[closed,setClosed]=useState(false);
|
||||
const root=useRef<HTMLElement>(null),menu=useRef<HTMLElement>(null),moveHandle=useRef<HTMLButtonElement>(null),finishing=useRef(false),[minimum,setMinimum]=useState({width:0,height:0,referenceTan:0});
|
||||
useEffect(()=>{let alive=true;void api<Editor>('cast.screenshot.get').then(value=>{if(!alive)return;setEditor(value);if(value.previewAspect)setSourceAspect(value.previewAspect);setDraft(value.spatial&&value.geometry?{...value.settings,horizontalFov:2*Math.atan(value.geometry.view.tanHalfHorizontal)*180/Math.PI,centerX:Math.atan(value.geometry.view.centerTanX)*180/Math.PI,centerY:Math.atan(value.geometry.view.centerTanY)*180/Math.PI}:{...value.settings,width:1920,height:Math.round(1920/(value.previewAspect??1)),horizontalFov:null,centerX:0,centerY:0});}).catch(e=>{if(alive)setError(String(e.message??e));});return()=>{alive=false;};},[]);
|
||||
useEffect(()=>{const measure=()=>{const area=root.current?.getBoundingClientRect(),panel=menu.current?.getBoundingClientRect();if(area?.width&&area.height&&panel)setMinimum({width:(panel.width+32)/area.width,height:(panel.height+32)/area.height,referenceTan:editor?.geometry?.referenceTan??1});};measure();const observer=typeof ResizeObserver==='undefined'?null:new ResizeObserver(measure);if(menu.current)observer?.observe(menu.current);if(root.current)observer?.observe(root.current);window.addEventListener('resize',measure);return()=>{observer?.disconnect();window.removeEventListener('resize',measure);};},[editor?.id]);
|
||||
const reference:ReferenceView=editor?.spatial?{tanHalfHorizontal:editor.geometry?.referenceTan??1,centerTanX:0,centerTanY:0,aspect:1}:{tanHalfHorizontal:1,centerTanX:0,centerTanY:0,aspect:sourceAspect};
|
||||
const limits=useMemo(()=>editor?.spatial&&editor.geometry?new FramingLimits(editor.geometry.view.visibility,editor.geometry.referenceTan):null,[editor]);
|
||||
const limit=useMemo(()=>limits?.fit.bind(limits),[limits]);
|
||||
const ready=!!editor&&!!draft&&(!editor.spatial||minimum.referenceTan===editor.geometry?.referenceTan);
|
||||
function change(patch:Partial<CastSettings>){if(!draft||!editor||busy||closed)return;try{setDraft(editor.spatial?normalizeRegion({...draft,...patch},reference,minimum,limit,draft):{...draft,...patch});setError('');}catch(e){setError(t((e as Error).message));}}
|
||||
function close(){setClosed(true);setEditor(null);setDraft(null);}
|
||||
async function finish(capture:boolean){if(!editor||!draft||finishing.current)return;finishing.current=true;setBusy(true);setError('');try{
|
||||
let params:Record<string,unknown>={id:editor.id};
|
||||
if(capture){if(editor.spatial){const valid=normalizeRegion(draft,reference,minimum,limit,draft);params={...params,aspect:valid.width/valid.height,horizontalFov:valid.horizontalFov,centerX:valid.centerX,centerY:valid.centerY};}
|
||||
else{const frame=frameFromSettings(draft,draft.width/draft.height,reference);params={...params,crop:frame};}}
|
||||
await api(capture?'cast.screenshot.capture':'cast.screenshot.cancel',params);close();
|
||||
}catch(e){setError(String((e as Error).message??e));try{await api('cast.screenshot.get');}catch{close();}}finally{setBusy(false);finishing.current=false;}}
|
||||
useEffect(()=>{const back=()=>void finish(false);window.addEventListener('framely.back',back);return()=>window.removeEventListener('framely.back',back);},[editor,draft]);
|
||||
if(closed)return null;
|
||||
const aspect=draft?draft.width/draft.height:1;
|
||||
return <main ref={root} className={editor?.spatial?'cast-native-framing cast-screenshot-spatial':'cast-screenshot-flat'} aria-label={t('截图取景')} inert={busy}>
|
||||
{ready&&draft&&editor?.spatial&&<FramingEditor spatial moveHandle={moveHandle} settings={draft} view={reference} minimumSize={minimum} limit={limit} onChange={change} onError={setError}/>}
|
||||
{ready&&draft&&!editor?.spatial&&<div className="cast-screenshot-flat-stage"><img src={editor?.preview??''} alt={t('截图画面')} onLoad={event=>{const img=event.currentTarget;if(img.naturalWidth&&img.naturalHeight)setSourceAspect(img.naturalWidth/img.naturalHeight);}}/><FramingEditor key={sourceAspect} settings={draft} view={reference} previewUrl={editor?.preview??undefined} onChange={change} onError={setError}/></div>}
|
||||
<section ref={menu} className={editor?.spatial?'cast-native-framing-controls':'cast-screenshot-flat-controls'} style={editor?.spatial?{'--framing-menu-scale':framingMenuScale(reference.tanHalfHorizontal)} as React.CSSProperties:undefined}>
|
||||
<h1>{editor?.spatial?<button ref={moveHandle} className="cast-framing-move" disabled={!ready||busy}>{t('截图取景')}<IconArrowsMove size={22}/></button>:t('截图取景')}</h1>
|
||||
<p>{t('拖动取景框移动画面,拖动四角调整范围。截图使用原始可用像素。')}</p>
|
||||
{draft&&<div className="cast-native-framing-field"><span>{t('画面比例')}</span><Select label={t('画面比例')} value={String(frameRatios.find(r=>Math.abs(r.value-aspect)<.004)?.value??aspect)} disabled={busy} onChange={value=>{const ratio=Number(value),edge=Math.max(draft.width,draft.height);change(ratio>=1?{width:edge,height:Math.round(edge/ratio)}:{width:Math.round(edge*ratio),height:edge});}} options={[...(!frameRatios.some(r=>Math.abs(r.value-aspect)<.004)?[{value:String(aspect),label:t('当前比例')}]:[]),...frameRatios.map(r=>({value:String(r.value),label:r.label}))]}/></div>}
|
||||
{error&&<p role="alert" className="error">{error}</p>}
|
||||
<footer><button disabled={busy||!editor} onClick={()=>void finish(false)}>{t('取消')}</button><button className="primary" disabled={busy||!ready} onClick={()=>void finish(true)}><IconCamera size={19}/>{t('截图')}</button></footer>
|
||||
</section>
|
||||
</main>;
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {IconPlayerPlay,IconPlayerStop,IconExternalLink,IconCopy,IconCheck,IconDeviceVisionPro,IconDeviceDesktop,IconCrop,IconEye,IconDeviceTv,IconRefresh,IconCast,IconMusic,IconVideo,IconWifi,IconArrowRight} from '@tabler/icons-react';
|
||||
import {IconPlayerPlay,IconPlayerStop,IconExternalLink,IconCopy,IconCheck,IconDeviceVisionPro,IconDeviceDesktop,IconCrop,IconEye,IconDeviceTv,IconRefresh,IconCast,IconMusic,IconVideo,IconWifi,IconArrowRight,IconCamera} from '@tabler/icons-react';
|
||||
import {type NetworkPanel} from './network';
|
||||
import {api} from './api';
|
||||
import {Switch,SwitchRow} from './switch';
|
||||
@@ -49,7 +49,7 @@ export function Casting({config,refresh,network,page='casting'}:{config:CastSett
|
||||
{errorMessage&&<div className="cast-error" role="alert"><strong>{t('操作未完成')}</strong><p>{errorMessage}</p></div>}
|
||||
{page==='casting'&&<>
|
||||
<div className="cast-monitor"><CastVideo key={epoch} active={!!runtime?.running&&!runtime.preview} onError={setError} onStreamInfo={setStreamInfo}/>{!runtime?.running&&<div className="cast-monitor-empty"><IconDeviceVisionPro size={44} stroke={1.5}/><h2>{t('分享你看到的画面')}</h2><p>{t('启动后,手机和电脑可以通过网页实时观看。')}</p></div>}</div>
|
||||
<div className="cast-stream-bar"><div><strong>{t(config.source==='screen'?'屏幕采集':'SteamVR')}</strong><span>{config.width} × {config.height}<i/> {config.fps} fps</span></div><div className="cast-stream-actions"><button className={runtime?.running?'':'primary'} disabled={busy||!runtime} onClick={()=>void action('start',async()=>{if(runtime?.running)await api('cast.stop');else{if(dirty)await apply();await api('cast.start');}setEpoch(v=>v+1);})}>{runtime?.running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{pending==='start'?t('正在启动…'):runtime?.running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void action('record',async()=>{if(!runtime?.recording?.active&&!runtime?.running&&dirty)await apply();await api(runtime?.recording?.active?'cast.record.stop':'cast.record.start');setEpoch(v=>v+1);})}/></div></div>
|
||||
<div className="cast-stream-bar"><div><strong>{t(config.source==='screen'?'屏幕采集':'SteamVR')}</strong><span>{config.width} × {config.height}<i/> {config.fps} fps</span></div><div className="cast-stream-actions"><button className={runtime?.running?'':'primary'} disabled={busy||!runtime} onClick={()=>void action('start',async()=>{if(runtime?.running)await api('cast.stop');else{if(dirty)await apply();await api('cast.start');}setEpoch(v=>v+1);})}>{runtime?.running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{pending==='start'?t('正在启动…'):runtime?.running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void action('record',async()=>{if(!runtime?.recording?.active&&!runtime?.running&&dirty)await apply();await api(runtime?.recording?.active?'cast.record.stop':'cast.record.start');setEpoch(v=>v+1);})}/><button disabled={busy||!runtime} onClick={()=>void action('screenshot',()=>api('cast.screenshot.open'))}><IconCamera size={21}/>{t('截图')}</button></div></div>
|
||||
<SwitchRow label={t('显示录制指示器')} description={t('关闭后仅隐藏右上角录制提示,不会停止录制。')} checked={config.recordingIndicatorVisible??true} disabled={busy} onChange={enabled=>void action('indicator',async()=>{await api('cast.recordingIndicator.visible.save',{enabled});await refresh();})}/><p className="cast-record-path">{t('录制保存到 /home/steamos/Videos/framely')}{runtime?.recording?.path&&<> · <span>{runtime.recording.path.split('/').pop()}</span></>}</p>
|
||||
{runtime?.recording?.error&&<p className="error" role="alert">{runtime.recording.error}</p>}
|
||||
<section className="cast-share"><div><IconWifi size={24}/><div><h2>{t('在其他设备上观看')}</h2><p>{t('连接同一网络,打开观看地址。')}</p></div></div><SwitchRow label={t('免验证')} checked={runtime?.watchAccessEnabled??false} disabled={busy||!runtime} onChange={enabled=>void action('watch-access',async()=>{await api('cast.watchAccess.save',{enabled});setCopied(false);})}/><p>{t('持有此链接的人无需密码即可观看。')}</p>{runtime&&<div className="cast-share-link"><code>{watchAddress}</code><button disabled={busy} onClick={()=>void action('copy',copy)} aria-label={t('复制观看地址')}>{copied?<IconCheck size={20}/>:<IconCopy size={20}/>}<span>{copied?t('已复制'):t('复制')}</span></button><a className="cast-icon-link" href={watchAddress} aria-label={t('打开观看网页')}><IconExternalLink size={21}/></a></div>}</section>
|
||||
|
||||
+3
-3
@@ -12,8 +12,8 @@ import './launcher.css';
|
||||
import {installLauncherGaze} from './launcher-gaze';
|
||||
import {LauncherCalibration} from './launcher-calibration';
|
||||
import {matchesSearch,type SearchIndex} from './launcher-search';
|
||||
export type LauncherConfig={prioritizeUi?:boolean;primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
|
||||
const defaults:LauncherConfig={prioritizeUi:true,primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
|
||||
export type LauncherConfig={prioritizeUi?:boolean;systemRadialMenu?:boolean;primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]};
|
||||
const defaults:LauncherConfig={prioritizeUi:true,systemRadialMenu:false,primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']};
|
||||
type SteamApp={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name:string}[];id:number;name:string;icon?:string;iconFit?:string;kind?:string};
|
||||
function launcherCells(total:number){
|
||||
const counts=[Math.min(Math.max(total-5,0),4),Math.min(total,5),Math.max(total-9,0)];
|
||||
@@ -30,7 +30,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf
|
||||
const[busy,setBusy]=useState(false),[error,setError]=useState(''),[seconds,setSeconds]=useState(config.menuTimeoutSeconds);
|
||||
useEffect(()=>setSeconds(config.menuTimeoutSeconds),[config.menuTimeoutSeconds]);
|
||||
async function save(p:Partial<LauncherConfig>){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
|
||||
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><SwitchRow label={t('长按 Steam 键打开 Framely 菜单')} description={t('关闭后恢复 Steam 原生长按行为。切换时 SteamVR 会重新启动。')} checked={config.systemRadialMenu??false} disabled={busy} onChange={systemRadialMenu=>void save({systemRadialMenu})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
|
||||
}
|
||||
type LocalApp={steamAppId?:number|null;id:string;name:string;kind:'desktop'|'lepton';icon?:string;launchUnavailable?:string|null};
|
||||
type LaunchItem={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name:string}[];key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {CastQuick} from './cast-quick';
|
||||
import {SystemRadialMenu} from './system-radial-menu';
|
||||
import {SpatialFraming} from './cast-spatial-framing';
|
||||
import {ScreenshotFraming} from './cast-screenshot';
|
||||
import {CastPlayer} from './cast-player';
|
||||
import {Casting,type CastSettings,type CastPage} from './casting';
|
||||
import {CastViewer} from './cast-viewer';
|
||||
@@ -39,7 +41,10 @@ const managerMode=location.pathname==='/manager';
|
||||
const empty:Status={version:'0.4.0',database:{plugins:{},sources:[],safeMode:false},notifications:[],running:[]};
|
||||
|
||||
function App(){
|
||||
if(location.pathname==='/system/radial')return <SystemRadialMenu/>;
|
||||
if(location.pathname==='/system/radial/progress')return <SystemRadialMenu progressOnly/>;
|
||||
if(location.pathname==='/cast/framing')return <SpatialFraming/>;
|
||||
if(location.pathname==='/cast/screenshot')return <ScreenshotFraming/>;
|
||||
if(location.pathname==='/apk/permission')return <ApkPermission/>;
|
||||
if(location.pathname==='/cast/watch')return <CastViewer/>;
|
||||
if(location.pathname.startsWith('/cast/window/'))return <CastPlayer/>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {api,viewKey} from './api';
|
||||
import {api,viewKey,nativeKeyboardHost} from './api';
|
||||
import {t,currentLanguage} from './i18n';
|
||||
export let pluginLanguagePreference='auto';
|
||||
export function setPluginLanguagePreference(value:string){pluginLanguagePreference=value;}
|
||||
@@ -27,7 +27,7 @@ export function PluginFrame({plugin,entry='quick',actionContext,onActionDone}:{p
|
||||
}
|
||||
else if(op==='ui.visibility.get')result=await api('ui.visibility.get',{view:viewKey()});
|
||||
else if(op==='haptic')result=await api('host.haptic',{view:viewKey(),feedback:params?.feedback});
|
||||
else if(op==='keyboard')result=await api('host.keyboard',{...params,view:viewKey()});
|
||||
else if(op==='keyboard'){if(!nativeKeyboardHost)result={ok:false};else if(params?.bridge){const method=params.bridge.method;if(!['webInputOpen','webInputPoll','webInputClaim','webInputAck','webInputClose'].includes(method))throw Error('Invalid keyboard bridge operation');result=await api('plugin.call',{plugin:'tooru.frame-keyboard',method,params:{...params.bridge.params,view:viewKey()}});}else result=await api('host.keyboard',{...params,view:viewKey()});}
|
||||
else{const method=methods[op];if(!method)throw new Error(t('插件请求不支持的能力'));result=await api(method,{...params,plugin,...(op==='window.open'&&actionContext?{launchContext:actionContext}:{})});}
|
||||
if(live)ref.current?.contentWindow?.postMessage({channel:'framely.reply',id,result},'*');
|
||||
}catch(e){if(live)ref.current?.contentWindow?.postMessage({channel:'framely.reply',id,error:String(e)},'*');}
|
||||
|
||||
+1
-1
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
html:has(.system-radial),body:has(.system-radial),#root:has(.system-radial){width:100%;height:100%;margin:0;background:transparent!important;overflow:hidden}
|
||||
.system-radial{position:relative;display:grid;place-items:center;width:100%;height:100%;color:#fff;font-family:system-ui,"Noto Sans CJK SC",sans-serif;user-select:none;pointer-events:none}
|
||||
.system-radial-charge{position:relative;width:86px;height:86px;border-radius:50%;background:rgba(0,0,0,.58)}
|
||||
.system-radial-charge-ring{position:absolute;inset:0;width:86px;height:86px;fill:none;stroke-width:3;transform:rotate(-90deg)}
|
||||
.system-radial-charge-track{stroke:rgba(255,255,255,.24)}
|
||||
.system-radial-charge-progress{stroke:#fff;stroke-linecap:round}
|
||||
.system-radial-face{position:relative;width:500px;height:500px}
|
||||
.system-radial-slot{--radius:177px;position:absolute;left:50%;top:50%;width:124px;height:82px;transform:translate(-50%,-50%) rotate(var(--angle)) translateY(calc(-1 * var(--radius))) rotate(calc(-1 * var(--angle)));display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:1px solid transparent;border-radius:18px;background:transparent;box-sizing:border-box;opacity:.7;text-shadow:0 2px 8px rgba(0,0,0,.9);transition:opacity .12s,background .12s,border-color .12s}
|
||||
.system-radial-slot span{font-size:14px;font-weight:590;white-space:nowrap}
|
||||
.system-radial-slot.is-selected{opacity:1;background:rgba(0,0,0,.62);border-color:rgba(255,255,255,.65)}
|
||||
.system-radial-center{position:absolute;left:50%;top:50%;width:112px;height:112px;transform:translate(-50%,-50%);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:7px;border-radius:50%;border:1px solid rgba(255,255,255,.22);background:rgba(0,0,0,.5);color:#fff;box-sizing:border-box;text-shadow:0 2px 8px rgba(0,0,0,.75);opacity:.68}
|
||||
.system-radial-center.is-selected{border-color:rgba(255,255,255,.8);background:rgba(0,0,0,.7);opacity:1}
|
||||
.system-radial-center>span{font-size:13px;font-weight:580;white-space:nowrap}
|
||||
@media (prefers-reduced-motion:reduce){.system-radial-slot{transition:none}}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {IconApps,IconCamera,IconDeviceDesktop,IconPlayerRecord,IconRefresh} from '@tabler/icons-react';
|
||||
import './system-radial-menu.css';
|
||||
|
||||
type State={progress:number;open:boolean;direction:string;recording:boolean};
|
||||
const initial:State={progress:0,open:false,direction:'center',recording:false};
|
||||
const slots=[
|
||||
{direction:'up',label:'启动台',icon:IconApps},
|
||||
{direction:'upRight',label:'',icon:null},
|
||||
{direction:'right',label:'截图',icon:IconCamera},
|
||||
{direction:'downRight',label:'',icon:null},
|
||||
{direction:'down',label:'管理面板',icon:IconDeviceDesktop},
|
||||
{direction:'downLeft',label:'',icon:null},
|
||||
{direction:'left',label:'录屏',icon:IconPlayerRecord},
|
||||
{direction:'upLeft',label:'',icon:null},
|
||||
];
|
||||
|
||||
export function SystemRadialMenu({progressOnly=false}:{progressOnly?:boolean}){
|
||||
const[state,setState]=useState<State>(initial);
|
||||
useEffect(()=>{
|
||||
const update=(event:Event)=>setState((event as CustomEvent<State>).detail);
|
||||
window.addEventListener('framely.systemMenu',update);
|
||||
return()=>window.removeEventListener('framely.systemMenu',update);
|
||||
},[]);
|
||||
return <main className={'system-radial '+(progressOnly?'is-charging':'is-open')} aria-label="Framely 快捷菜单">
|
||||
{progressOnly?<div className="system-radial-charge">
|
||||
<svg className="system-radial-charge-ring" viewBox="0 0 86 86" aria-hidden="true">
|
||||
<circle className="system-radial-charge-track" cx="43" cy="43" r="31"/>
|
||||
<circle className="system-radial-charge-progress" cx="43" cy="43" r="31" pathLength="100" strokeDasharray={`${Math.max(0,Math.min(1,state.progress))*100} 100`}/>
|
||||
</svg>
|
||||
</div>:<div className="system-radial-face">
|
||||
{slots.map((slot,index)=>{
|
||||
const Icon=slot.icon;
|
||||
if(!Icon)return null;
|
||||
return <div key={slot.direction} className={'system-radial-slot'+(state.direction===slot.direction?' is-selected':'')} style={{'--angle':`${index*45}deg`} as React.CSSProperties}>
|
||||
<Icon size={29} stroke={1.7}/><span>{slot.direction==='left'&&state.recording?'结束录屏':slot.direction==='left'?'开始录屏':slot.label}</span>
|
||||
</div>;
|
||||
})}
|
||||
<div className={'system-radial-center'+(state.direction==='center'?' is-selected':'')}>
|
||||
<IconRefresh size={24} stroke={1.6}/><span>重置视角</span>
|
||||
</div>
|
||||
</div>}
|
||||
</main>;
|
||||
}
|
||||
Reference in new issue
Block a user