mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-19 23:01:41 +02:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cea24afbc9 | ||
|
|
e43000875c | ||
|
|
a159dfc2d2 | ||
|
|
7be3553936 | ||
|
|
9e25abb9b6 | ||
|
|
590ae46bc5 | ||
|
|
f9f14947ee | ||
|
|
4746357769 | ||
|
|
b3182c46ec | ||
|
|
109160ac5b | ||
|
|
342fa96056 |
No files matched your search
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.8.3-preview.3"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.8.3-preview.3"
|
||||
version = "0.8.3"
|
||||
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
|
||||
|
||||
@@ -27,7 +27,7 @@ Downloads are checked locally and again on the device. Passwords are not saved i
|
||||
Run these commands **on Frame**. 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
|
||||
```
|
||||
|
||||
To select a Preview or another tag:
|
||||
@@ -38,6 +38,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 need no second confirmation. The interactive action menu and uninstall still ask for `y` and Enter. If sudo needs a password in a single remote `ssh` command, allocate a terminal with `ssh -t`; `--yes` skips only Framely's confirmation, not sudo authentication.
|
||||
|
||||
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,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
|
||||
|
||||
@@ -27,7 +27,7 @@ Linux 用户可在安装器解压目录执行 `bash install-desktop-entry.sh`,
|
||||
以下命令在 **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 -s -- install --yes
|
||||
```
|
||||
|
||||
指定 Preview 或其他标签:
|
||||
@@ -38,6 +38,16 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
|
||||
|
||||
将示例标签换成 Releases 中实际存在的本体标签。指定标签时从该 Release 下载引擎和包,不要求已有正式版;不指定标签时使用最新正式版。终端会按需请求 sudo 密码。
|
||||
|
||||
明确指定 `install` 或 `update` 时无需再次确认。使用交互菜单或卸载时,脚本会询问是否继续,需要输入 `y` 并回车。如果通过一条 `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 的同一目录:
|
||||
|
||||
+28
-4
@@ -1,5 +1,5 @@
|
||||
#!/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
|
||||
repo=${FRAMELY_REPOSITORY:-SteamFramelyHomebrew/framely}
|
||||
[[ $repo =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]] || { echo 'Invalid repository.' >&2; exit 1; }
|
||||
@@ -8,19 +8,43 @@ work=$(mktemp -d)
|
||||
trap 'rm -rf -- "$work"' EXIT
|
||||
release_path=latest/download
|
||||
args=("$@")
|
||||
github_proxy=${FRAMELY_GITHUB_PROXY:-}
|
||||
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; }
|
||||
;;
|
||||
--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
|
||||
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
|
||||
bootstrap_url="https://github.com/$repo/releases/$release_path/bootstrap.py"
|
||||
if [[ -n $github_proxy ]]; then
|
||||
curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --output "$work/bootstrap.py" "$github_proxy/$bootstrap_url"
|
||||
python3 "$work/bootstrap.py" --repo "$repo" "$@"
|
||||
exit
|
||||
fi
|
||||
if status=$(curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --dump-header "$work/headers" --write-out '%{http_code}' --output "$work/bootstrap.py" "$bootstrap_url"); then
|
||||
:
|
||||
else
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,8 @@ import pathlib
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
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 +16,55 @@ spec.loader.exec_module(bootstrap)
|
||||
|
||||
|
||||
class BootstrapTests(unittest.TestCase):
|
||||
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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -27,6 +27,19 @@ 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])
|
||||
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 run_entry(self, args):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = pathlib.Path(temp)
|
||||
@@ -35,12 +48,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', '')}))
|
||||
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,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)
|
||||
|
||||
+34
-8
@@ -57,6 +57,27 @@ 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 public_github(url):
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
return (parsed.scheme == 'https' and parsed.hostname in GITHUB_HOSTS
|
||||
@@ -95,7 +116,7 @@ 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)
|
||||
try:
|
||||
@@ -109,12 +130,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'):
|
||||
@@ -280,9 +299,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):
|
||||
@@ -363,14 +385,18 @@ 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)
|
||||
args = parser.parse_args(argv)
|
||||
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 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,7 +405,7 @@ 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')
|
||||
cache = pathlib.Path.home() / '.cache' / 'framely-installer' if args.action in ('install', 'update') and not args.archive else None
|
||||
if cache is not None:
|
||||
|
||||
@@ -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
|
||||
|
||||
+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)},'*');}
|
||||
|
||||
@@ -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