mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-20 01:01:35 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cea24afbc9 | ||
|
|
e43000875c | ||
|
|
a159dfc2d2 | ||
|
|
7be3553936 | ||
|
|
9e25abb9b6 | ||
|
|
590ae46bc5 | ||
|
|
f9f14947ee | ||
|
|
4746357769 | ||
|
|
b3182c46ec | ||
|
|
109160ac5b | ||
|
|
342fa96056 | ||
|
|
f4886c4de2 | ||
|
|
05e3215204 | ||
|
|
dbf37ecd33 | ||
|
|
f301327eb8 | ||
|
|
ed51e913bb | ||
|
|
176c4d0f8b | ||
|
|
2b43094404 | ||
|
|
2afd1dff50 | ||
|
|
d414fd2733 | ||
|
|
ac6e3dc110 | ||
|
|
b52b8d7e20 | ||
|
|
acf298991f | ||
|
|
92d97d5b3b | ||
|
|
2028c97039 | ||
|
|
fe1c84d5e5 | ||
|
|
493e6f66f9 | ||
|
|
267c5dfff5 |
No files matched your search
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.8.2"
|
||||
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
-1
@@ -4,6 +4,8 @@
|
||||
|
||||
## 采集与网页观看
|
||||
|
||||
快捷面板和“网页观看”提供 **显示录制指示器** 开关,默认开启并持久保存。录制过程中也可切换;关闭仅隐藏头显右上角指示器及其点击区域,不停止或重启录制、不改变录制区域边框。隐藏后仍可通过两个面板的“停止录制”按钮停止。
|
||||
|
||||
“网页观看”和快捷面板的串流卡片均有 **开始/停止录制** 按钮,状态同步。录制与串流共用画面来源、取景、分辨率、帧率、码率、视频编码和声音设置;默认输出为 1920×1440(4:3),已有自定义设置保持不变。录制复用原始 H.264/H.265 视频,不进行二次视频编码,声音转为 AAC。文件保存到 Steam 用户的 `Videos/framely`(头显默认 `/home/steamos/Videos/framely`),使用唯一文件名和分片 MP4 容器,不覆盖已有视频。
|
||||
|
||||
录制与网页串流独立启停:点击录制直接启动本地采集和录制,不开启网页观看或 HLS 服务;之后开启串流会复用正在录制的采集编码,不重启录制。停止串流不停止录制,停止录制不停止串流,两者都停止时释放采集资源。录制期间不能修改采集参数或调整取景;接收投屏设置不受影响。
|
||||
@@ -22,7 +24,7 @@ Framecorder 原插件的名称是“面板画面”和“SteamVR 备用画面”
|
||||
|
||||
网页优先协商用户选择的 H.264 或 H.265。当前官方 CEF 运行包不能解码所选编码时,头显自带 FFmpeg 以 libvpx 提供 VP8 + Opus 兼容输出;所有需要 VP8 的观看者共享一个额外的软件编码进程。采集来源不变。支持所选编码的观看者直接接收原串流,DLNA 仍使用 H.264。VP8 增加 CPU 消耗;兼容观看结束后收回该进程。Framely 不安装、调用或依赖外部 Chrome。
|
||||
|
||||
在“在其他设备上观看”中开启 **免验证**,显示和复制的观看地址会附带随机凭证。持有该链接的人无需管理密码即可观看,但不能访问管理面板、文件或终端。链接在停止/重新开始串流及重启后仍有效;关闭开关会撤销链接并断开免验证观看,已登录观看不受影响。再次开启生成新链接。网络访问总开关仍需开启。
|
||||
“在其他设备上观看”及 **免验证** 开关常驻显示,未开始串流时也能提前设置和复制观看地址。开启后,显示和复制的观看地址会附带随机凭证。持有该链接的人无需管理密码即可观看,但不能访问管理面板、文件或终端。链接在停止/重新开始串流及重启后仍有效;关闭开关会撤销链接并断开免验证观看,已登录观看不受影响。再次开启生成新链接。网络访问总开关仍需开启。
|
||||
|
||||
声音使用小片段和独立的有界输入队列,避免音频阻塞拖慢视频;编码使用无 B 帧和低延迟参数。SteamVR 的 H.265 是软件编码,会增加 CPU 负载;屏幕采集使用 Iris 硬件编码。
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
## Default GitHub Release URL
|
||||
|
||||
GitHub topic discovery is also supported without a catalog. See [GitHub plugin discovery and release.json](../github-plugin-discovery.md) for the generated Release asset, legacy compatibility and publishing commands.
|
||||
|
||||
A public GitHub plugin repository's **source `manifest.json` may omit `downloadUrl`**. The community database derives it from the registered repository, version and plugin ID:
|
||||
|
||||
```text
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# GitHub plugin discovery
|
||||
|
||||
[简体中文及完整 JSON 示例](zh-CN/github-plugin-discovery.md)
|
||||
|
||||
Framely searches public GitHub repositories with topic `framely-plugin`, excluding forks and archived repositories. Releases appear in the existing plugin library alongside catalogs. One row per plugin ID recommends the highest compatible SemVer; version selection retains different package hashes and origins. Existing install confirmation, runtime-user checks and dependency resolution apply.
|
||||
|
||||
Upload a `release.json` asset with `schemaVersion: 1` and a nonempty `plugins` array. Required plugin fields are `id`, `name`, `version`, `apiVersion`, `author`, `description`, `asset`, `size`, `sha256`, and `runAs`. `asset` identifies a `.framely` asset in that Release; size is whole-package bytes and SHA256 is 64 hexadecimal characters. `runAs` is the effective backend/lifecycle user, or explicit `null` for UI-only plugins. Optional presentation, engines and relation fields follow existing catalog metadata. Images may name Release assets or fixed HTTPS URLs. Release tag, prerelease flag and download URLs come from GitHub.
|
||||
|
||||
Generate metadata from final, verified packages rather than maintaining hashes manually:
|
||||
|
||||
```bash
|
||||
framely release-json --packages ./release --output ./release/release.json
|
||||
gh release upload "$TAG" ./release/*
|
||||
```
|
||||
|
||||
The generator also exports packaged images into that directory unless external publishing images are configured. Upload every generated file. Add these commands to your own publishing CI and add the repository topic to opt into discovery. Existing external plugin repositories are not changed by this feature.
|
||||
|
||||
Only Releases without `release.json` use legacy compatibility: resolve the tag to a commit SHA, read its root `manifest.json`, require tag/version agreement, match the explicit download URL or `<id>-<version>.framely`, and require GitHub's SHA256 asset digest or `SHA256SUMS`. All available hashes must agree. Malformed new metadata never falls back to legacy metadata. Old clients continue using their existing catalogs; package format is unchanged.
|
||||
|
||||
Metadata uses pagination, SemVer sorting, a ten-minute cache, ETag revalidation and a global six-request concurrency limit. Manual refresh revalidates. Retryable network failures may use stale cache with a warning; invalid JSON, certificate errors and ordinary 404s do not. Proxy settings and cancellation are reused. Browsing does not download package bytes.
|
||||
|
||||
GitHub sources use canonical repository URLs and stable IDs. A newly discovered source is persisted only when installation is confirmed. Updates, history and dependencies subsequently read its Releases. Disabled sources remain disabled. Installation verifies actual package hash, identity, version, API, engine requirements, runtime user and relations against Release metadata.
|
||||
@@ -64,6 +64,8 @@ framely catalog --name 'My plugins' --base-url https://example.org/plugins --pac
|
||||
|
||||
Archives contain Manifest and hashed payload only, without keys/signatures. Host the main catalog and relative history JSON. Authors can host packages on GitHub Releases. Current first initialization includes community stable/testing sources. Origins remain explicit; same-ID plugins in other sources do not silently replace existing origins. HTTP needs explicit development permission; up to five validated redirects, without HTTPS downgrade.
|
||||
|
||||
Alternatively, opt into [GitHub plugin discovery](github-plugin-discovery.md) with topic `framely-plugin` and a generated `release.json` asset. The library merges identical IDs and recommends the highest compatible version, preserving origin/hash choices and installation confirmation. Existing catalogs and older Releases remain supported.
|
||||
|
||||
## Hover feedback
|
||||
|
||||
Interactive controls automatically receive one hover pulse per entry, excluding disabled/inert controls and movement between their children. Supported elements include buttons, links, inputs, interactive ARIA roles/focusable controls or pointer cursors. Custom controls may mark `data-framely-interactive`. Native feedback uses the current hit controller, 12 ms/120 Hz/amplitude 0.15 with at least 80 ms spacing. Pages cannot target other windows/controllers.
|
||||
|
||||
+6
-6
@@ -38,8 +38,8 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven
|
||||
|
||||
Device and installer share the repository with independent versions/tags/workflows:
|
||||
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.7.1`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.10`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
|
||||
- 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,11 +47,11 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
|
||||
|
||||
```bash
|
||||
# After committing and pushing the intended source:
|
||||
git tag v0.7.1
|
||||
git push origin v0.7.1
|
||||
git tag v0.8.3
|
||||
git push origin v0.8.3
|
||||
# Independent installer release:
|
||||
git tag installer-v0.4.1-preview.10
|
||||
git push origin installer-v0.4.1-preview.10
|
||||
git tag installer-v0.4.3
|
||||
git push origin installer-v0.4.3
|
||||
```
|
||||
|
||||
Prerelease suffixes create GitHub Prereleases without Latest. Installer releases are never Latest. Only stable device releases become Latest, keeping default installer/update URLs on the device product. The desktop installer filters device archive names and does not mistake ARM64 installer packages for device runtimes. Download desktop installers from their explicit installer tags.
|
||||
|
||||
@@ -163,6 +163,14 @@ On Frame Gamescope `3.16.28-76-ge383171f`, both window and Dock close controls l
|
||||
|
||||
Steam entries show the APK's application name and icon. Framely generates matching library capsules, headers and hero images from the icon, without downloading artwork. The original launcher icon, order and favorites stay intact; the explicit Steam App ID binding excludes duplicate entries from the launcher Steam category. APK updates refresh generated entry metadata.
|
||||
|
||||
Generated artwork includes a 600×900 portrait capsule, 920×430 landscape capsule, 3840×1240 text-free hero, 1280×360 transparent logo and 256×256 icon. Portrait titles are centered; landscape capsules use the icon on the left and the title on the right. The hero reserves space for Steam's separate logo overlay. Long titles are fitted using actual font metrics. Rendering uses Steam's existing CEF Canvas and system fonts; no extra runtime, image service or font download is required.
|
||||
|
||||
The shortcut icon is a separate file, not a fifth Steam custom-artwork type. Steam can crop hero backgrounds with `cover` depending on its window shape; the main motifs stay in the central safe band rather than requiring the whole exported image to be visible. An exact Framely-owned icon accidentally written over a landscape capsule by the first artwork build is automatically repaired; unrelated custom images are not replaced.
|
||||
|
||||
The same OKLab clustering and area/edge-distribution scoring processes every icon; application names and package IDs do not select colors. Main ornaments retain the selected color's hue and use 80% RGB intensity. Icons without a reliable chromatic candidate use a neutral palette. Package IDs seed only the stable decoration layout, so refreshing does not randomly change the design.
|
||||
|
||||
Framely stores generated image-content hashes in its private Devkit entry directory. Updates replace only missing images, unchanged Framely-generated images or exact old icon-only artwork; manually replaced Steam artwork is preserved. Renaming a shortcut refreshes Framely-generated titles during the next Steam synchronization. Steam must be running and its library UI available; otherwise synchronization is deferred.
|
||||
|
||||
The native runtime has a `steamlaunch-…` process/container name, while the APK keeps its existing data directory and Framely identity. No game-data copy, APK reinstall or system compositor modification is required. A private, per-user Lepton entry adapter retains resource storage, selected activity, window orientation, shader cache and optional controller forwarding. It prevents native Lepton from clearing an existing installation after a timestamp change or early exit. Unknown entry-script formats are refused before starting the application.
|
||||
|
||||
Close an already running container before switching its launch path. Framely does not restart a running game to assign Steam ownership. Old shared containers remain discoverable, but only one Steam launch may own their data at a time. Duplicate launches are blocked using an ownership lock that survives the native process launch.
|
||||
@@ -195,6 +203,12 @@ Container list headings show installed application names; the data path identifi
|
||||
|
||||
**APK → Settings → Keep installed APKs in Steam** defaults to on. Turning it off removes idle Framely-owned entries and disables automatic registration. Launching an app whose **Launch through Steam** option is enabled registers its entry on demand; after the native launch process and container finish, the entry and its temporary Devkit copy are removed. Installed APKs, containers, saves and downloaded game data are retained. Running entries are protected. Failed or interrupted starts are cleaned up after a startup grace period, with retries when Steam is unavailable. Turning retention back on resumes automatic registration.
|
||||
|
||||
### Failed installs and damaged containers
|
||||
|
||||
A failed first install rolls back only the container, runtime and installation records created by that attempt, retaining operation logs. A failed update keeps the existing container, application data and any backup already created. If safe cleanup fails, the pending state is retained and both installation and cleanup errors are reported.
|
||||
|
||||
For older, incomplete Framely-owned containers without an Android package database, choose **Clean damaged container** in application details or delete the container from the container page. Explicit confirmation permanently deletes its application data, associated APK cache and Steam entries; historical backups remain. Ownership, storage paths and runtime ownership must pass validation. Unreadable old data is never automatically deleted. After confirming that the data is no longer needed, clean the damaged container and select the APK again. Diagnose the original installation failure using its logs.
|
||||
|
||||
### Uninstalling while keeping the container
|
||||
|
||||
Keeping application data uses Android PackageManager readiness instead of requiring user unlock or external storage mounting. Deleting data still waits for those conditions. Framely boots a stopped container without showing its desktop and repairs only the selected APK registration if needed. It reports startup, PackageManager, unlock, storage and container-exit failures separately. Steam entries are removed after Android confirms successful uninstall; Android success is saved first so a Steam cleanup retry does not uninstall again. Failed startup or uninstall preserves the application record, Steam binding and application data. The error dialog offers Retry and Export logs. A container that cannot boot still requires diagnosis; Framely does not silently delete it or edit Android package databases.
|
||||
|
||||
@@ -25,6 +25,8 @@ Plugin windows use the same SteamVR move, resize, keyboard and close controls, w
|
||||
|
||||
Settings provides language, network panel and proxy configuration. HTTP and GitHub proxies have independent switches; saving an address does not automatically enable it.
|
||||
|
||||
**Automatically try gh-proxy.com when GitHub cannot be reached** is enabled by default and can be disabled separately. Framely first uses the existing direct/manual proxy route, then retries eligible public GitHub network failures once through this third-party service. Authentication-bearing requests, signed URLs, missing resources and failed checksum verification are not routed automatically. A truncated download restarts from zero; package verification remains required. Non-GitHub requests are unchanged.
|
||||
|
||||
Safe mode pauses third-party plugins for troubleshooting; leave it after resolving the problem. Settings → About shows the device version and terms; Settings → Updates manages updates. Revoking consent disables all plugins; accepting again does not automatically re-enable them.
|
||||
|
||||
In Settings → Updates, select the Stable or Testing update channel and check for updates. Choose **Download and install**, then confirm once: Framely downloads and verifies the release and automatically starts installation. Download and verification are displayed as separate stages, with byte progress for each. Download or verification failure stops installation and displays an error. Plugins and data remain; the Framely interface briefly closes and restarts.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -72,6 +72,14 @@ Download a single file directly, or select multiple files/folders to download a
|
||||
|
||||
Opening an ordinary file attempts the UTF-8 editor, limited to 5 MiB. Invalid UTF-8, binary data and larger files cannot be edited; use Download instead. Saving checks a content revision. External changes require reloading the file or explicitly confirming overwrite. Unsaved changes require confirmation before closing the editor.
|
||||
|
||||
**Edit as text** is available in the context menu and Actions for every non-directory entry, including images, archives and unknown extensions. It bypasses media preview. Binary data and invalid UTF-8 can be viewed as read-only text (replacement characters may appear); they cannot be saved through the editor. Devices, pipes and files over 5 MiB are rejected. Nothing is executed or converted on disk.
|
||||
|
||||
The offline CodeMirror editor loads only when needed. It provides line numbers, highlighting for JavaScript/TypeScript, JSON, HTML, CSS, Python and Rust, indentation, folding, find/replace, undo/redo, word wrap and Ctrl/Cmd+S. Other formats open as plain text. There are no completion popups, diagnostics, language servers or CDN requests. Headset keyboard input edits the document at the current selection. If loading fails, the ordinary textarea remains available.
|
||||
|
||||
The editor occupies the file workspace, with a document tab and parent path, a compact toolbar, a manual language-mode selector and live line/column status. Saving and closing stay at the top. The status bar reports encoding and line endings; consistent CRLF and CR files retain their line separators when edited. Opening a confirmation dialog preserves the editor's undo history and temporarily blocks hidden input.
|
||||
|
||||
**Properties** reads fresh metadata: path, type, byte size, allocated disk space, owner/group names and numeric IDs, modification/access/metadata-change/creation times (where supported), inode, device and hard-link count. Link properties describe the link itself and show its target and resolved path. Directory size describes the directory entry, not a recursive total. Owned non-symlink entries support a read/write/execute matrix synchronized with a validated three-digit octal mode. Existing special bits are preserved; other users' permissions are not elevated. Saving checks device/inode identity to reject replaced entries.
|
||||
|
||||
## Archives and media
|
||||
|
||||
Create ZIP, TAR or TAR.GZ archives; the output filename selects the format. Choose a destination and conflict behavior when extracting. Archive operations reject absolute paths, parent traversal, symbolic/hard links and special files; extracted contents are never executed. Archive limits are 50,000 entries and 64 GiB expanded data. Cancelling removes staging files and leaves already committed items intact.
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
## GitHub Release 默认地址
|
||||
|
||||
现在也支持通过 GitHub topic 自动发现,无需目录文件。`release.json` 格式、生成命令和旧 Release 兼容规则见 [GitHub 插件自动发现](../github-plugin-discovery.md)。
|
||||
|
||||
公开 GitHub 插件仓库的**源码 `manifest.json` 可以省略 `downloadUrl`**。社区数据库从登记的仓库地址、版本和插件 ID 自动生成:
|
||||
|
||||
```text
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# GitHub 插件自动发现与 Release 元数据
|
||||
|
||||
[English](../github-plugin-discovery.md)
|
||||
|
||||
Framely 在现有插件库中搜索带 `framely-plugin` topic 的公开 GitHub 仓库,排除 fork 和 archived 仓库,再读取 Releases。无需 npm 或额外目录文件。现有社区源、自建源继续使用;同 ID 合并为一个列表项,优先推荐语义版本最高的兼容版本。来源、正式版/预览版和历史版本可在原界面选择。同 ID、版本和包哈希相同的条目合并;同版本不同哈希仍保留,版本选项显示短哈希。更换来源继续经过安装确认。
|
||||
|
||||
## 新 Release:release.json
|
||||
|
||||
每个 Release 上传一个 `release.json` 附件,可描述多个插件。不要放在仓库默认分支来代替 Release 附件。
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"plugins": [{
|
||||
"id": "example.plugin",
|
||||
"name": "Example Plugin",
|
||||
"version": "1.2.0",
|
||||
"apiVersion": 1,
|
||||
"author": "Developer",
|
||||
"description": "插件简介",
|
||||
"engines": {"framely": ">=0.8.3"},
|
||||
"asset": "example.plugin-1.2.0.framely",
|
||||
"size": 123456,
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"runAs": "steamos",
|
||||
"icon": "example.plugin-1.2.0-icon.png",
|
||||
"screenshots": [],
|
||||
"tags": ["工具"],
|
||||
"details": "详细介绍",
|
||||
"changelog": "更新记录",
|
||||
"homepage": "https://github.com/example/plugin",
|
||||
"dependencies": {},
|
||||
"optionalDependencies": {},
|
||||
"conflicts": {},
|
||||
"exclusiveResources": []
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
必填:`id`、`name`、`version`(SemVer)、`apiVersion`、`author`、`description`、`asset`、`size`(整包字节数)、`sha256`(整包 64 位十六进制 SHA256)、`runAs`。有后端或生命周期脚本时,`runAs` 是实际运行用户;纯界面插件为 `null`,不能省略。其他字段遵循现有插件目录格式;图片可以是同一 Release 的附件名或固定 HTTPS 地址。`authorUrl`、`documentationUrl` 也支持现有格式。
|
||||
|
||||
`asset` 必须对应同一 Release 的 `.framely` 附件;大小必须与 GitHub 附件资料一致。GitHub 提供 tag、预览版标记及附件下载地址,JSON 无需重复 `downloadUrl`。有 GitHub asset digest/`SHA256SUMS` 时必须与 JSON 一致。声明不合法、缺附件或哈希冲突的 Release 不会作为可安装版本,也不会退回旧格式掩盖错误。
|
||||
|
||||
## 自动生成及上传
|
||||
|
||||
先完成正常构建、打包和验证,把最终 `.framely` 包放入一个专用发行目录,再运行新版 Framely:
|
||||
|
||||
```bash
|
||||
framely release-json --packages ./release --output ./release/release.json
|
||||
```
|
||||
|
||||
在 Framely 源码目录也可用 `cargo run --locked -- release-json ...`。生成器验证包内文件哈希,读取最终包内清单,自动计算整包 SHA256、字节数、运行用户及依赖。没有 `publish.icon`/非空 `publish.screenshots` 外链时,自动导出包内图标和截图到同一发行目录,JSON 使用这些附件名。拒绝覆盖内容不同的同名发行图片。每个插件 ID 在一次发行中只出现一次。
|
||||
|
||||
将生成步骤加入自己的发布 CI,并上传发行目录中的全部包、JSON 和图片,例如已有 GitHub CLI 发布流程中:
|
||||
|
||||
```bash
|
||||
framely release-json --packages ./release --output ./release/release.json
|
||||
gh release upload "$TAG" ./release/*
|
||||
```
|
||||
|
||||
不要在生成 JSON 后重新打包,也不要覆盖已发布的同版本附件。想加入自动发现,由仓库维护者添加 `framely-plugin` topic。本次 Framely 实现不会替维护者修改外部插件仓库。
|
||||
|
||||
## 没有 release.json 的旧 Release
|
||||
|
||||
只有完全没有该附件时才采用兼容路径:
|
||||
|
||||
1. 将 Release 的 tag 解析为提交 SHA,读取该 SHA 的根目录 `manifest.json`;不读取默认分支。
|
||||
2. tag 为 `v<version>` 或 `<version>`,且必须与清单版本完全一致。
|
||||
3. 清单有 `downloadUrl` 时必须精确对应本 Release 的 `.framely` 附件;否则匹配 `<id>-<version>.framely`。
|
||||
4. 使用 GitHub asset digest 或 Release 的 `SHA256SUMS`;二者都存在必须一致。均缺失则不允许安装,不猜测哈希。可选 `downloadSha256` 也必须一致。
|
||||
5. 有 `publish` 图片外链时沿用;否则图标使用固定提交 SHA 的 Raw 地址。
|
||||
|
||||
旧插件和现有 `catalog.json`/`versions.json` 来源无需迁移,旧客户端仍沿用原有来源,忽略新 Release 附件。`release.json` 只是增加一种发现方式,不替换现有安装包格式和兼容性检查。
|
||||
|
||||
## 缓存、安装及失败处理
|
||||
|
||||
搜索和 Releases 分页读取,最多各 10 页,每页 100 条;Release 先全部读取再按 SemVer 排序,不依赖 API 返回顺序。达到搜索上限会提示;超过 Release 历史上限会报错,避免错误推荐未完整读取的版本。元数据缓存 10 分钟,携带 ETag,手动刷新重新验证;网络失败时可显示旧缓存并提示,证书、解析和普通 404 等错误不隐藏。所有 GitHub 请求最多同时 6 个,支持取消,复用现有 HTTP/手动 GitHub 代理和自动代理策略。浏览目录不下载插件包。
|
||||
|
||||
首次选择 GitHub 插件时来源是规范仓库地址 `https://github.com/<owner>/<repo>`,来源 ID 根据仓库名稳定生成,安装确认成功才写入配置。后续更新、依赖和历史版本从该仓库 Releases 读取;禁用来源不会被自动发现重新启用。安装时再次核对整包哈希及包内 ID、版本、API、Framely 要求、实际运行用户、依赖与冲突;权限和来源切换使用现有确认流程。部分仓库失败不会隐藏其他来源已成功加载的插件。
|
||||
@@ -79,7 +79,7 @@ framely catalog --name 'My plugins' --base-url https://example.org/plugins --pac
|
||||
|
||||
包中包含 manifest.json 和文件载荷,files 记录每个载荷的 SHA256;不生成公钥或签名。只支持 manifest.json 与清单中声明的载荷文件,拒绝未声明的附加文件。
|
||||
|
||||
源需静态托管 catalog.json 及相对路径下的 plugins/<ID>/versions.json;插件包可放在作者自己的 GitHub Release。在插件源管理中添加目录完整 URL;当前代码首次初始化加入社区 stable/testing 来源,实际内容以服务器为准。目录条目有版本、下载 URL、SHA256。每个源独立显示,插件记录所选来源;另一个源同 ID 不会静默替换。HTTP 必须用户明确允许;目录和包支持最多五次重定向,HTTPS 不允许降级为 HTTP。
|
||||
传统源可静态托管 catalog.json 及相对路径下的 plugins/<ID>/versions.json;插件包可放在作者自己的 GitHub Release。在插件源管理中添加目录完整 URL;当前代码首次初始化加入社区 stable/testing 来源,实际内容以服务器为准。目录条目有版本、下载 URL、SHA256。插件库也支持 [GitHub 自动发现及 release.json](github-plugin-discovery.md),同 ID 合并展示并按兼容语义版本推荐,历史版本保留不同来源及哈希。插件记录所选来源,切换来源仍经过安装确认。HTTP 必须用户明确允许;目录和包支持最多五次重定向,HTTPS 不允许降级为 HTTP。
|
||||
|
||||
### 悬停反馈
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时
|
||||
|
||||
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
|
||||
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.7.1`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.10`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
|
||||
- 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,12 +59,12 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
|
||||
|
||||
```bash
|
||||
# 提交并推送代码后,选择需要发布的产品
|
||||
git tag v0.7.1
|
||||
git push origin v0.7.1
|
||||
git tag v0.8.3
|
||||
git push origin v0.8.3
|
||||
|
||||
# 独立发布安装器,不触发 Framely 构建
|
||||
git tag installer-v0.4.1-preview.10
|
||||
git push origin installer-v0.4.1-preview.10
|
||||
git tag installer-v0.4.3
|
||||
git push origin installer-v0.4.3
|
||||
```
|
||||
|
||||
带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。
|
||||
|
||||
@@ -133,6 +133,14 @@ APK 安装、更新、其他修改与启动检查期间暂停监测。容器重
|
||||
|
||||
Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的库封面、横幅和背景图,不联网下载图片。启动台原有图标、排序和收藏不变,通过明确的 Steam App ID 绑定过滤 Steam 分类中的重复入口。APK 更新会刷新生成的入口信息。
|
||||
|
||||
自动生成 600×900 竖版封面、920×430 横版封面、3840×1240 无文字详情背景、1280×360 透明 Logo 和 256×256 图标。竖版标题居中,横版采用左图标、右名称,详情背景由 Steam 单独叠加 Logo。较长名称按实际字体宽度缩放。使用 Steam 现有 CEF Canvas 和系统字体,无需额外安装运行时、调用图片服务或下载字体。
|
||||
|
||||
快捷方式图标使用独立文件,不作为第五种 Steam 自定义封面类型写入。Steam 会按窗口比例以 `cover` 裁切详情背景;主要装饰放在中央安全带,不依赖整张导出图片都可见。第一版封面功能误写到横版位置的 Framely 图标,会在内容摘要完全匹配时自动修复;无关的手动图片不覆盖。
|
||||
|
||||
所有图标使用同一套 OKLab 聚类及面积、外圈分布评分,不按应用名称或包名指定颜色。主装饰保留选中色相,RGB 强度统一为原色的 80%;无可靠彩色候选时使用中性配色。包名仅用于生成稳定的装饰布局,刷新不会随机改变画面。
|
||||
|
||||
Framely 在自己的私有 Devkit 入口目录记录生成图片的内容摘要。后续只更新缺失图片、仍与记录一致的 Framely 生成图片,或内容完全匹配的旧版居中图标封面;手动替换的 Steam 图片保留。修改 Steam 名称后,会在下一次 Steam 同步时刷新 Framely 生成的标题。Steam 及其库界面未就绪时延后同步。
|
||||
|
||||
原生运行时的进程和容器名为 `steamlaunch-…`,APK 仍保留原数据目录及 Framely 身份,无需复制游戏数据、重装 APK 或修改系统合成器。用户目录内的独立 Lepton 入口适配保留资源目录、选择的启动入口、窗口方向、着色器缓存和可选手柄转发,并防止原生 Lepton 在时间戳变化或短时间退出后清除已有安装。无法识别的入口脚本格式会在启动应用之前拒绝。
|
||||
|
||||
切换启动路径之前,需要先关闭已经运行的容器;Framely 不会为了绑定 Steam 重启游戏。旧共享容器仍可识别,但同时只允许一个 Steam 启动占用其数据。跨进程保留的占用锁阻止重复启动及共享数据的并发启动。
|
||||
@@ -149,6 +157,12 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
|
||||
|
||||
**APK → 设置 → 保留安装的 APK 到 Steam** 默认开启。关闭后清理空闲的 Framely 自有入口,不再自动注册。应用启用 **通过 Steam 启动** 时,会在启动时临时注册入口;原生启动进程和容器退出后删除入口及临时 Devkit 副本。已安装 APK、容器、存档和下载的游戏数据都会保留,运行中的入口不会删除。失败或中断的启动在启动宽限期后清理;Steam 不可用时稍后重试。重新开启保留设置会恢复自动注册。
|
||||
|
||||
### 安装失败与损坏容器
|
||||
|
||||
首次安装失败时,Framely 仅回滚本次新建的容器、运行时和安装记录,保留操作日志。更新已有容器失败时不会自动删除容器或存档,已生成的备份也会保留。安全校验或清理失败会保留待处理状态,并报告安装和清理两项错误。
|
||||
|
||||
旧版本遗留的容器若缺少安卓数据库,可在应用管理详情中选择 **清理损坏容器**,或在容器页删除该容器。此操作仍需确认:容器内可能存在的应用数据、关联 APK 缓存和 Steam 入口会永久删除,历史备份保留。只允许对明确由 Framely 管理且路径和运行时归属校验通过的损坏容器执行;不会因读取失败就自动删除旧数据。确认不需要容器内数据后清理,再重新选择 APK 安装。原始安装失败的具体原因仍应通过日志诊断。
|
||||
|
||||
### 卸载并保留容器
|
||||
|
||||
保留应用数据的卸载使用 Android 包管理器就绪检查,不再强制等待用户解锁或外部存储挂载。删除数据仍等待这些条件。Framely 在后台启动已停止的容器,不显示容器桌面;需要时只修复目标 APK 的登记。启动、包管理器、解锁、存储和容器退出分别报错。Android 确认卸载成功后才移除 Steam 入口,先保存 Android 卸载结果,重试 Steam 清理不会重复卸载。启动或卸载失败保留应用记录、Steam 绑定和应用数据。错误弹窗提供重试与导出日志。无法启动的容器仍需诊断,不会自动删除容器或直接修改 Android 包数据库。
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
|
||||
设置中可修改语言、网络面板及代理。HTTP 代理和 GitHub 代理各有开关,保存地址不会自动启用代理。
|
||||
|
||||
**GitHub 连接失败时自动使用 gh-proxy.com** 默认开启,可单独关闭。先使用原有直连或手动代理,符合条件的公开 GitHub 网络失败才通过第三方服务重试一次。带认证信息或签名参数的请求、资源不存在以及校验失败不自动代理;下载中断后从零重新下载,不混合两条线路的内容,仍须通过原有校验。非 GitHub 请求不受影响。
|
||||
|
||||
“安全模式”暂停第三方插件,适合出现故障时检查;排查后再退出安全模式。在“设置 → 关于”查看本体版本和协议,在“设置 → 更新”检查更新。撤回协议会停用全部插件,再次同意后需要手动启用。
|
||||
|
||||
在“设置 → 更新”选择正式版或测试版渠道并检查更新,点击“下载并安装”并确认一次:Framely 下载、校验发行包后自动开始安装。下载和校验分别显示阶段提示与实际字节进度。下载或校验失败时停止安装并显示原因。插件和数据保留,Framely 界面会暂时关闭并重新启动。
|
||||
|
||||
@@ -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 的同一目录:
|
||||
|
||||
@@ -74,6 +74,14 @@
|
||||
|
||||
普通文件打开时尝试 UTF-8 文本编辑器,上限 5 MiB;非法编码、二进制或超大文件无法编辑,可下载后处理。保存校验内容版本,外部修改后须重新加载或确认覆盖。关闭未保存内容会要求确认。
|
||||
|
||||
所有非目录条目的右键菜单和“操作”中都提供“以文本编辑”,包括图片、压缩包和未知后缀文件,不会先打开媒体预览。二进制或非法 UTF-8 可作为只读文本查看(可能出现替代字符),不能保存;设备、管道和超过 5 MiB 的文件不打开。不执行文件内容,不在磁盘上转换编码。
|
||||
|
||||
离线 CodeMirror 编辑器仅在打开文件时加载,支持行号、JavaScript/TypeScript、JSON、HTML、CSS、Python、Rust 高亮、缩进、折叠、查找替换、撤销重做、自动换行和 Ctrl/Cmd+S。其他格式按纯文本显示。没有补全弹窗、诊断、语言服务或 CDN 请求。头显键盘在当前光标或选区编辑文档;加载失败时仍可使用普通文本框。
|
||||
|
||||
编辑器占满文件管理工作区,提供文件标签及父目录路径、紧凑工具栏、手动语言模式选择和实时行列状态,保存及关闭固定在顶部。底部显示编码和换行格式;统一使用 CRLF 或 CR 的文件编辑后保留其分隔符。确认对话框打开时保留撤销历史,并暂时阻止隐藏编辑区继续接收输入。
|
||||
|
||||
“属性”实时读取完整路径、类型、字节大小、磁盘占用、所有者及所属组名称和数字 ID、修改/访问/状态变更/创建时间(不支持时显示不可用)、Inode、设备编号及硬链接数。符号链接显示链接自身的属性,以及目标和解析路径。目录大小是目录条目自身大小,不是递归统计。当前用户拥有的非符号链接条目可通过读/写/执行矩阵和同步的三位八进制权限修改;保留现有特殊权限位,不自动提权。保存会核对设备及 Inode,拒绝更改已经被替换的条目。
|
||||
|
||||
## 压缩与媒体
|
||||
|
||||
支持创建 ZIP、TAR、TAR.GZ,输出文件名决定格式。解压时选择目标目录和冲突策略。拒绝绝对路径、向上路径穿越、软硬链接及特殊文件,不执行解压内容;最多 50,000 项、展开大小 64 GiB。取消会清理暂存文件,保留已经提交的项目。
|
||||
|
||||
+48
-5
@@ -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,17 +8,60 @@ 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
|
||||
curl --fail --location --proto '=https' --proto-redir '=https' --retry 3 --output "$work/bootstrap.py" "https://github.com/$repo/releases/$release_path/bootstrap.py"
|
||||
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
|
||||
code=$?
|
||||
retry=false
|
||||
case "$code" in 5|6|7|18|28|35|52|55|56) retry=true;; esac
|
||||
if [[ $code == 22 ]]; then
|
||||
case "$status" in 408|429|5??) retry=true;; 403)
|
||||
if grep -Eiq '^(x-ratelimit-remaining:[[:space:]]*0[[:space:]]*$|retry-after:)' "$work/headers"; then retry=true; fi;;
|
||||
esac
|
||||
fi
|
||||
if [[ ${FRAMELY_GITHUB_AUTO_FALLBACK:-1} == 0 || $retry != true ]]; then
|
||||
echo "Bootstrap primary route failed (curl $code, HTTP $status); not eligible for automatic proxy." >&2; exit 1
|
||||
fi
|
||||
echo 'GitHub connection failed; trying gh-proxy.com once' >&2
|
||||
if ! curl --disable --silent --show-error --fail --location --proto '=https' --proto-redir '=https' --output "$work/bootstrap.py" "https://gh-proxy.com/$bootstrap_url"; then
|
||||
echo "Bootstrap primary route failed (curl $code, HTTP $status); gh-proxy.com fallback also failed." >&2; exit 1
|
||||
fi
|
||||
fi
|
||||
python3 "$work/bootstrap.py" --repo "$repo" "$@"
|
||||
Generated
+1
-1
@@ -1985,7 +1985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.10"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely-installer"
|
||||
version = "0.4.1-preview.10"
|
||||
version = "0.4.3"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
+4
-2
@@ -16,7 +16,7 @@ Only one installer instance can run per user; additional instances exit immediat
|
||||
|
||||
1. Enable developer mode and set a password on Frame. Connect the computer and device to the same LAN.
|
||||
2. Discovery prioritizes `192.168.*` subnets, with at most 1024 addresses per scan. Name discovery and IP probing run together. Only devices whose hostname is `frame` are listed, with SSH availability indicated. Other devices and addresses without a confirmed name are omitted. Manual IP and SSH port entry is always available.
|
||||
3. Check the SSH handshake and displayed host fingerprint, then enter credentials. The default user is `steamos`. Changed saved fingerprints cause the connection to be rejected. After verifying a device reinstall, you can remove its entry from `known-hosts.json` in the configuration directory.
|
||||
3. Check the SSH handshake and displayed host fingerprint, then enter credentials. The default user is `steamos`. If a reinstall changes the host key, the connection page shows both old and new fingerprints. Verify the device and choose “Trust new fingerprint and connect” to continue without editing `known-hosts.json`. Cancellation or failed login preserves the old record; the new fingerprint is saved only after authentication and device checks succeed. A further key change before login stops the connection and requires a fresh SSH check.
|
||||
4. Select a release or a local `framely-*-linux-arm64.tar.gz` together with its external `SHA256SUMS` or matching `.sha256` file. Local packages do not require GitHub access. Prereleases are hidden by default.
|
||||
5. Choose an operation on Install & Maintenance and confirm the device, version and action in the modal dialog. Cancel or press Esc to return without executing. Errors and operation results also appear in dialogs. SSH fingerprint verification stays on the connection page. The installer does not disable SteamOS root filesystem read-only protection; it stops with an error when required directories are not writable.
|
||||
|
||||
@@ -64,7 +64,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
|
||||
|
||||
Preview requires an available GPU or software Vulkan renderer. Default release builds do not include preview support.
|
||||
|
||||
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.1-preview.10`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
|
||||
Installer versions are managed independently in this directory's `Cargo.toml` and `Cargo.lock`. Pushing an `installer-v<VERSION>` tag (currently `installer-v0.4.3`) triggers `.github/workflows/installer-release.yml` to build and publish only the installer. The core uses `v<VERSION>` tags and a separate workflow. Manual Actions runs produce build artifacts only. Installer releases do not become the repository's Latest release, preserving Frame's default install/update URLs.
|
||||
|
||||
Actions builds Linux x64/ARM64, Windows x64, and macOS Intel/Apple Silicon. macOS produces an `.app` ZIP, Windows an EXE ZIP, and Linux a tar.gz. Apple notarization and Windows code signing are not configured; verify first-launch behavior on each system before publishing.
|
||||
|
||||
@@ -88,4 +88,6 @@ The archive includes bounded recent service/plugin/CEF logs, GPU evidence, insta
|
||||
|
||||
### Proxy preferences
|
||||
|
||||
Automatic GitHub fallback is enabled by default and can be disabled independently. After an eligible public GitHub connection failure, the installer retries once through `https://gh-proxy.com`, retaining the existing HTTP/system proxy and checksum verification. Partial downloads restart from zero. The selected fallback preference is passed to the device-side installation engine without changing persistent Frame settings. Standalone scripts also default to fallback and accept `FRAMELY_GITHUB_AUTO_FALLBACK=0` to disable it. Certificate errors, ordinary permission/not-found errors, authenticated or signed URLs and checksum failures do not trigger fallback.
|
||||
|
||||
The **Settings** tab controls downloads on the installer computer. System proxy discovery is enabled by default. An explicit HTTP/SOCKS proxy takes precedence; leave it empty to follow the system toggle. An optional HTTPS GitHub proxy prefix routes GitHub API and asset URLs as `prefix/original-URL`. Settings persist locally and apply to subsequent version checks and downloads; SSH and Frame settings are unchanged. Saving clears the release selection so the next version check uses the new settings. Package verification remains enabled.
|
||||
@@ -16,7 +16,7 @@ Rust + GPUI Kit 的桌面安装器,连接 Steam Frame,安装、更新、修
|
||||
|
||||
1. 在 Frame 开启开发者模式并设置密码;电脑和设备连接同一局域网。
|
||||
2. 默认优先扫描 `192.168.*` 网段(每次最多 1024 个地址)。名称发现和 IP 探测同时开始;只列出主机名为 `frame` 的设备,并标注 SSH 是否可用。其他设备和无法确认名称的地址不会显示。手动 IP 和 SSH 端口始终可用。
|
||||
3. 检查 SSH 握手,核实显示的主机指纹,再输入账号密码登录。用户名默认 `steamos`。已保存的指纹发生变化时拒绝连接;核实设备重装后可手动移除配置目录中的对应 `known-hosts.json` 条目。
|
||||
3. 检查 SSH 握手,核实显示的主机指纹,再输入账号密码登录。用户名默认 `steamos`。设备重装等情况导致指纹变化时,连接页显示旧、新指纹,核实设备身份后点击“信任新指纹并连接”即可继续,无需手动修改 `known-hosts.json`。取消或登录失败保留原记录;登录及设备检查成功后才保存新指纹。登录前若指纹再次变化,会停止并要求重新检查 SSH。
|
||||
4. 从 Releases 选择版本,或选择本地 `framely-*-linux-arm64.tar.gz` 和压缩包外部的 `SHA256SUMS`(也可选择对应 `.sha256` 文件)。本地包不需要访问 GitHub。默认隐藏测试版。
|
||||
5. 在“安装与维护”选择操作,在弹窗中核对设备、版本和操作后确认;取消或按 Esc 返回,不会执行。错误和操作结果也使用弹窗。SSH 指纹确认仍保留在连接页面。安装器不会解除 SteamOS 根分区的只读保护,所需目录不可写时会报错停止。
|
||||
|
||||
@@ -64,7 +64,7 @@ FRAMELY_INSTALLER_PREVIEW_DIR=installer/dist/previews installer/target/debug/fra
|
||||
|
||||
预览依赖可用的 GPU 或软件 Vulkan 渲染器;默认发行构建不包含此预览功能。
|
||||
|
||||
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.10`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
|
||||
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.3`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
|
||||
|
||||
Actions 构建 Linux x64/ARM64、Windows x64、macOS Intel/Apple Silicon。macOS 输出 `.app` ZIP,Windows 输出 EXE ZIP,Linux 输出 tar.gz。当前未配置 Apple 公证或 Windows 代码签名,发布前应分别验证系统的首次启动体验。
|
||||
|
||||
@@ -88,4 +88,6 @@ cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --
|
||||
|
||||
### 代理设置
|
||||
|
||||
自动 GitHub 代理默认开启,可单独关闭。公开 GitHub 请求出现符合条件的网络故障后,安装器通过 `https://gh-proxy.com` 最多重试一次,继续沿用 HTTP/系统代理及校验保护;半截下载会从零重新开始。本次开关状态会传给设备侧安装引擎,不改写 Frame 的持久设置。独立安装脚本默认开启,可用 `FRAMELY_GITHUB_AUTO_FALLBACK=0` 禁用。证书错误、普通权限或资源不存在错误、带认证或签名参数的地址以及校验失败不触发兜底。
|
||||
|
||||
**设置**页控制安装器电脑的下载。默认启用系统代理;填写 HTTP/SOCKS 代理后优先使用指定地址,留空则遵循系统代理开关。可选的 HTTPS GitHub 代理以 `代理前缀/原始 URL` 转发 GitHub API 和资源请求。设置在本机保存,后续检查版本和下载时生效,不更改 SSH 或 Frame 的设置。保存会清除版本选择,下次检查版本使用新配置;包校验继续启用。
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod discovery;
|
||||
#[path = "../../src/http/github_fallback.rs"]
|
||||
pub(crate) mod github_fallback;
|
||||
pub mod instance;
|
||||
pub mod maintenance;
|
||||
pub mod progress;
|
||||
|
||||
+86
-2
@@ -49,6 +49,7 @@ enum Event {
|
||||
struct Installer {
|
||||
page: usize,
|
||||
system_proxy: bool,
|
||||
github_auto_fallback: bool,
|
||||
http_proxy: Entity<InputState>,
|
||||
github_proxy: Entity<InputState>,
|
||||
chosen_action: String,
|
||||
@@ -117,6 +118,7 @@ impl Installer {
|
||||
let mut view = Self {
|
||||
page: 0,
|
||||
system_proxy: proxies.system,
|
||||
github_auto_fallback: proxies.github_auto_fallback,
|
||||
http_proxy: input(&proxies.http, "http://127.0.0.1:7890", false),
|
||||
github_proxy: input(&proxies.github, "https://your-github-proxy.example", false),
|
||||
chosen_action: "install".into(),
|
||||
@@ -273,7 +275,12 @@ impl Installer {
|
||||
self.busy = false;
|
||||
match result {
|
||||
Ok(probe) => {
|
||||
self.status = "SSH 握手成功。确认下方设备指纹后登录。".into();
|
||||
self.status = if probe.changed() {
|
||||
"设备指纹已变化。若设备已重装,请核对下方新指纹后重新信任并连接。"
|
||||
} else {
|
||||
"SSH 握手成功。确认下方设备指纹后登录。"
|
||||
}
|
||||
.into();
|
||||
self.probe = Some(probe);
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -292,6 +299,10 @@ impl Installer {
|
||||
self.busy = false;
|
||||
match result {
|
||||
Ok(connection) => {
|
||||
if let Some(probe) = &mut self.probe {
|
||||
probe.previous_fingerprint = Some(probe.fingerprint.clone());
|
||||
probe.known = true;
|
||||
}
|
||||
self.status =
|
||||
format!("已连接 {}:{}", connection.host, connection.version);
|
||||
self.chosen_action = connection.installation.default_action().into();
|
||||
@@ -525,7 +536,7 @@ impl Installer {
|
||||
self.connection_stage = Some(self.status.clone());
|
||||
let sender = self.sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = remote::connect(credentials, &probe.fingerprint, &mut |stage| {
|
||||
let result = remote::connect(credentials, &probe, &mut |stage| {
|
||||
let _ = sender.send(Event::ConnectionStage(stage));
|
||||
})
|
||||
.map_err(|e| format!("{e:#}"));
|
||||
@@ -601,6 +612,7 @@ impl Installer {
|
||||
}
|
||||
fn save_proxy(&mut self, cx: &mut Context<Self>) {
|
||||
let settings = framely_installer::proxy::DownloadSettings {
|
||||
github_auto_fallback: self.github_auto_fallback,
|
||||
system: self.system_proxy,
|
||||
http: self.http_proxy.read(cx).value().trim().to_owned(),
|
||||
github: self
|
||||
@@ -610,6 +622,7 @@ impl Installer {
|
||||
.trim()
|
||||
.trim_end_matches('/')
|
||||
.to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
match framely_installer::proxy::save(settings) {
|
||||
Ok(()) => {
|
||||
@@ -833,6 +846,75 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
})?;
|
||||
context.update_window(handle, |_, window, cx| transparent_window_root(window, cx))?;
|
||||
context.allow_parking();
|
||||
if std::env::var("FRAMELY_INSTALLER_PREVIEW_CASE").as_deref() == Ok("fingerprint-change") {
|
||||
context.update_window(handle, |_, window, cx| {
|
||||
view.update(cx, |view, cx| {
|
||||
view.page = 0;
|
||||
view.busy = true;
|
||||
view.connection = None;
|
||||
view.sender
|
||||
.send(Event::Probe(Ok(Probe {
|
||||
endpoint: "192.168.1.42:22".into(),
|
||||
fingerprint: "SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".into(),
|
||||
known: false,
|
||||
previous_fingerprint: Some(
|
||||
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into(),
|
||||
),
|
||||
})))
|
||||
.unwrap();
|
||||
view.poll(window, cx);
|
||||
assert!(!view.busy && view.probe.as_ref().unwrap().changed());
|
||||
assert!(view.status.contains("设备指纹已变化"));
|
||||
});
|
||||
window.render_frame(cx);
|
||||
assert!(window.find("connect").visible());
|
||||
assert!(window.find("cancel-fingerprint-change").visible());
|
||||
})?;
|
||||
context.run_until_parked();
|
||||
context.update_window(handle, |_, window, cx| window.render_frame(cx))?;
|
||||
context
|
||||
.capture_screenshot(handle)?
|
||||
.save(dir.join("installer-fingerprint-change.png"))?;
|
||||
let pending =
|
||||
context.update_window(handle, |_, _, cx| view.read(cx).probe.clone().unwrap())?;
|
||||
context.update_window(handle, |_, window, cx| {
|
||||
window.click("cancel-fingerprint-change", cx);
|
||||
window.render_frame(cx);
|
||||
assert!(view.read(cx).probe.is_none() && !view.read(cx).busy);
|
||||
assert!(view.read(cx).status.contains("未更改"));
|
||||
assert!(window.find("probe").visible());
|
||||
view.update(cx, |view, cx| {
|
||||
view.probe = Some(pending.clone());
|
||||
view.sender
|
||||
.send(Event::Connected(Err("SSH 登录失败".into())))
|
||||
.unwrap();
|
||||
view.poll(window, cx);
|
||||
assert!(view.probe.as_ref().unwrap().changed() && !view.busy);
|
||||
});
|
||||
window.close_all_dialogs(cx);
|
||||
view.update(cx, |view, cx| {
|
||||
view.prompts.clear();
|
||||
view.sender
|
||||
.send(Event::Connected(Ok(Connection {
|
||||
session: ssh2::Session::new().unwrap(),
|
||||
host: "192.168.1.42".into(),
|
||||
user: "steamos".into(),
|
||||
version: "frame · 0.8.3".into(),
|
||||
installation: Installation {
|
||||
present: true,
|
||||
current_version: Some("0.8.3".into()),
|
||||
previous_version: None,
|
||||
},
|
||||
})))
|
||||
.unwrap();
|
||||
view.poll(window, cx);
|
||||
let probe = view.probe.as_ref().unwrap();
|
||||
assert!(probe.known && !probe.changed() && !view.busy);
|
||||
});
|
||||
})?;
|
||||
println!("Fingerprint change preview, cancellation and connection state checks passed");
|
||||
return Ok(());
|
||||
}
|
||||
for (page, name) in [
|
||||
(0, "devices"),
|
||||
(1, "versions"),
|
||||
@@ -866,8 +948,10 @@ fn preview_pages(dir: PathBuf) -> anyhow::Result<()> {
|
||||
view.devices.clear();
|
||||
} else if name == "connecting" {
|
||||
view.probe = Some(Probe {
|
||||
endpoint: "192.168.1.42:22".into(),
|
||||
fingerprint: "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into(),
|
||||
known: false,
|
||||
previous_fingerprint: None,
|
||||
});
|
||||
view.busy = true;
|
||||
view.sender
|
||||
|
||||
@@ -62,12 +62,12 @@ pub fn copy(
|
||||
ensure!(value.completed + n as u64 <= total, "传输超过声明长度");
|
||||
writer.write_all(&block[..n])?;
|
||||
value.completed += n as u64;
|
||||
if last.elapsed() >= Duration::from_millis(200) {
|
||||
if last.elapsed() >= Duration::from_millis(200) && value.completed < total {
|
||||
report(value.clone());
|
||||
last = Instant::now();
|
||||
}
|
||||
}
|
||||
ensure!(value.completed == total, "传输未完成");
|
||||
if value.completed != total { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "传输未完成").into()); }
|
||||
writer.flush()?;
|
||||
report(value);
|
||||
Ok(())
|
||||
|
||||
@@ -8,6 +8,10 @@ use url::Url;
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct DownloadSettings {
|
||||
#[serde(skip)]
|
||||
#[doc(hidden)]
|
||||
pub github_fallback_attempt: bool,
|
||||
pub github_auto_fallback: bool,
|
||||
pub system: bool,
|
||||
pub http: String,
|
||||
pub github: String,
|
||||
@@ -15,6 +19,8 @@ pub struct DownloadSettings {
|
||||
impl Default for DownloadSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
github_fallback_attempt: false,
|
||||
github_auto_fallback: true,
|
||||
system: true,
|
||||
http: String::new(),
|
||||
github: String::new(),
|
||||
@@ -46,6 +52,7 @@ impl DownloadSettings {
|
||||
Ok(())
|
||||
}
|
||||
pub fn route(&self, url: &Url) -> Url {
|
||||
if self.github_fallback_attempt && !crate::github_fallback::eligible(url.as_str(), &[]) { return url.clone(); }
|
||||
if !self.github.is_empty()
|
||||
&& matches!(
|
||||
url.host_str(),
|
||||
@@ -695,6 +702,15 @@ mod download_settings_tests {
|
||||
assert!(settings.proxy_for(&target).unwrap().is_some());
|
||||
}
|
||||
#[test]
|
||||
fn github_fallback_default_and_sensitive_redirect_policy() {
|
||||
let legacy:DownloadSettings=serde_json::from_str("{}").unwrap();assert!(legacy.github_auto_fallback);
|
||||
let disabled:DownloadSettings=serde_json::from_str("{\"githubAutoFallback\":false}").unwrap();assert!(!disabled.github_auto_fallback);
|
||||
let fallback=DownloadSettings {github:crate::github_fallback::PREFIX.into(),github_fallback_attempt:true,..Default::default()};
|
||||
let signed=Url::parse("https://release-assets.githubusercontent.com/a?X-Amz-Signature=secret").unwrap();
|
||||
assert_eq!(fallback.route(&signed),signed);
|
||||
assert!(fallback.route(&Url::parse("https://github.com/a/b/releases").unwrap()).as_str().starts_with(crate::github_fallback::PREFIX));
|
||||
}
|
||||
#[test]
|
||||
fn github_routes_only_exact_hosts_and_does_not_double_wrap_redirects() {
|
||||
let settings = DownloadSettings {
|
||||
github: "https://proxy.example/prefix/".into(),
|
||||
|
||||
+57
-11
@@ -30,25 +30,42 @@ pub fn validate_repo(repo: &str) -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn agent(url: &url::Url, settings: &crate::proxy::DownloadSettings) -> Result<ureq::Agent> {
|
||||
fn agent(url: &url::Url, settings: &crate::proxy::DownloadSettings, download: bool) -> Result<ureq::Agent> {
|
||||
let mut builder = ureq::AgentBuilder::new()
|
||||
.try_proxy_from_env(false)
|
||||
.timeout(Duration::from_secs(120))
|
||||
.redirects(0);
|
||||
let timeout = Duration::from_secs(120);
|
||||
builder = if download { builder.timeout_connect(timeout).timeout_read(timeout).timeout_write(timeout) } else { builder.timeout(timeout) };
|
||||
if let Some(proxy) = settings.proxy_for(url)? {
|
||||
builder = builder.proxy(proxy);
|
||||
}
|
||||
Ok(builder.build())
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn get(url: &str) -> Result<ureq::Response> {
|
||||
let settings = crate::proxy::current();
|
||||
crate::github_fallback::run(settings.github_auto_fallback, url, &[], |fallback| {
|
||||
let mut route=settings.clone();
|
||||
if fallback {route.github=crate::github_fallback::PREFIX.into();route.github_fallback_attempt=true;}
|
||||
get_routed(url, &route, false)
|
||||
})
|
||||
}
|
||||
fn get_routed(url: &str, settings: &crate::proxy::DownloadSettings, download: bool) -> Result<ureq::Response> {
|
||||
let mut url = url::Url::parse(url)?;
|
||||
for _ in 0..=5 {
|
||||
ensure!(url.scheme() == "https", "下载地址必须使用 HTTPS");
|
||||
// Re-evaluate bypass rules for every redirect target.
|
||||
let routed = settings.route(&url);
|
||||
let response = agent(&routed, &settings)?
|
||||
.get(routed.as_str())
|
||||
#[cfg(test)]
|
||||
let transport_url = crate::github_fallback::test_url(routed.as_str());
|
||||
#[cfg(not(test))]
|
||||
let transport_url = routed.to_string();
|
||||
#[cfg(test)]
|
||||
let transport_settings = if transport_url != routed.as_str() { crate::proxy::DownloadSettings {system:false,http:String::new(),..settings.clone()} } else {settings.clone()};
|
||||
#[cfg(not(test))]
|
||||
let transport_settings = settings.clone();
|
||||
let response = agent(&routed, &transport_settings, download)?
|
||||
.get(&transport_url)
|
||||
.set("User-Agent", "Framely-Installer")
|
||||
.call()?;
|
||||
if (300..400).contains(&response.status()) {
|
||||
@@ -63,15 +80,22 @@ pub fn list(repo: &str) -> Result<Vec<Release>> {
|
||||
validate_repo(repo)?;
|
||||
let mut all = Vec::new();
|
||||
for page in 1..=10 {
|
||||
let response = get(&format!(
|
||||
let url=format!(
|
||||
"https://api.github.com/repos/{repo}/releases?per_page=100&page={page}"
|
||||
))?;
|
||||
);
|
||||
let settings=crate::proxy::current();
|
||||
let bytes=crate::github_fallback::run(settings.github_auto_fallback,&url,&[],|fallback| {
|
||||
let mut route=settings.clone();
|
||||
if fallback {route.github=crate::github_fallback::PREFIX.into();route.github_fallback_attempt=true;}
|
||||
let response=get_routed(&url,&route,false)?;
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.into_reader()
|
||||
.take(4 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.len() <= 4 * 1024 * 1024, "Release 列表过大");
|
||||
Ok(bytes)
|
||||
})?;
|
||||
let items: Vec<Release> = serde_json::from_slice(&bytes)?;
|
||||
let finished = items.len() < 100;
|
||||
all.extend(
|
||||
@@ -209,9 +233,17 @@ pub fn download(
|
||||
crate::progress::Progress::new(crate::progress::Stage::Download, &asset.name);
|
||||
starting.total = Some(asset.size);
|
||||
progress(starting);
|
||||
let mut input = get(&asset.browser_download_url)?.into_reader();
|
||||
let mut output = File::create(dest)?;
|
||||
crate::progress::copy(
|
||||
let settings = crate::proxy::current();
|
||||
crate::github_fallback::run(settings.github_auto_fallback, &asset.browser_download_url, &[], |fallback| {
|
||||
let mut route=settings.clone();
|
||||
if fallback {
|
||||
route.github=crate::github_fallback::PREFIX.into();
|
||||
route.github_fallback_attempt=true;
|
||||
progress(crate::progress::Progress::new(crate::progress::Stage::Download, "正在尝试 gh-proxy.com 自动代理"));
|
||||
}
|
||||
let mut input = get_routed(&asset.browser_download_url, &route, true)?.into_reader();
|
||||
let mut output = File::create(dest)?;
|
||||
crate::progress::copy(
|
||||
&mut input,
|
||||
&mut output,
|
||||
asset.size,
|
||||
@@ -219,12 +251,26 @@ pub fn download(
|
||||
&asset.name,
|
||||
progress,
|
||||
)?;
|
||||
output.sync_all()?;
|
||||
Ok(())
|
||||
output.sync_all()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn github_fallback_restarts_a_truncated_installer_download_from_zero() {
|
||||
let (target,server)=crate::github_fallback::local_truncated_server();
|
||||
let source=format!("https://github.com/framely-test/{}/asset",target.rsplit(':').next().unwrap());
|
||||
let _routes=crate::github_fallback::TestRoutes::new(vec![(source.clone(),target.clone()),(format!("{}/{}",crate::github_fallback::PREFIX,source),target)]);
|
||||
let temp=tempfile::tempdir().unwrap();let dest=temp.path().join("archive");let mut progress=vec![];
|
||||
// This test uses an explicit local transport mapping, never the public network.
|
||||
let asset=Asset{name:"test.tar.gz".into(),browser_download_url:source,size:8};
|
||||
download(&asset,&dest,&mut |value| progress.push(value)).unwrap();
|
||||
assert_eq!(std::fs::read(dest).unwrap(),b"new-data");
|
||||
assert!(progress.iter().any(|p|p.detail.contains("gh-proxy.com")));
|
||||
server.join().unwrap();
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn https_requests_use_discovered_desktop_proxy() {
|
||||
|
||||
+144
-27
@@ -23,8 +23,32 @@ pub struct Credentials {
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Probe {
|
||||
pub endpoint: String,
|
||||
pub fingerprint: String,
|
||||
pub known: bool,
|
||||
pub previous_fingerprint: Option<String>,
|
||||
}
|
||||
impl Probe {
|
||||
pub fn changed(&self) -> bool {
|
||||
self.previous_fingerprint
|
||||
.as_ref()
|
||||
.is_some_and(|old| old != &self.fingerprint)
|
||||
}
|
||||
fn confirm(&self, actual: &str, hosts: &Hosts) -> Result<()> {
|
||||
ensure!(
|
||||
actual == self.fingerprint,
|
||||
"SSH 指纹与刚才检查的不一致,请重新检查 SSH 连接"
|
||||
);
|
||||
ensure!(
|
||||
hosts
|
||||
.keys
|
||||
.get(&self.endpoint)
|
||||
.map(|saved| normalized_saved_fingerprint(saved))
|
||||
== self.previous_fingerprint,
|
||||
"已保存的 SSH 指纹记录发生变化,请重新检查 SSH 连接"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub struct Connection {
|
||||
pub session: Session,
|
||||
@@ -44,7 +68,9 @@ fn hosts_file() -> Result<PathBuf> {
|
||||
.join("known-hosts.json"))
|
||||
}
|
||||
fn load_hosts() -> Result<Hosts> {
|
||||
let path = hosts_file()?;
|
||||
load_hosts_from(&hosts_file()?)
|
||||
}
|
||||
fn load_hosts_from(path: &Path) -> Result<Hosts> {
|
||||
if path.exists() {
|
||||
Ok(serde_json::from_slice(&fs::read(path)?)?)
|
||||
} else {
|
||||
@@ -88,20 +114,24 @@ pub fn probe(host: &str, port: u16) -> Result<Probe> {
|
||||
let fingerprint = fingerprint(&session)?;
|
||||
let hosts = load_hosts()?;
|
||||
let endpoint = format!("{host}:{port}");
|
||||
if let Some(saved) = hosts.keys.get(&endpoint) {
|
||||
ensure!(
|
||||
normalized_saved_fingerprint(saved) == fingerprint,
|
||||
"SSH 主机指纹发生变化,已阻止连接;请先核实设备身份"
|
||||
);
|
||||
}
|
||||
Ok(Probe {
|
||||
Ok(observed_probe(endpoint, fingerprint, &hosts))
|
||||
}
|
||||
fn observed_probe(endpoint: String, fingerprint: String, hosts: &Hosts) -> Probe {
|
||||
let previous_fingerprint = hosts
|
||||
.keys
|
||||
.get(&endpoint)
|
||||
.map(|saved| normalized_saved_fingerprint(saved));
|
||||
let known = previous_fingerprint.as_ref() == Some(&fingerprint);
|
||||
Probe {
|
||||
endpoint,
|
||||
fingerprint,
|
||||
known: hosts.keys.contains_key(&endpoint),
|
||||
})
|
||||
known,
|
||||
previous_fingerprint,
|
||||
}
|
||||
}
|
||||
pub fn connect(
|
||||
credentials: Credentials,
|
||||
expected: &str,
|
||||
confirmed: &Probe,
|
||||
progress: &mut dyn FnMut(String),
|
||||
) -> Result<Connection> {
|
||||
ensure!(
|
||||
@@ -118,12 +148,15 @@ pub fn connect(
|
||||
"密码不能包含换行符"
|
||||
);
|
||||
ensure!(!credentials.password.is_empty(), "请输入设备登录密码");
|
||||
ensure!(
|
||||
confirmed.endpoint == format!("{}:{}", credentials.host, credentials.port),
|
||||
"设备地址已变化,请重新检查 SSH 连接"
|
||||
);
|
||||
progress("正在建立 SSH 连接…".into());
|
||||
let session = handshake(&credentials.host, credentials.port).context("无法建立 SSH 连接")?;
|
||||
ensure!(
|
||||
fingerprint(&session)? == expected,
|
||||
"SSH 指纹与刚才检查的不一致"
|
||||
);
|
||||
// The Connect button confirms this exact observation, including a changed
|
||||
// key. Recheck before sending credentials; never accept a second change.
|
||||
confirmed.confirm(&fingerprint(&session)?, &load_hosts()?)?;
|
||||
progress("正在验证用户名和密码…".into());
|
||||
session
|
||||
.userauth_password(&credentials.user, &credentials.password)
|
||||
@@ -161,16 +194,7 @@ pub fn connect(
|
||||
"未安装"
|
||||
});
|
||||
progress("正在保存设备指纹…".into());
|
||||
let path = hosts_file()?;
|
||||
fs::create_dir_all(path.parent().unwrap())
|
||||
.context("无法创建指纹配置目录,请检查本机目录权限")?;
|
||||
let mut hosts = load_hosts()?;
|
||||
hosts.keys.insert(
|
||||
format!("{}:{}", credentials.host, credentials.port),
|
||||
expected.into(),
|
||||
);
|
||||
fs::write(path, serde_json::to_vec_pretty(&hosts)?)
|
||||
.context("无法保存设备指纹,请检查本机配置目录权限")?;
|
||||
remember_probe(&hosts_file()?, confirmed)?;
|
||||
Ok(Connection {
|
||||
session,
|
||||
host: credentials.host,
|
||||
@@ -179,6 +203,21 @@ pub fn connect(
|
||||
installation,
|
||||
})
|
||||
}
|
||||
fn remember_probe(path: &Path, confirmed: &Probe) -> Result<()> {
|
||||
let parent = path.parent().context("无效的指纹配置路径")?;
|
||||
fs::create_dir_all(parent).context("无法创建指纹配置目录,请检查本机目录权限")?;
|
||||
let mut hosts = load_hosts_from(path)?;
|
||||
confirmed.confirm(&confirmed.fingerprint, &hosts)?;
|
||||
hosts
|
||||
.keys
|
||||
.insert(confirmed.endpoint.clone(), confirmed.fingerprint.clone());
|
||||
let mut file = tempfile::NamedTempFile::new_in(parent)?;
|
||||
file.write_all(&serde_json::to_vec_pretty(&hosts)?)?;
|
||||
file.as_file().sync_all()?;
|
||||
file.persist(path)
|
||||
.context("无法保存设备指纹,请检查本机配置目录权限")?;
|
||||
Ok(())
|
||||
}
|
||||
fn read_installation(
|
||||
session: &Session,
|
||||
password: &str,
|
||||
@@ -368,7 +407,8 @@ pub fn operate(
|
||||
transfer.completed = super::BOOTSTRAP.len() as u64;
|
||||
progress(transfer);
|
||||
let mut args = format!(
|
||||
"sudo -S -p '' -k -- python3 -u {} {} --yes --progress-json --repo {} --user {}",
|
||||
"sudo -S -p '' -k -- env FRAMELY_GITHUB_AUTO_FALLBACK={} python3 -u {} {} --yes --progress-json --repo {} --user {}",
|
||||
if crate::proxy::current().github_auto_fallback { "1" } else { "0" },
|
||||
quote(&script),
|
||||
quote(action),
|
||||
quote(repo),
|
||||
@@ -462,6 +502,83 @@ mod tests {
|
||||
normalized_saved_fingerprint(&format!("SHA256:{}", hex::encode([1_u8; 32]))),
|
||||
standard
|
||||
);
|
||||
let hosts = Hosts {
|
||||
keys: BTreeMap::from([("frame:22".into(), format!("SHA256:{}", hex::encode(digest)))]),
|
||||
};
|
||||
let probe = observed_probe("frame:22".into(), standard.clone(), &hosts);
|
||||
assert!(probe.known && !probe.changed());
|
||||
probe.confirm(&standard, &hosts).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn changed_fingerprint_can_be_confirmed_without_losing_other_devices() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("known-hosts.json");
|
||||
let old = "SHA256:old";
|
||||
let new = "SHA256:new";
|
||||
let hosts = Hosts {
|
||||
keys: BTreeMap::from([
|
||||
("frame:22".into(), old.into()),
|
||||
("other:2222".into(), "SHA256:other".into()),
|
||||
]),
|
||||
};
|
||||
fs::write(&path, serde_json::to_vec(&hosts).unwrap()).unwrap();
|
||||
let fresh = observed_probe("fresh:22".into(), new.into(), &hosts);
|
||||
assert!(!fresh.known && !fresh.changed() && fresh.previous_fingerprint.is_none());
|
||||
let changed = observed_probe("frame:22".into(), new.into(), &hosts);
|
||||
assert!(!changed.known && changed.changed());
|
||||
assert_eq!(changed.previous_fingerprint.as_deref(), Some(old));
|
||||
// Merely inspecting or cancelling leaves the original record intact.
|
||||
assert_eq!(load_hosts_from(&path).unwrap().keys["frame:22"], old);
|
||||
assert!(changed.confirm("SHA256:changed-again", &hosts).is_err());
|
||||
assert_eq!(load_hosts_from(&path).unwrap().keys["frame:22"], old);
|
||||
changed.confirm(new, &hosts).unwrap();
|
||||
remember_probe(&path, &changed).unwrap();
|
||||
let saved = load_hosts_from(&path).unwrap();
|
||||
assert_eq!(saved.keys["frame:22"], new);
|
||||
assert_eq!(saved.keys["other:2222"], "SHA256:other");
|
||||
let next = observed_probe("frame:22".into(), new.into(), &saved);
|
||||
assert!(next.known && !next.changed());
|
||||
next.confirm(new, &saved).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn stale_fingerprint_confirmation_does_not_replace_a_newer_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("known-hosts.json");
|
||||
let mut hosts = Hosts {
|
||||
keys: BTreeMap::from([("frame:22".into(), "SHA256:old".into())]),
|
||||
};
|
||||
let pending = observed_probe("frame:22".into(), "SHA256:new".into(), &hosts);
|
||||
hosts
|
||||
.keys
|
||||
.insert("frame:22".into(), "SHA256:confirmed-elsewhere".into());
|
||||
fs::write(&path, serde_json::to_vec(&hosts).unwrap()).unwrap();
|
||||
assert!(pending.confirm(&pending.fingerprint, &hosts).is_err());
|
||||
assert!(remember_probe(&path, &pending).is_err());
|
||||
assert_eq!(
|
||||
load_hosts_from(&path).unwrap().keys["frame:22"],
|
||||
"SHA256:confirmed-elsewhere"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn confirmed_fingerprint_is_bound_to_the_checked_address_and_port() {
|
||||
let pending = observed_probe("frame:22".into(), "SHA256:new".into(), &Hosts::default());
|
||||
for (host, port) in [("other", 22), ("frame", 2222)] {
|
||||
let mut stages = vec![];
|
||||
let error = connect(
|
||||
Credentials {
|
||||
host: host.into(),
|
||||
port,
|
||||
user: "steamos".into(),
|
||||
password: Zeroizing::new("test".into()),
|
||||
},
|
||||
&pending,
|
||||
&mut |stage| stages.push(stage),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.to_string().contains("设备地址已变化"));
|
||||
assert!(stages.is_empty());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Requires an explicitly supplied SSH device and trusted OpenSSH fingerprints"]
|
||||
@@ -491,7 +608,7 @@ mod tests {
|
||||
user: "steamos".into(),
|
||||
password: Zeroizing::new("framely-invalid-password-test-9c45bc39".into()),
|
||||
},
|
||||
&probe.fingerprint,
|
||||
&probe,
|
||||
&mut |stage| stages.push(stage),
|
||||
);
|
||||
let error = match result {
|
||||
|
||||
+91
-15
@@ -336,6 +336,9 @@ impl Installer {
|
||||
this.child(field("扫描范围", &self.cidr, self.busy || self.scanning))
|
||||
});
|
||||
let mut connection = panel()
|
||||
.when(self.probe.as_ref().is_some_and(Probe::changed), |this| {
|
||||
this.gap_3().p_5()
|
||||
})
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
@@ -391,32 +394,99 @@ impl Installer {
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(icon(IconName::ShieldCheck))
|
||||
.child(div().text_sm().child(if probe.known {
|
||||
.child(div().text_sm().child(if probe.changed() {
|
||||
"设备指纹已变化"
|
||||
} else if probe.known {
|
||||
"设备指纹与上次一致"
|
||||
} else {
|
||||
"首次连接,请核对设备指纹"
|
||||
})),
|
||||
)
|
||||
.when(probe.changed(), |this| {
|
||||
this.child(muted("设备重装后,请核对并信任新指纹,继续连接。"))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("上次指纹"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(12.))
|
||||
.text_color(rgb(MUTED))
|
||||
.child(
|
||||
probe
|
||||
.previous_fingerprint
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(12.))
|
||||
.text_color(rgb(BLUE))
|
||||
.child(probe.fingerprint.clone()),
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.when(probe.changed(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("当前指纹"),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(12.))
|
||||
.text_color(rgb(BLUE))
|
||||
.child(probe.fingerprint.clone()),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("connect")
|
||||
.h(px(42.))
|
||||
.primary()
|
||||
.label(if self.connection_stage.is_some() {
|
||||
"正在连接…"
|
||||
} else {
|
||||
"确认指纹并连接"
|
||||
})
|
||||
.loading(self.connection_stage.is_some())
|
||||
.disabled(locked)
|
||||
.on_click(cx.listener(|view, _, _, cx| view.connect(cx))),
|
||||
div()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
Button::new("connect")
|
||||
.flex_1()
|
||||
.h(px(42.))
|
||||
.primary()
|
||||
.label(if self.connection_stage.is_some() {
|
||||
"正在连接…"
|
||||
} else if probe.changed() {
|
||||
"信任新指纹并连接"
|
||||
} else {
|
||||
"确认指纹并连接"
|
||||
})
|
||||
.loading(self.connection_stage.is_some())
|
||||
.disabled(locked)
|
||||
.on_click(cx.listener(|view, _, _, cx| view.connect(cx))),
|
||||
)
|
||||
.when(probe.changed(), |this| {
|
||||
this.child(
|
||||
Button::new("cancel-fingerprint-change")
|
||||
.h(px(42.))
|
||||
.ghost()
|
||||
.label("取消")
|
||||
.disabled(locked)
|
||||
.on_click(cx.listener(|view, _, _, cx| {
|
||||
view.probe = None;
|
||||
view.error = None;
|
||||
view.status = "未更改已保存的设备指纹。".into();
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
connection = connection.child(
|
||||
@@ -491,6 +561,12 @@ impl Installer {
|
||||
.child(field("HTTP 代理", &self.http_proxy, self.busy))
|
||||
.child(muted("留空时使用系统代理开关的选择。支持 HTTP 和 SOCKS 代理。"))
|
||||
.child(field("GitHub 代理", &self.github_proxy, self.busy))
|
||||
.child(div().flex().items_center().justify_between()
|
||||
.child(div().flex().flex_col().gap_2().child("GitHub 连接失败时自动使用 gh-proxy.com")
|
||||
.child(muted("默认开启。仅在网络失败时尝试一次;公开请求将经过第三方代理,仍保留包校验。")))
|
||||
.child(Switch::new("github-auto-fallback").checked(self.github_auto_fallback)
|
||||
.accessibility_label("GitHub 连接失败时自动使用 gh-proxy.com").disabled(self.busy)
|
||||
.on_change(cx.listener(|view, checked, _, cx| { view.github_auto_fallback = *checked; cx.notify(); }))))
|
||||
.child(muted("留空直接访问 GitHub。填写 HTTPS 前缀地址后,GitHub 请求按“代理地址/原始 URL”转发;仍保留包校验。"))
|
||||
.when(self.error.is_some(), |this| this.child(div().text_color(rgb(0xf7a7a7)).child(self.error.clone().unwrap())))
|
||||
.child(div().flex().justify_end().child(Button::new("save-proxy").primary().label("保存")
|
||||
|
||||
@@ -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("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)
|
||||
@@ -40,7 +40,7 @@ class RecordingIndicator {
|
||||
vr::IVROverlay* overlay=nullptr;vr::VROverlayHandle_t handle=0;
|
||||
std::string id;uint64_t rendered=~uint64_t(0);bool last_hover=false;std::array<RecordingIndicatorClick,2> clicks{};std::array<bool,2> hover{};
|
||||
public:
|
||||
void destroy(){if(handle&&overlay)overlay->DestroyOverlay(handle);handle=0;id.clear();hover={};}
|
||||
void destroy(){if(handle&&overlay)overlay->DestroyOverlay(handle);handle=0;id.clear();cancel_input();}
|
||||
bool active()const{return handle&&!id.empty();}
|
||||
void cancel_input(){clicks={};hover={};}
|
||||
bool hovered(int hand)const{return hand>=0&&hand<2&&hover[hand];}
|
||||
|
||||
@@ -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));}
|
||||
};
|
||||
Generated
+292
@@ -12,6 +12,18 @@
|
||||
"examples/showcase"
|
||||
],
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
"@codemirror/lang-html": "6.4.12",
|
||||
"@codemirror/lang-javascript": "6.2.5",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-rust": "6.0.2",
|
||||
"@codemirror/language": "6.13.1",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@tabler/icons-react": "3.48.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
@@ -34,6 +46,172 @@
|
||||
"@framely/sdk": "0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
|
||||
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-python": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
|
||||
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.3.2",
|
||||
"@codemirror/language": "^6.8.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/python": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-rust": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
|
||||
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/rust": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
|
||||
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/streamparser": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
|
||||
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/streamparser": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
|
||||
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.14",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
|
||||
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
|
||||
@@ -488,6 +666,102 @@
|
||||
"resolved": "ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
|
||||
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
|
||||
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
|
||||
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
|
||||
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
|
||||
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/python": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz",
|
||||
"integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/rust": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.3.tgz",
|
||||
"integrity": "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@resvg/resvg-js": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
|
||||
@@ -777,6 +1051,12 @@
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -853,6 +1133,12 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -867,6 +1153,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"sdk": {
|
||||
"name": "@framely/sdk",
|
||||
"version": "0.4.0",
|
||||
|
||||
@@ -15,6 +15,18 @@
|
||||
"plugin:init": "node tools/plugin-dev.mjs init"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
"@codemirror/lang-html": "6.4.12",
|
||||
"@codemirror/lang-javascript": "6.2.5",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-rust": "6.0.2",
|
||||
"@codemirror/language": "6.13.1",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@tabler/icons-react": "3.48.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
|
||||
@@ -21,7 +21,13 @@ if [[ $installer_cgroup =~ /framely(-session|-ui-recovery|-backend-[^/]+|-plugin
|
||||
worker_args=("$steam_user")
|
||||
if $repair; then worker_args=(--repair "$steam_user"); fi
|
||||
install_unit="framely-install-$$-$(date +%s)"
|
||||
worker_environment=()
|
||||
if [[ -n ${FRAMELY_GITHUB_AUTO_FALLBACK+x} ]]; then
|
||||
[[ $FRAMELY_GITHUB_AUTO_FALLBACK == 0 || $FRAMELY_GITHUB_AUTO_FALLBACK == 1 ]] || { echo 'Invalid automatic GitHub fallback preference.' >&2; exit 1; }
|
||||
worker_environment+=("--setenv=FRAMELY_GITHUB_AUTO_FALLBACK=$FRAMELY_GITHUB_AUTO_FALLBACK")
|
||||
fi
|
||||
systemd-run --quiet --collect --no-block --unit="$install_unit" \
|
||||
"${worker_environment[@]}" \
|
||||
--property=Type=exec --property=StandardInput=null \
|
||||
--property=StandardOutput=journal --property=StandardError=journal \
|
||||
/usr/bin/bash "$base/install.sh" "${worker_args[@]}"
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
+22
-7
@@ -1,16 +1,28 @@
|
||||
type Editable=HTMLInputElement|HTMLTextAreaElement;
|
||||
export function installKeyboard(open:(params:{existing:string;password:boolean;multiline:boolean})=>Promise<unknown>){
|
||||
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>,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)=>{
|
||||
if(!(target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement)||target.disabled||target.readOnly)return;
|
||||
const embedded=target instanceof HTMLElement&&target.matches('[data-framely-editor-input][contenteditable="true"]');
|
||||
if(!embedded&&(!(target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement)||target.disabled||target.readOnly))return;
|
||||
if(!(target instanceof HTMLElement))return;
|
||||
if(target instanceof HTMLInputElement&&!['text','password','url','search','email','number','tel'].includes(target.type))return;
|
||||
field=target;const now=performance.now();if(last===target&&now-openedAt<300)return;last=target;openedAt=now;
|
||||
void open({existing:target.value,password:target instanceof HTMLInputElement&&target.type==='password',multiline:target instanceof HTMLTextAreaElement}).catch(console.error);
|
||||
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 (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;
|
||||
if(!field.dispatchEvent(new CustomEvent('framely.keyboard.commit',{detail:value,cancelable:true})))return;
|
||||
if(!(field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement))return;
|
||||
const proto=field instanceof HTMLTextAreaElement?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;
|
||||
Object.getOwnPropertyDescriptor(proto,'value')!.set!.call(field,value);
|
||||
field.dispatchEvent(new Event('input',{bubbles:true}));field.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
@@ -18,9 +30,11 @@ export function installKeyboard(open:(params:{existing:string;password:boolean;m
|
||||
};
|
||||
(window as any).__framelyEditKeyboard=(edit:string)=>{
|
||||
if(!field?.isConnected)return;
|
||||
if(field.closest('[inert]')||((field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement)&&(field.disabled||field.readOnly)))return;
|
||||
// Embedded editors can consume the native keyboard without mutating their
|
||||
// hidden textarea. Ordinary inputs continue through the path below.
|
||||
if(!field.dispatchEvent(new CustomEvent('framely.keyboard.edit',{detail:edit,cancelable:true})))return;
|
||||
if(!(field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement))return;
|
||||
const value=field.value;
|
||||
let start=field.selectionStart??value.length,end=field.selectionEnd??start;
|
||||
const segments=Array.from(new (Intl as any).Segmenter(undefined,{granularity:'grapheme'}).segment(value)) as {index:number;segment:string}[];
|
||||
@@ -50,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);}};
|
||||
}
|
||||
+168
-22
@@ -2,6 +2,7 @@
|
||||
use crate::{
|
||||
apk_metadata::{self, Metadata},
|
||||
jobs::Cancellation,
|
||||
model::ProxySettings,
|
||||
};
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -22,6 +23,7 @@ use std::{
|
||||
};
|
||||
mod cleanup;
|
||||
mod graphics;
|
||||
mod install_rollback;
|
||||
mod lifecycle;
|
||||
mod native;
|
||||
pub(crate) mod permissions;
|
||||
@@ -439,17 +441,16 @@ fn containers(home: &Path, db: &Database) -> Vec<Container> {
|
||||
for id in &db.owned_contexts {
|
||||
if safe(id) && !out.iter().any(|c| c.id == *id) {
|
||||
let baked = managed_base.join(id).join("baked");
|
||||
if baked.parent().is_some_and(|p| p.is_dir())
|
||||
&& !fs::symlink_metadata(&baked).is_ok_and(|m| m.file_type().is_symlink())
|
||||
{
|
||||
out.push(Container {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
baked,
|
||||
running: false,
|
||||
managed: true,
|
||||
steam: false,
|
||||
});
|
||||
let candidate = Container {
|
||||
id: id.clone(),
|
||||
name: id.clone(),
|
||||
baked,
|
||||
running: false,
|
||||
managed: true,
|
||||
steam: false,
|
||||
};
|
||||
if cleanup::validate_location(home, db, &candidate).is_ok() {
|
||||
out.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -656,7 +657,8 @@ fn automatic_install_context(
|
||||
|
||||
fn installed(home: &Path, c: &Container) -> Result<Vec<(Metadata, PathBuf)>> {
|
||||
let p = c.baked.join("data_overlay/system/packages.xml");
|
||||
let bytes = fs::read(&p)?;
|
||||
let bytes = fs::read(&p)
|
||||
.with_context(|| format!("Cannot read Android package database {}", p.display()))?;
|
||||
ensure!(
|
||||
bytes.len() <= 16 * 1024 * 1024,
|
||||
"Package database is too large"
|
||||
@@ -2788,8 +2790,9 @@ pub fn q2f_convert(
|
||||
ticket: &str,
|
||||
cancel: &Cancellation,
|
||||
progress: impl Fn(Value) -> Result<()>,
|
||||
proxy: &ProxySettings,
|
||||
) -> Result<Value> {
|
||||
q2f::convert(home, ticket, cancel, progress)
|
||||
q2f::convert(home, ticket, cancel, progress, proxy)
|
||||
}
|
||||
pub fn q2f_status(home: &Path) -> Result<Value> {
|
||||
q2f::status(home)
|
||||
@@ -3083,8 +3086,9 @@ fn operate_internal(
|
||||
}
|
||||
}
|
||||
let packages = if c.baked.exists() {
|
||||
installed(home, &c)?
|
||||
installed(home, &c).with_context(|| format!("Existing container is unreadable; inspect its log or explicitly clean the damaged container before retrying: {}", c.baked.display()))?
|
||||
} else {
|
||||
ensure!(!cs.iter().any(|old| old.id == c.id), "Existing container is incomplete; explicitly clean the damaged container before retrying: {}", c.baked.display());
|
||||
Vec::new()
|
||||
};
|
||||
let previous = packages
|
||||
@@ -3117,6 +3121,12 @@ fn operate_internal(
|
||||
fs::metadata(review.join("base.apk"))?.len() * 4
|
||||
+ if c.baked.exists() { size(&c.baked) } else { 0 },
|
||||
)?;
|
||||
let fresh=if !cs.iter().any(|old|old.id==c.id)
|
||||
&& !db.records.values().any(|record|record.context==c.id) {
|
||||
Some(install_rollback::NewContext::reserve(home,&db,&c)?)
|
||||
} else {None};
|
||||
let mut backup_id=None;
|
||||
let attempt=(|| -> Result<Value> {
|
||||
let rec = db.records.entry(app_id.clone()).or_insert_with(|| Record {
|
||||
id: app_id.clone(),
|
||||
context: c.id.clone(),
|
||||
@@ -3135,18 +3145,39 @@ fn operate_internal(
|
||||
// Before submission cancellation remains possible. Package-manager submission is a commit.
|
||||
cancel.commit(||{
|
||||
progress(json!({"cancellable":false}));
|
||||
let mut backup_id=None;if c.baked.exists(){progress(json!({"phase":"backing-up"}));stop(&c,&log)?;backup_id=Some(backup(home,&c,&db,&log)?);}
|
||||
progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
|
||||
if fresh.is_none() && c.baked.exists(){progress(json!({"phase":"backing-up"}));stop(&c,&log)?;backup_id=Some(backup(home,&c,&db,&log)?);}
|
||||
progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log).with_context(||format!("Cannot initialize Android container {}",c.baked.display()))?;
|
||||
let sdk=podman(&["exec",&format!("lepton-{}",c.name),"getprop","ro.build.version.sdk"],Some(&log))?.trim().parse::<u32>()?;ensure!(metadata.min_sdk<=sdk,"APK requires Android SDK {}, container provides {}",metadata.min_sdk,sdk);
|
||||
progress(json!({"phase":"installing","cancellable":false}));install_package(&c,&review.join("base.apk"),&log)?;
|
||||
progress(json!({"phase":"verifying"}));let path=podman(&["exec",&format!("lepton-{}",c.name),"pm","path",&metadata.package],Some(&log))?;ensure!(path.contains("package:"),"Android did not report the installed APK");let dest=r.join("apks").join(hash(&app_id));store_apk(&review.join("base.apk"),&dest)?;
|
||||
// Installation boots a headless container solely for the package manager.
|
||||
// Finish that runtime before Steam takes ownership of the first app launch.
|
||||
stop(&c,&log)?;
|
||||
let rec=db.records.get_mut(&app_id).unwrap();rec.metadata=metadata.clone();rec.removed=false;rec.pending=None;rec.show_window=settings.show_window;rec.q2f_version=q2f_version;save(home,&db)?;fs::remove_dir_all(review)?;
|
||||
let rec=db.records.get_mut(&app_id).unwrap();rec.metadata=metadata.clone();rec.removed=false;rec.pending=None;rec.show_window=settings.show_window;rec.q2f_version=q2f_version;save(home,&db)?;
|
||||
// Review cleanup cannot turn a committed installation into a failed one.
|
||||
if let Err(error)=fs::remove_dir_all(&review) {let _=fs::OpenOptions::new().append(true).create(true).open(&log).and_then(|mut f|writeln!(f,"Review cleanup failed: {error}"));}
|
||||
// Mirror the operation log under the stable application id for newly installed apps.
|
||||
if id!=app_id {let _=fs::copy(&log,r.join("logs").join(format!("{}-{}.log",hash(&app_id),now())));}
|
||||
Ok(json!({"app":app_id,"backup":backup_id,"launchable":!metadata.activities.is_empty()}))})
|
||||
Ok(json!({"app":app_id,"backup":backup_id.clone(),"launchable":!metadata.activities.is_empty()}))})
|
||||
})();
|
||||
match attempt {
|
||||
Ok(value)=>Ok(value),
|
||||
Err(error)=>{
|
||||
let _=fs::OpenOptions::new().append(true).create(true).open(&log).and_then(|mut f|writeln!(f,"APK installation failed: {error:#}"));
|
||||
if let Some(fresh)=fresh {
|
||||
progress(json!({"phase":"rolling-back","cancellable":false}));
|
||||
match fresh.rollback(home,&mut db,&c,&log) {
|
||||
Ok(())=>bail!("New installation failed; newly created container was cleaned up: {error:#}"),
|
||||
Err(cleanup_error)=>{
|
||||
let _=fs::OpenOptions::new().append(true).create(true).open(&log).and_then(|mut f|writeln!(f,"New container rollback failed: {cleanup_error:#}"));
|
||||
bail!("Installation failed; container retained because safe cleanup failed: {error:#}; cleanup: {cleanup_error:#}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bail!("Installation failed; existing container and data retained (backup: {}): {error:#}",backup_id.as_deref().unwrap_or("not created"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"reconcile" => {
|
||||
let cs = containers(home, &db);
|
||||
@@ -3527,10 +3558,11 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
|
||||
.any(|other| other.name == c.name && other.baked != c.baked),
|
||||
"Container name is ambiguous; correct its data location first"
|
||||
);
|
||||
ensure!(
|
||||
validate_baked(&c.baked)? == c.baked,
|
||||
"Container data location changed"
|
||||
);
|
||||
if kind == "container.delete" {
|
||||
cleanup::validate_location(home, &db, &c)?;
|
||||
} else {
|
||||
ensure!(validate_baked(&c.baked)? == c.baked, "Container data location changed");
|
||||
}
|
||||
cancel.commit(|| {
|
||||
progress(json!({"cancellable":false}));
|
||||
match kind {
|
||||
@@ -4605,6 +4637,120 @@ echo true > '{}'
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn failed_install_new_context_is_rolled_back_and_logs_retained() {
|
||||
let f = Fixture::new();
|
||||
fs::write(f.dir.path().join("running"), "missing").unwrap();
|
||||
let entry = f.home.join(".local/share/Steam/steamapps/common/Lepton/lepton");
|
||||
// A missing interpreter reproduces ENOENT after context reservation.
|
||||
let script = fs::read_to_string(&entry).unwrap().replace("#!/bin/bash", "#!/missing-framely-test-interpreter");
|
||||
fs::write(entry, script).unwrap();
|
||||
let ticket = f.review(43);
|
||||
let old_log = root(&f.home).join("logs").join(format!("{}-previous.log", hash("new-test/com.example.app")));
|
||||
fs::write(&old_log, "previous failure").unwrap();
|
||||
let error = f.operation("install", json!({"context":"new-test","ticket":ticket,"approve":true})).unwrap_err();
|
||||
assert!(format!("{error:#}").contains("newly created container was cleaned up"), "{error:#}");
|
||||
assert!(format!("{error:#}").contains("Cannot initialize Android container"), "{error:#}");
|
||||
assert!(format!("{error:#}").contains("No such file or directory"), "{error:#}");
|
||||
assert!(!contexts_root(&f.home).join("new-test").exists());
|
||||
let db = load(&f.home).unwrap();
|
||||
assert!(!db.owned_contexts.iter().any(|id| id == "new-test"));
|
||||
assert!(!db.records.values().any(|r| r.context == "new-test"));
|
||||
assert_eq!(fs::read_to_string(f.save_file()).unwrap(), "saved progress");
|
||||
assert_eq!(fs::read_to_string(old_log).unwrap(), "previous failure");
|
||||
assert!(fs::read_dir(root(&f.home).join("logs")).unwrap().any(|entry|
|
||||
fs::read_to_string(entry.unwrap().path()).unwrap().contains("APK installation failed")));
|
||||
}
|
||||
#[test]
|
||||
fn failed_install_unsafe_rollback_retains_container_and_record() {
|
||||
let f = Fixture::new();
|
||||
let mut db = load(&f.home).unwrap();
|
||||
let c = Container { id:"new-test".into(), name:"new-test".into(), baked:contexts_root(&f.home).join("new-test/baked"), running:false, managed:true, steam:false };
|
||||
db.owned_contexts.push(c.id.clone());
|
||||
fs::write(f.dir.path().join("running"), "missing").unwrap();
|
||||
let reserved = install_rollback::NewContext::reserve(&f.home, &db, &c).unwrap();
|
||||
db.records.insert("new-test/com.example.app".into(), Record { id:"new-test/com.example.app".into(), context:c.id.clone(), ..Default::default() });
|
||||
save(&f.home, &db).unwrap();
|
||||
fs::write(f.dir.path().join("running"), "query-error").unwrap();
|
||||
let log = root(&f.home).join("logs/rollback-test.log");
|
||||
fs::write(&log, "original installation failure").unwrap();
|
||||
assert!(reserved.rollback(&f.home, &mut db, &c, &log).is_err());
|
||||
assert!(c.baked.parent().unwrap().exists());
|
||||
assert!(load(&f.home).unwrap().records.contains_key("new-test/com.example.app"));
|
||||
assert!(log.exists());
|
||||
}
|
||||
#[test]
|
||||
fn failed_install_reservation_never_adopts_existing_or_replaced_directories() {
|
||||
let f = Fixture::new();
|
||||
let mut db = load(&f.home).unwrap();
|
||||
let parent = contexts_root(&f.home).join("new-test");
|
||||
let c = Container { id:"new-test".into(), name:"new-test".into(), baked:parent.join("baked"), running:false, managed:true, steam:false };
|
||||
db.owned_contexts.push(c.id.clone());
|
||||
fs::write(f.dir.path().join("running"), "missing").unwrap();
|
||||
fs::create_dir(&parent).unwrap();
|
||||
fs::write(parent.join("save"), "old data").unwrap();
|
||||
assert!(install_rollback::NewContext::reserve(&f.home, &db, &c).is_err());
|
||||
let old = f.dir.path().join("old-data");
|
||||
fs::rename(&parent, &old).unwrap();
|
||||
let reservation = install_rollback::NewContext::reserve(&f.home, &db, &c).unwrap();
|
||||
let reserved = f.dir.path().join("reserved");
|
||||
fs::rename(&parent, &reserved).unwrap();
|
||||
fs::create_dir(&parent).unwrap();
|
||||
fs::write(parent.join("save"), "replacement data").unwrap();
|
||||
assert!(reservation.rollback(&f.home, &mut db, &c, &root(&f.home).join("logs/test.log")).is_err());
|
||||
assert_eq!(fs::read_to_string(old.join("save")).unwrap(), "old data");
|
||||
assert_eq!(fs::read_to_string(parent.join("save")).unwrap(), "replacement data");
|
||||
assert!(reserved.exists());
|
||||
}
|
||||
#[test]
|
||||
fn damaged_container_owned_cleanup_preserves_backups() {
|
||||
let f = Fixture::new();
|
||||
list(&f.home).unwrap();
|
||||
let mut db = load(&f.home).unwrap();
|
||||
db.owned_contexts.push("test".into());
|
||||
save(&f.home, &db).unwrap();
|
||||
fs::remove_file(contexts_root(&f.home).join("test/baked/data_overlay/system/packages.xml")).unwrap();
|
||||
let ticket = f.review(43);
|
||||
let error = f.operation("install", json!({"context":"test","ticket":ticket,"approve":true})).unwrap_err();
|
||||
assert!(format!("{error:#}").contains("Existing container is unreadable"));
|
||||
assert_eq!(fs::read_to_string(f.save_file()).unwrap(), "saved progress");
|
||||
let backup = root(&f.home).join("backups/keep");
|
||||
fs::create_dir_all(&backup).unwrap();
|
||||
fs::write(backup.join("save"), "backup").unwrap();
|
||||
assert!(f.operation("container.delete", json!({"context":"test","approve":true})).is_ok());
|
||||
assert!(!contexts_root(&f.home).join("test").exists());
|
||||
assert!(backup.join("save").exists());
|
||||
assert!(!load(&f.home).unwrap().records.contains_key("test/com.example.app"));
|
||||
}
|
||||
#[test]
|
||||
fn damaged_container_unowned_or_symlink_cleanup_is_rejected() {
|
||||
let f = Fixture::new();
|
||||
let mut db = load(&f.home).unwrap();
|
||||
let baked = contexts_root(&f.home).join("test/baked");
|
||||
fs::remove_file(baked.join("data_overlay/system/packages.xml")).unwrap();
|
||||
let c = Container { id:"test".into(), name:"test".into(), baked:baked.clone(), running:false, managed:true, steam:false };
|
||||
assert!(cleanup::validate_location(&f.home, &db, &c).is_err());
|
||||
db.owned_contexts.push("test".into());
|
||||
let moved = f.dir.path().join("preserved-data");
|
||||
fs::rename(&baked, &moved).unwrap();
|
||||
std::os::unix::fs::symlink(&moved, &baked).unwrap();
|
||||
assert!(cleanup::validate_location(&f.home, &db, &c).is_err());
|
||||
assert_eq!(fs::read_to_string(moved.join("data_overlay/data/com.example.app/files/save")).unwrap(), "saved progress");
|
||||
}
|
||||
#[test]
|
||||
fn damaged_container_missing_directory_can_clear_owned_journal() {
|
||||
let f = Fixture::new();
|
||||
list(&f.home).unwrap();
|
||||
let mut db = load(&f.home).unwrap();
|
||||
db.owned_contexts.push("test".into());
|
||||
save(&f.home, &db).unwrap();
|
||||
fs::remove_dir_all(contexts_root(&f.home).join("test")).unwrap();
|
||||
fs::write(f.dir.path().join("running"), "missing").unwrap();
|
||||
assert!(list(&f.home).unwrap()["containers"].as_array().unwrap().iter().any(|c| c["id"] == "test"));
|
||||
f.operation("container.delete", json!({"context":"test","approve":true})).unwrap();
|
||||
assert!(!load(&f.home).unwrap().records.contains_key("test/com.example.app"));
|
||||
assert!(!load(&f.home).unwrap().owned_contexts.iter().any(|id| id == "test"));
|
||||
}
|
||||
#[test]
|
||||
fn unchanged_inventory_preserves_state_file_and_changes_still_persist() {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let f = Fixture::new();
|
||||
|
||||
+94
-8
@@ -16,6 +16,57 @@ fn directory(path: &Path) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// A failed first boot has no packages.xml. Only explicit Framely ownership
|
||||
// plus an exact, non-symlink managed path may replace that validation.
|
||||
pub(super) fn validate_location(home: &Path, db: &Database, c: &Container) -> Result<()> {
|
||||
if let Ok(path) = validate_baked(&c.baked) {
|
||||
ensure!(path == c.baked, "Container data location changed");
|
||||
return Ok(());
|
||||
}
|
||||
ensure!(
|
||||
c.managed
|
||||
&& !c.steam
|
||||
&& c.id == c.name
|
||||
&& safe(&c.id)
|
||||
&& !c.id.starts_with("steamlaunch-")
|
||||
&& db.owned_contexts.contains(&c.id),
|
||||
"Unreadable containers can only be cleaned when owned by Framely"
|
||||
);
|
||||
let base = contexts_root(home);
|
||||
ensure!(
|
||||
base.is_absolute() && fs::canonicalize(&base)? == base,
|
||||
"Managed container location changed"
|
||||
);
|
||||
let parent = base.join(&c.id);
|
||||
ensure!(
|
||||
c.baked == parent.join("baked"),
|
||||
"Container data location changed"
|
||||
);
|
||||
for path in [&base, &parent, &c.baked] {
|
||||
directory(path)?;
|
||||
if path.exists() {
|
||||
ensure!(
|
||||
fs::canonicalize(path)? == *path,
|
||||
"Container cleanup path contains a link"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn ensure_runtime_absent(c: &Container) -> Result<()> {
|
||||
ensure!(
|
||||
runtime(&c.name, &c.baked, true)?.is_none(),
|
||||
"Container runtime already exists; refusing to replace it"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn identity(path: &Path) -> Result<Option<(u64, u64)>> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(m) => Ok(Some((m.dev(), m.ino()))),
|
||||
Err(e) if absent(&e) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
fn remove_owned(path: &Path) -> Result<()> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(m) => {
|
||||
@@ -80,6 +131,24 @@ fn runtime(name: &str, baked: &Path, original: bool) -> Result<Option<String>> {
|
||||
}
|
||||
|
||||
pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path) -> Result<()> {
|
||||
delete_internal(home, db, c, log, false)
|
||||
}
|
||||
pub(super) fn delete_failed_install(
|
||||
home: &Path,
|
||||
db: &mut Database,
|
||||
c: &Container,
|
||||
log: &Path,
|
||||
) -> Result<()> {
|
||||
delete_internal(home, db, c, log, true)
|
||||
}
|
||||
fn delete_internal(
|
||||
home: &Path,
|
||||
db: &mut Database,
|
||||
c: &Container,
|
||||
log: &Path,
|
||||
preserve_logs: bool,
|
||||
) -> Result<()> {
|
||||
validate_location(home, db, c)?;
|
||||
let records: Vec<_> = db
|
||||
.records
|
||||
.values()
|
||||
@@ -113,7 +182,12 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
|
||||
} else {
|
||||
c.id.clone()
|
||||
};
|
||||
let standard = (if db.owned_contexts.contains(&c.id) { super::contexts_root(home) } else { home.join(".local/share/lepton/contexts") }).join(&c.id);
|
||||
let standard = (if db.owned_contexts.contains(&c.id) {
|
||||
super::contexts_root(home)
|
||||
} else {
|
||||
home.join(".local/share/lepton/contexts")
|
||||
})
|
||||
.join(&c.id);
|
||||
let default_location = !c.id.starts_with("external-")
|
||||
&& !fs::symlink_metadata(&standard).is_ok_and(|m| m.file_type().is_symlink())
|
||||
&& fs::canonicalize(&standard).is_ok_and(|p| p == standard && p.join("baked") == c.baked);
|
||||
@@ -122,14 +196,15 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
|
||||
} else {
|
||||
c.baked.clone()
|
||||
};
|
||||
let target_identity = identity(&target)?;
|
||||
let mut names = BTreeSet::from([original.clone(), c.name.clone()]);
|
||||
names.extend(records.iter().filter_map(native::runtime_name));
|
||||
// Resolve every name before stopping anything. A recycled Steam App ID
|
||||
// pointing at different storage must never be removed.
|
||||
let mut runtimes = Vec::new();
|
||||
for name in names {
|
||||
if let Some(id) = runtime(&name, &c.baked, name == original)? {
|
||||
runtimes.push((name, id));
|
||||
for name in &names {
|
||||
if let Some(id) = runtime(name, &c.baked, *name == original)? {
|
||||
runtimes.push((name.clone(), id));
|
||||
}
|
||||
}
|
||||
for (name, _) in &runtimes {
|
||||
@@ -156,9 +231,14 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
|
||||
for rec in &records {
|
||||
steam_shortcuts::unregister(home, rec)?;
|
||||
}
|
||||
validate_location(home, db, c)?;
|
||||
ensure!(
|
||||
validate_baked(&c.baked)? == c.baked,
|
||||
"Container data location changed"
|
||||
identity(&target)? == target_identity,
|
||||
"Container directory changed during deletion"
|
||||
);
|
||||
ensure!(
|
||||
!names.iter().any(|name| wrapper_active(name)),
|
||||
"A container launcher is still running; wait for it to close"
|
||||
);
|
||||
for (name, identity) in &runtimes {
|
||||
if let Some(current) = runtime(name, &c.baked, *name == original)? {
|
||||
@@ -181,14 +261,20 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
|
||||
for entry in fs::read_dir(root(home).join("logs"))? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if entry.path() != log
|
||||
if !preserve_logs
|
||||
&& entry.path() != log
|
||||
&& name.ends_with(".log")
|
||||
&& keys.iter().any(|key| name.starts_with(&format!("{key}-")))
|
||||
{
|
||||
remove_owned(&entry.path())?;
|
||||
}
|
||||
}
|
||||
remove_tree(&target)?;
|
||||
validate_location(home, db, c)?;
|
||||
ensure!(
|
||||
identity(&target)? == target_identity,
|
||||
"Container directory changed during deletion"
|
||||
);
|
||||
remove_owned(&target)?;
|
||||
db.roots.retain(|p| p != &c.baked);
|
||||
db.root_contexts.remove(&c.baked);
|
||||
db.records.retain(|_, r| r.context != c.id);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Roll back only a directory atomically reserved by this installation.
|
||||
use super::*;
|
||||
|
||||
pub(super) struct NewContext {
|
||||
parent: PathBuf,
|
||||
identity: (u64, u64),
|
||||
}
|
||||
impl NewContext {
|
||||
pub(super) fn reserve(home: &Path, db: &Database, c: &Container) -> Result<Self> {
|
||||
cleanup::ensure_runtime_absent(c)?;
|
||||
let base = contexts_root(home);
|
||||
let ancestor = base
|
||||
.ancestors()
|
||||
.find(|p| p.exists())
|
||||
.context("Missing context storage ancestor")?;
|
||||
ensure!(
|
||||
fs::canonicalize(ancestor)? == ancestor,
|
||||
"Container storage contains a link"
|
||||
);
|
||||
fs::create_dir_all(&base)?;
|
||||
cleanup::validate_location(home, db, c)?;
|
||||
let parent = c
|
||||
.baked
|
||||
.parent()
|
||||
.context("Missing context parent")?
|
||||
.to_path_buf();
|
||||
// create_dir, not create_dir_all: an existing/replaced context is never
|
||||
// owned by this attempt, regardless of its size or missing database.
|
||||
fs::create_dir(&parent)
|
||||
.with_context(|| format!("Cannot reserve new container {}", parent.display()))?;
|
||||
let metadata = fs::symlink_metadata(&parent)?;
|
||||
Ok(Self {
|
||||
parent,
|
||||
identity: (metadata.dev(), metadata.ino()),
|
||||
})
|
||||
}
|
||||
pub(super) fn rollback(
|
||||
self,
|
||||
home: &Path,
|
||||
db: &mut Database,
|
||||
c: &Container,
|
||||
log: &Path,
|
||||
) -> Result<()> {
|
||||
let metadata = fs::symlink_metadata(&self.parent)?;
|
||||
ensure!(
|
||||
metadata.is_dir()
|
||||
&& !metadata.file_type().is_symlink()
|
||||
&& (metadata.dev(), metadata.ino()) == self.identity,
|
||||
"New container directory changed; rollback refused"
|
||||
);
|
||||
cleanup::delete_failed_install(home, db, c, log)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Optional Quest APK conversion. The runtime is versioned separately from Framely.
|
||||
use super::{apk_metadata, init, review, space, Cancellation, Metadata, MUTATION};
|
||||
use crate::model::ProxySettings;
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
@@ -251,6 +252,7 @@ pub(super) fn convert(
|
||||
ticket: &str,
|
||||
cancel: &Cancellation,
|
||||
progress: impl Fn(Value) -> Result<()>,
|
||||
proxy: &ProxySettings,
|
||||
) -> Result<Value> {
|
||||
let _guard = MUTATION.lock().unwrap();
|
||||
cancel.check()?;
|
||||
@@ -285,6 +287,9 @@ pub(super) fn convert(
|
||||
let log = storage.join("logs").join(format!("q2f-{ticket}.log"));
|
||||
let mut prepare = Command::new("python3");
|
||||
prepare.arg(tool_path()?).arg(&descriptor).arg(&storage);
|
||||
prepare.env("FRAMELY_GITHUB_AUTO_FALLBACK", if proxy.github_auto_fallback {"1"} else {"0"});
|
||||
prepare.env("FRAMELY_GITHUB_PROXY", if proxy.github_enabled {&proxy.github} else {""});
|
||||
prepare.env("FRAMELY_HTTP_PROXY", if proxy.http_enabled {&proxy.http} else {""});
|
||||
run(prepare, &log, cancel)?;
|
||||
let runtime = PathBuf::from(
|
||||
fs::read_to_string(&log)?
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Only missing artwork or content previously generated by Framely may change.
|
||||
async function(p, id, render, selectPalette) {
|
||||
const suffix={0:'p',1:'_hero',2:'_logo',3:''};
|
||||
const load=data=>new Promise((resolve,reject)=>{const i=new Image(),timer=setTimeout(()=>reject(Error('Artwork decode timeout')),4000);i.onload=()=>{clearTimeout(timer);resolve(i);};i.onerror=()=>{clearTimeout(timer);reject(Error('Invalid artwork'));};i.src=data;});
|
||||
const digest=async image=>{
|
||||
let c=image;
|
||||
if(!image.getContext){c=document.createElement('canvas');c.width=image.width;c.height=image.height;c.getContext('2d').drawImage(image,0,0);}
|
||||
const ctx=c.getContext('2d'),pixels=ctx.getImageData(0,0,c.width,c.height).data;
|
||||
const header=new TextEncoder().encode(`${c.width}x${c.height}:`),input=new Uint8Array(header.length+pixels.length);input.set(header);input.set(pixels,header.length);
|
||||
return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',input))).map(v=>v.toString(16).padStart(2,'0')).join('');
|
||||
};
|
||||
const inspect=async kind=>{
|
||||
const hashes=[];
|
||||
for(const ext of ['png','jpg','jpeg','webp']){
|
||||
const response=await fetch(`/customimages/${id}${suffix[kind]}.${ext}?framely=${Date.now()}`,{cache:'no-store'});
|
||||
if(response.status===404)continue;
|
||||
if(!response.ok)throw Error('Cannot inspect existing Steam artwork');
|
||||
const blob=await response.blob();
|
||||
if(blob.size>16*1024*1024)throw Error('Existing Steam artwork is too large');
|
||||
const data=await new Promise((resolve,reject)=>{const r=new FileReader();r.onload=()=>resolve(r.result);r.onerror=reject;r.readAsDataURL(blob);});
|
||||
const image=await load(data);
|
||||
if(image.width>8192||image.height>8192||image.width*image.height>16*1024*1024)throw Error('Existing artwork dimensions are too large');
|
||||
hashes.push(await digest(image));
|
||||
}
|
||||
return hashes;
|
||||
};
|
||||
const owned=p.ownership?.appId===id?p.ownership.hashes:{};
|
||||
const hashes={...owned}, generated=await render(p,selectPalette);
|
||||
try { for(const a of generated){
|
||||
if(!Object.hasOwn(suffix,a.kind))throw Error('Unsupported Steam artwork type');
|
||||
const current=await inspect(a.kind), previous=[];
|
||||
if(owned[a.kind])previous.push(owned[a.kind]);
|
||||
// v1 mistakenly sent the icon as type 4; Steam wrote it to the SAME
|
||||
// landscape path as type 3. Repair only that exact owned image.
|
||||
if(a.kind===3&&owned[4])previous.push(owned[4]);
|
||||
// One-time upgrade of the exact old plain-background/icon-only images.
|
||||
const legacy=p.legacy?.find(v=>v.kind===a.kind);
|
||||
if(legacy)previous.push(await digest(await load('data:image/png;base64,'+legacy.data)));
|
||||
if(a.kind===3){const oldIcon=p.legacy?.find(v=>v.kind===4);if(oldIcon)previous.push(await digest(await load('data:image/png;base64,'+oldIcon.data)));}
|
||||
if(current.length&¤t.some(h=>!previous.includes(h))){delete hashes[a.kind];if(a.kind===3)delete hashes[4];continue;}
|
||||
const hash=await digest(a.canvas);
|
||||
if(current.length===1&¤t[0]===hash){hashes[a.kind]=hash;if(a.kind===3)delete hashes[4];continue;}
|
||||
// Recheck just before writing in case a user changed the image meanwhile.
|
||||
const rechecked=await inspect(a.kind);
|
||||
if(JSON.stringify(current)!==JSON.stringify(rechecked)){delete hashes[a.kind];continue;}
|
||||
try{await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind);}
|
||||
catch(error){await new Promise(r=>setTimeout(r,200));const latest=await inspect(a.kind);if(latest.some(h=>!previous.includes(h)&&h!==hash))throw Error('Steam artwork changed during retry');await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind);}
|
||||
hashes[a.kind]=hash;
|
||||
if(a.kind===3)delete hashes[4];
|
||||
}} catch(error) {
|
||||
// Return completed hashes even on a partial failure. The core persists
|
||||
// this checkpoint before reporting failure, so retry does not mistake
|
||||
// images just written by Framely for user-created artwork.
|
||||
return {version:1,appId:id,hashes,failed:true};
|
||||
}
|
||||
return {version:1,appId:id,hashes};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Pure image algorithm. Package IDs and application names never enter selection.
|
||||
function(pixels, width, height) {
|
||||
if (pixels.length !== width * height * 4) throw Error('Invalid RGBA image');
|
||||
const distance = (a, b) => a.reduce((sum, v, i) => sum + (v - b[i]) ** 2, 0);
|
||||
const average = (points, key) => {
|
||||
const weight = points.reduce((sum, p) => sum + p.weight, 0);
|
||||
return [0, 1, 2].map(i => points.reduce((sum, p) => sum + p[key][i] * p.weight, 0) / weight);
|
||||
};
|
||||
const hex = rgb => '#' + rgb.map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
|
||||
const lab = rgb => {
|
||||
const [r, g, b] = rgb.map(v => { v /= 255; return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4; });
|
||||
const l = Math.cbrt(.4122214708*r + .5363325363*g + .0514459929*b);
|
||||
const m = Math.cbrt(.2119034982*r + .6806995451*g + .1073969566*b);
|
||||
const s = Math.cbrt(.0883024619*r + .2817188376*g + .6299787005*b);
|
||||
return [.2104542553*l + .793617785*m - .0040720468*s, 1.9779984951*l - 2.428592205*m + .4505937099*s, .0259040371*l + .7827717662*m - .808675766*s];
|
||||
};
|
||||
const hsl = rgb => {
|
||||
const [r,g,b] = rgb.map(v => v/255), hi = Math.max(r,g,b), lo = Math.min(r,g,b), d = hi-lo, l = (hi+lo)/2;
|
||||
const h = d === 0 ? 0 : hi === r ? (g-b)/d : hi === g ? (b-r)/d+2 : (r-g)/d+4;
|
||||
return [(h*60+360)%360, d === 0 ? 0 : d/(1-Math.abs(2*l-1)), l];
|
||||
};
|
||||
const fromHsl = (h,s,l) => {
|
||||
const a=s*Math.min(l,1-l), f=n=>{const k=(n+h/30)%12;return Math.round(255*(l-a*Math.max(-1,Math.min(k-3,9-k,1))));};
|
||||
return [f(0),f(8),f(4)];
|
||||
};
|
||||
const points = [];
|
||||
for (let y=0; y<height; y++) for (let x=0; x<width; x++) {
|
||||
const i=(y*width+x)*4; if(pixels[i+3]<32) continue;
|
||||
const rgb=Array.from(pixels.subarray(i,i+3));
|
||||
points.push({rgb,lab:lab(rgb),weight:pixels[i+3]/255,edge:x<width*.19||x>=width*.81||y<height*.19||y>=height*.81});
|
||||
}
|
||||
const neutral = {selected:'#808080',base:'#191a1d',accent:'#9a9da5',neutral:true};
|
||||
if(!points.length) return neutral;
|
||||
let centers=[average(points,'lab')], groups;
|
||||
while(centers.length<8) {
|
||||
const nearest=p=>Math.min(...centers.map(c=>distance(p.lab,c)));
|
||||
const far=points.reduce((best,p)=>nearest(p)>nearest(best)?p:best,points[0]);
|
||||
if(nearest(far)<.0004) break;
|
||||
centers.push(far.lab);
|
||||
}
|
||||
for(let step=0;step<24;step++) {
|
||||
groups=centers.map(()=>[]);
|
||||
for(const p of points) {const ds=centers.map(c=>distance(p.lab,c));groups[ds.indexOf(Math.min(...ds))].push(p);}
|
||||
const next=groups.map((g,i)=>g.length?average(g,'lab'):centers[i]);
|
||||
const delta=Math.max(...next.map((c,i)=>distance(c,centers[i])));
|
||||
centers=next;if(delta<.0000001)break;
|
||||
}
|
||||
const clusters=groups.filter(g=>g.length).map(points=>({points,lab:average(points,'lab')}));
|
||||
for(let changed=true;changed;) {
|
||||
changed=false;
|
||||
outer:for(let i=0;i<clusters.length;i++)for(let j=i+1;j<clusters.length;j++)if(distance(clusters[i].lab,clusters[j].lab)<.065**2) {
|
||||
clusters[i].points.push(...clusters[j].points);clusters[i].lab=average(clusters[i].points,'lab');clusters.splice(j,1);changed=true;break outer;
|
||||
}
|
||||
}
|
||||
const total=points.reduce((s,p)=>s+p.weight,0), edgeTotal=points.filter(p=>p.edge).reduce((s,p)=>s+p.weight,0);
|
||||
const candidates=clusters.map(c=>{
|
||||
const coverage=c.points.reduce((s,p)=>s+p.weight,0)/total;
|
||||
const edgeCoverage=edgeTotal?c.points.filter(p=>p.edge).reduce((s,p)=>s+p.weight,0)/edgeTotal:coverage;
|
||||
const rgb=average(c.points,'rgb'), chroma=Math.hypot(c.lab[1],c.lab[2]);
|
||||
const eligible=coverage>=.035&&edgeCoverage>=.025&&chroma>=.012&&c.lab[0]>.18&&c.lab[0]<.96;
|
||||
return {rgb,coverage,edgeCoverage,eligible,score:(.5*coverage+.5*edgeCoverage)*Math.min(1,chroma/.025)*(chroma>.16?.8:1)};
|
||||
}).sort((a,b)=>b.score-a.score);
|
||||
const best=candidates.find(c=>c.eligible);
|
||||
if(!best||best.score<.055)return {...neutral,selected:hex([...candidates].sort((a,b)=>(b.coverage+b.edgeCoverage)-(a.coverage+a.edgeCoverage))[0].rgb)};
|
||||
const [h,s]=hsl(best.rgb);
|
||||
// Multiply all RGB channels uniformly: 80% intensity, no hue remapping.
|
||||
return {selected:hex(best.rgb),base:hex(fromHsl(h,s*.22,.095)),accent:hex(best.rgb.map(v=>v*.8)),neutral:false};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Render inside Steam's existing CEF context; no external runtime/fonts needed.
|
||||
async function(p, selectPalette) {
|
||||
const load = data => new Promise((resolve,reject)=>{
|
||||
const image=new Image(), timer=setTimeout(()=>reject(Error('Artwork image timeout')),4000);
|
||||
image.onload=()=>{clearTimeout(timer);resolve(image);};
|
||||
image.onerror=()=>{clearTimeout(timer);reject(Error('Invalid artwork image'));};image.src=data;
|
||||
});
|
||||
const source=await load(p.image);
|
||||
const canvas=(w,h)=>{const c=document.createElement('canvas');c.width=w;c.height=h;return c;};
|
||||
const sample=canvas(64,64), sc=sample.getContext('2d');sc.drawImage(source,0,0,64,64);
|
||||
const palette=selectPalette(sc.getImageData(0,0,64,64).data,64,64);
|
||||
let n=2166136261;
|
||||
for(const c of p.package)n=Math.imul(n^c.charCodeAt(0),16777619);
|
||||
const random=()=>{n+=0x6D2B79F5;let t=n;t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return ((t^(t>>>14))>>>0)/4294967296;};
|
||||
const angle=(-39-random()*8)*Math.PI/180, offset=(random()-.5)*65;
|
||||
const shade=(hex,f)=>'#'+[1,3,5].map(i=>Math.round(parseInt(hex.slice(i,i+2),16)*f).toString(16).padStart(2,'0')).join('');
|
||||
const rounded=(c,x,y,w,h,r)=>{c.beginPath();c.roundRect(x,y,w,h,r);};
|
||||
const icon=(c,x,y,s,rotation=0,opacity=1)=>{
|
||||
c.save();c.globalAlpha=opacity;c.translate(x+s/2,y+s/2);c.rotate(rotation*Math.PI/180);c.translate(-s/2,-s/2);
|
||||
rounded(c,0,0,s,s,s*.23);c.clip();
|
||||
const scale=Math.min(s/source.width,s/source.height),w=source.width*scale,h=source.height*scale;
|
||||
c.drawImage(source,(s-w)/2,(s-h)/2,w,h);c.restore();
|
||||
};
|
||||
const fontSize=(size,max)=>{
|
||||
// Use actual font metrics, including CJK/emoji and long custom names.
|
||||
const c=sc;c.font=`700 ${size}px "Noto Sans CJK SC", "Noto Sans", sans-serif`;
|
||||
const width=c.measureText(p.name).width;return width>max?size*max/width:size;
|
||||
};
|
||||
const title=(c,x,y,size,max,center=false)=>{
|
||||
c.save();c.fillStyle='#fff';c.textAlign=center?'center':'left';c.font=`700 ${fontSize(size,max)}px "Noto Sans CJK SC", "Noto Sans", sans-serif`;
|
||||
c.fillText(p.name,x,y);c.restore();
|
||||
};
|
||||
const base=(c,w,h)=>{
|
||||
const g=c.createLinearGradient(0,0,w,h);g.addColorStop(0,palette.base);g.addColorStop(.43,shade(palette.base,.7));g.addColorStop(1,shade(palette.accent,.24));
|
||||
c.fillStyle=g;c.fillRect(0,0,w,h);
|
||||
c.save();c.translate(w*.82,h*.82);c.scale(w*.7,h*.85);
|
||||
const light=c.createRadialGradient(0,0,0,0,0,1);light.addColorStop(0,palette.accent+'0f');light.addColorStop(1,palette.accent+'00');c.fillStyle=light;c.fillRect(-1,-1,2,2);c.restore();
|
||||
};
|
||||
const pill=(c,x,y,w,h,muted=false,opacity=1)=>{
|
||||
c.save();c.translate(x+w/2,y+h/2);c.rotate(angle);c.translate(-w/2,-h/2);
|
||||
c.globalAlpha=muted?opacity:1;
|
||||
if(muted){const g=c.createLinearGradient(0,0,w,h);g.addColorStop(0,shade(palette.accent,.22));g.addColorStop(1,shade(palette.accent,.4));c.fillStyle=g;}else c.fillStyle=palette.accent;
|
||||
rounded(c,0,0,w,h,h/2);c.fill();c.restore();
|
||||
};
|
||||
const line=(c,x,y,w,h)=>{c.fillStyle=palette.accent;rounded(c,x,y,w,h,h/2);c.fill();};
|
||||
const result=[];
|
||||
// Only these four Steam custom-artwork enums are supported. A shortcut
|
||||
// icon goes through SetShortcutIcon, never SetCustomArtworkForApp(type=4).
|
||||
for(const [kind,w,h] of [[0,600,900],[1,3840,1240],[2,1280,360],[3,920,430]]){
|
||||
const image=canvas(w,h),c=image.getContext('2d');
|
||||
if(kind===0){base(c,w,h);pill(c,-205+offset,30,540,116);pill(c,206,-90+offset,425,98,true,.42);pill(c,-132,684,310,100);icon(c,315,676,490,-16,.5);icon(c,155,146,290);title(c,300,551,65,490,true);line(c,265,592,70,7);}
|
||||
if(kind===3){base(c,w,h);pill(c,-100+offset,-24,405,80);pill(c,700,-30+offset,355,100,true,.35);pill(c,692,350,350,104);pill(c,570,410,286,49,true,.55);icon(c,76,117,196);title(c,323,243,62,490);line(c,326,282,62,6);}
|
||||
// Frame currently displays the hero at ~4.17:1 using object-fit:cover.
|
||||
// Keep the main motifs within the central 75% height, rather than
|
||||
// designing solely against the uncropped 3840x1240 export.
|
||||
if(kind===1){base(c,w,h);pill(c,-520,-140,1410,240);pill(c,1650+offset*2,400,620,150,true,.43);pill(c,2540,680,720,190);pill(c,2140,780,520,100,true,.5);pill(c,3300,450,700,160,true,.32);}
|
||||
if(kind===2){icon(c,16,24,312);title(c,382,232,136,866);}
|
||||
result.push({kind,data:image.toDataURL('image/png').split(',')[1],canvas:image});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -234,6 +234,7 @@ pub(super) fn register(home: &Path, a: &App, r: &mut Record) -> Result<()> {
|
||||
icon.as_deref(),
|
||||
&launch,
|
||||
&native_apk(home, &a.id),
|
||||
&a.metadata.package,
|
||||
)?);
|
||||
r.steam_binding = Some(Binding {
|
||||
game_id: game,
|
||||
@@ -270,7 +271,9 @@ pub(super) fn unregister(home: &Path, r: &Record) -> Result<()> {
|
||||
"icon.png",
|
||||
"icon.webp",
|
||||
"icon.jpg",
|
||||
"steam-icon.png",
|
||||
"lepton-show-flatscreen",
|
||||
".framely-artwork.json",
|
||||
] {
|
||||
let file = p.parent().unwrap().join(name);
|
||||
if file.exists() {
|
||||
@@ -287,11 +290,14 @@ pub(super) fn unregister(home: &Path, r: &Record) -> Result<()> {
|
||||
}
|
||||
pub(super) fn parse_name(value: &Value) -> Result<Option<String>> {
|
||||
let name = value.as_str().context("Invalid Steam display name")?.trim();
|
||||
ensure!(name.chars().count() <= 128 && !name.chars().any(char::is_control), "Steam display name must be at most 128 characters without control characters");
|
||||
ensure!(
|
||||
name.chars().count() <= 128 && !name.chars().any(char::is_control),
|
||||
"Steam display name must be at most 128 characters without control characters"
|
||||
);
|
||||
Ok((!name.is_empty()).then(|| name.to_owned()))
|
||||
}
|
||||
fn revision(a: &App, r: &Record) -> String {
|
||||
let mut value = json!({"metadata":a.metadata,"showWindow":a.show_window});
|
||||
let mut value = json!({"metadata":a.metadata,"showWindow":a.show_window,"artworkVersion":2});
|
||||
if let Some(name) = &r.steam_name {
|
||||
value["steamName"] = json!(name);
|
||||
}
|
||||
@@ -303,8 +309,13 @@ pub(super) fn update_name(home: &Path, a: &App, r: &mut Record) -> Result<()> {
|
||||
"(async()=>{{const p={parameters};const owned=await ({lookup})(p);SteamClient.Apps.SetShortcutName(owned.id,p.name);return true;}})()",
|
||||
lookup = include_str!("steam_shortcut_lookup.js")
|
||||
))?;
|
||||
ensure!(result == json!(true), "Steam name update was not acknowledged");
|
||||
let current_revision = revision(a, r);
|
||||
ensure!(
|
||||
result == json!(true),
|
||||
"Steam name update was not acknowledged"
|
||||
);
|
||||
// The next synchronization redraws only our generated titles; custom user
|
||||
// artwork is preserved by its content hash. Do not mark stale artwork fresh.
|
||||
let current_revision = format!("artwork-pending:{}", revision(a, r));
|
||||
if let Some(binding) = &mut r.steam_binding {
|
||||
binding.revision = current_revision;
|
||||
}
|
||||
|
||||
+161
-20
@@ -54,7 +54,8 @@ pub(crate) fn evaluate(expression: &str) -> Result<Value> {
|
||||
&SocketAddr::from(([127, 0, 0, 1], 8080)),
|
||||
Duration::from_secs(3),
|
||||
)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(6)))?;
|
||||
// Artwork renders and hashes a 3840x1240 hero inside Steam's CEF context.
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(3)))?;
|
||||
let (mut ws, _) = client(address.as_str(), stream)
|
||||
.map_err(|_| anyhow::anyhow!("Cannot connect to Steam UI"))?;
|
||||
@@ -70,7 +71,7 @@ pub(crate) fn evaluate(expression: &str) -> Result<Value> {
|
||||
.to_string()
|
||||
.into(),
|
||||
))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
ensure!(Instant::now() < deadline, "Steam UI request timed out");
|
||||
let message = ws
|
||||
@@ -98,10 +99,30 @@ pub(super) fn configure(
|
||||
icon: Option<&Path>,
|
||||
launch: &str,
|
||||
executable: &Path,
|
||||
package: &str,
|
||||
) -> Result<u32> {
|
||||
let art = artwork(icon)?;
|
||||
use base64::Engine;
|
||||
let legacy = legacy_artwork(icon)?;
|
||||
let image = icon
|
||||
.map(|path| -> Result<String> {
|
||||
ensure!(
|
||||
fs::metadata(path)?.len() <= 8 * 1024 * 1024,
|
||||
"APK icon is too large"
|
||||
);
|
||||
Ok(format!(
|
||||
"data:image/png;base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(fs::read(path)?)
|
||||
))
|
||||
})
|
||||
.transpose()?;
|
||||
let ownership = icon
|
||||
.map(|p| load_artwork_ownership(p.parent().context("Missing artwork directory")?))
|
||||
.transpose()?
|
||||
.unwrap_or(Value::Null);
|
||||
let shortcut_icon = icon.map(shortcut_icon_file).transpose()?;
|
||||
let parameters = json!({"game":game,"previous":previous,"name":name,
|
||||
"icon":icon,"launch":launch,"exe":executable,"art":art});
|
||||
"icon":shortcut_icon,"launch":launch,"exe":executable,"image":image,"package":package,
|
||||
"ownership":ownership,"legacy":legacy});
|
||||
let expression = format!(
|
||||
r#"(async()=>{{
|
||||
const p={parameters};
|
||||
@@ -109,27 +130,109 @@ pub(super) fn configure(
|
||||
const id=owned.id;
|
||||
SteamClient.Apps.SetShortcutLaunchOptions(id,p.launch);
|
||||
SteamClient.Apps.SetShortcutName(id,p.name);
|
||||
if(p.icon) SteamClient.Apps.SetShortcutIcon(id,p.icon);
|
||||
for(const a of p.art){{
|
||||
try{{await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind)}}
|
||||
catch(e){{
|
||||
await new Promise(r=>setTimeout(r,200));
|
||||
await SteamClient.Apps.SetCustomArtworkForApp(id,a.data,'png',a.kind);
|
||||
}}
|
||||
}}
|
||||
return id;
|
||||
// Existing shortcuts may have a user-selected icon path.
|
||||
if(p.icon&&!p.previous) SteamClient.Apps.SetShortcutIcon(id,p.icon);
|
||||
const ownership=p.image?await ({apply})(p,id,({render}),({palette})):null;
|
||||
return {{id,ownership}};
|
||||
}})()"#,
|
||||
lookup = include_str!("steam_shortcut_lookup.js")
|
||||
lookup = include_str!("steam_shortcut_lookup.js"),
|
||||
apply = include_str!("steam_artwork_apply.js"),
|
||||
render = include_str!("steam_artwork_render.js"),
|
||||
palette = include_str!("steam_artwork_palette.js"),
|
||||
);
|
||||
let result = evaluate(&expression)?;
|
||||
result
|
||||
// Numeric mock responses remain supported by the existing IPC fixtures.
|
||||
let id = result
|
||||
.get("id")
|
||||
.unwrap_or(&result)
|
||||
.as_u64()
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.filter(|v| *v >= 0x80000000)
|
||||
.context("Invalid Steam APK App ID")
|
||||
.context("Invalid Steam APK App ID")?;
|
||||
if let (Some(path), Some(ownership)) = (icon, result.get("ownership").filter(|v| !v.is_null()))
|
||||
{
|
||||
validate_artwork_ownership(ownership)?;
|
||||
ensure!(
|
||||
ownership["appId"] == id,
|
||||
"Artwork ownership does not match shortcut"
|
||||
);
|
||||
super::steam_shortcuts::write_owned(
|
||||
&path
|
||||
.parent()
|
||||
.context("Missing artwork directory")?
|
||||
.join(".framely-artwork.json"),
|
||||
&serde_json::to_vec(ownership)?,
|
||||
0o600,
|
||||
)?;
|
||||
ensure!(
|
||||
ownership["failed"] != true,
|
||||
"Steam artwork update was interrupted; completed images were recorded and will be retried"
|
||||
);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn artwork(icon: Option<&Path>) -> Result<Vec<Value>> {
|
||||
fn validate_artwork_ownership(value: &Value) -> Result<()> {
|
||||
ensure!(
|
||||
value["version"] == 1
|
||||
&& value["appId"]
|
||||
.as_u64()
|
||||
.is_some_and(|id| (0x80000000..=u32::MAX as u64).contains(&id)),
|
||||
"Invalid artwork ownership version or app ID"
|
||||
);
|
||||
let hashes = value["hashes"]
|
||||
.as_object()
|
||||
.context("Invalid artwork ownership hashes")?;
|
||||
ensure!(
|
||||
hashes.len() <= 5
|
||||
&& hashes.iter().all(|(kind, hash)| matches!(
|
||||
kind.as_str(),
|
||||
"0" | "1" | "2" | "3" | "4"
|
||||
) && hash
|
||||
.as_str()
|
||||
.is_some_and(|h| h.len() == 64 && h.bytes().all(|c| c.is_ascii_hexdigit()))),
|
||||
"Invalid artwork ownership hash"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shortcut_icon_file(source: &Path) -> Result<PathBuf> {
|
||||
let picture = image::open(source)?.thumbnail(256, 256).to_rgba8();
|
||||
let mut icon = image::RgbaImage::new(256, 256);
|
||||
image::imageops::overlay(
|
||||
&mut icon,
|
||||
&picture,
|
||||
((256 - picture.width()) / 2) as i64,
|
||||
((256 - picture.height()) / 2) as i64,
|
||||
);
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(icon).write_to(&mut bytes, image::ImageFormat::Png)?;
|
||||
let path = source
|
||||
.parent()
|
||||
.context("Missing icon directory")?
|
||||
.join("steam-icon.png");
|
||||
super::steam_shortcuts::write_owned(&path, &bytes.into_inner(), 0o600)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn load_artwork_ownership(dir: &Path) -> Result<Value> {
|
||||
let path = dir.join(".framely-artwork.json");
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Value::Null),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
ensure!(
|
||||
metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() <= 8192,
|
||||
"Invalid artwork ownership file"
|
||||
);
|
||||
let value: Value = serde_json::from_slice(&fs::read(path)?)?;
|
||||
validate_artwork_ownership(&value)?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
// Kept solely to recognize/migrate exact artwork made by earlier releases.
|
||||
fn legacy_artwork(icon: Option<&Path>) -> Result<Vec<Value>> {
|
||||
use base64::Engine;
|
||||
let Some(path) = icon else { return Ok(vec![]) };
|
||||
let image = image::open(path)?.to_rgba8();
|
||||
@@ -171,7 +274,7 @@ pub(super) fn clear_artwork(app_id: u32, executable: &Path) -> Result<()> {
|
||||
sub?.unregister();
|
||||
const exe=details.strShortcutExe?.replace(/^"|"$/g,'').replace('/./','/');
|
||||
if(exe!==p.exe) throw Error('Shortcut target does not match owned APK');
|
||||
for(const kind of [0,1,3,4]) await SteamClient.Apps.ClearCustomArtworkForApp(p.id,kind);
|
||||
for(const kind of [0,1,2,3]) await SteamClient.Apps.ClearCustomArtworkForApp(p.id,kind);
|
||||
await SteamClient.Apps.SetShortcutIcon(p.id,'');
|
||||
}})()"#
|
||||
))?;
|
||||
@@ -182,6 +285,22 @@ pub(super) fn clear_artwork(app_id: u32, executable: &Path) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn shortcut_icon_has_its_own_256px_file_and_preserves_source() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let source = t.path().join("icon.png");
|
||||
image::RgbaImage::from_pixel(128, 64, image::Rgba([230, 30, 20, 255]))
|
||||
.save(&source)
|
||||
.unwrap();
|
||||
let before = fs::read(&source).unwrap();
|
||||
let path = shortcut_icon_file(&source).unwrap();
|
||||
assert_eq!(path.file_name().unwrap(), "steam-icon.png");
|
||||
let picture = image::open(path).unwrap().to_rgba8();
|
||||
assert_eq!(picture.dimensions(), (256, 256));
|
||||
assert_eq!(picture.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(picture.get_pixel(128, 128).0, [230, 30, 20, 255]);
|
||||
assert_eq!(fs::read(source).unwrap(), before);
|
||||
}
|
||||
#[test]
|
||||
fn steam_artwork_has_matching_aspect_ratios_and_never_crops_icon() {
|
||||
use base64::Engine;
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
@@ -189,7 +308,7 @@ mod tests {
|
||||
image::RgbaImage::from_pixel(128, 64, image::Rgba([230, 30, 20, 255]))
|
||||
.save(&p)
|
||||
.unwrap();
|
||||
let art = artwork(Some(&p)).unwrap();
|
||||
let art = legacy_artwork(Some(&p)).unwrap();
|
||||
assert_eq!(art.len(), 4);
|
||||
for (a, expected) in art
|
||||
.iter()
|
||||
@@ -205,6 +324,28 @@ mod tests {
|
||||
[230, 30, 20, 255]
|
||||
);
|
||||
}
|
||||
assert!(artwork(None).unwrap().is_empty());
|
||||
assert!(legacy_artwork(None).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artwork_ownership_is_bounded_and_never_follows_symlinks() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
assert!(load_artwork_ownership(t.path()).unwrap().is_null());
|
||||
let valid = json!({"version":1,"appId":0x92345678u32,"hashes":{"0":"a".repeat(64)}});
|
||||
let file = t.path().join(".framely-artwork.json");
|
||||
fs::write(&file, serde_json::to_vec(&valid).unwrap()).unwrap();
|
||||
assert_eq!(load_artwork_ownership(t.path()).unwrap(), valid);
|
||||
for invalid in [
|
||||
json!({}),
|
||||
json!({"version":1,"appId":42,"hashes":{}}),
|
||||
json!({"version":1,"appId":0x92345678u32,"hashes":{"5":"a".repeat(64)}}),
|
||||
json!({"version":1,"appId":0x92345678u32,"hashes":{"0":"not-a-hash"}}),
|
||||
] {
|
||||
fs::write(&file, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(load_artwork_ownership(t.path()).is_err());
|
||||
}
|
||||
fs::remove_file(&file).unwrap();
|
||||
std::os::unix::fs::symlink(t.path().join("other"), &file).unwrap();
|
||||
assert!(load_artwork_ownership(t.path()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -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" } }
|
||||
@@ -57,6 +65,11 @@ pub struct Settings {
|
||||
pub dlna: bool,
|
||||
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 {
|
||||
@@ -78,6 +91,9 @@ impl Default for Settings {
|
||||
dlna: false,
|
||||
receiver_name: "Framely".into(),
|
||||
recording_region_visible: false,
|
||||
recording_indicator_visible: true,
|
||||
screenshot_shortcut_enabled: false,
|
||||
screenshot_shortcut: ScreenshotShortcut::LeftView,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +104,9 @@ impl Settings {
|
||||
capture.dlna = other.dlna;
|
||||
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<()> {
|
||||
@@ -129,6 +148,15 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn recording_indicator_defaults_on_and_is_not_capture_configuration() {
|
||||
let legacy: Settings = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||
assert!(legacy.recording_indicator_visible);
|
||||
let mut hidden = legacy.clone();
|
||||
hidden.recording_indicator_visible = false;
|
||||
assert!(legacy.same_capture(&hidden));
|
||||
assert_eq!(serde_json::to_value(hidden).unwrap()["recordingIndicatorVisible"], false);
|
||||
}
|
||||
#[test]
|
||||
fn legacy_settings_keep_h264_and_hevc_is_explicit() {
|
||||
let legacy: Settings=serde_json::from_value(serde_json::json!({"source":"screen"})).unwrap();
|
||||
assert_eq!(legacy.codec,Codec::H264);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+38
-5
@@ -1,5 +1,14 @@
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use std::time::Duration;
|
||||
pub(crate) mod github_fallback;
|
||||
|
||||
pub(crate) fn with_github_fallback<T>(initial: &str, headers: &[(&str, &str)], proxy: &crate::model::ProxySettings, mut attempt: impl FnMut(&crate::model::ProxySettings) -> Result<T>) -> Result<T> {
|
||||
github_fallback::run(proxy.github_auto_fallback, initial, headers, |fallback| {
|
||||
let mut route=proxy.clone();route.github_auto_fallback=false;
|
||||
if fallback {route.github_enabled=true;route.github=github_fallback::PREFIX.into();route.github_fallback_attempt=true;}
|
||||
attempt(&route)
|
||||
})
|
||||
}
|
||||
|
||||
/// Follow at most five redirects, validating each target before requesting it.
|
||||
/// HTTPS can never be downgraded; HTTP requires explicit source opt-in.
|
||||
@@ -16,6 +25,7 @@ pub fn get_headers(
|
||||
) -> Result<ureq::Response> {
|
||||
get_with_proxy(initial, allow_http, timeout, headers, &Default::default())
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn get_with_proxy(
|
||||
initial: &str,
|
||||
allow_http: bool,
|
||||
@@ -117,6 +127,9 @@ pub fn get_with_proxy_cancel(
|
||||
proxy: &crate::model::ProxySettings,
|
||||
cancel: &crate::jobs::Cancellation,
|
||||
) -> Result<ureq::Response> {
|
||||
if proxy.github_auto_fallback {
|
||||
return with_github_fallback(initial, headers, proxy, |route| get_with_proxy_cancel(initial, allow_http, timeout, headers, route, cancel));
|
||||
}
|
||||
let initial = initial.to_owned();
|
||||
let proxy = proxy.clone();
|
||||
let headers: Vec<_> = headers
|
||||
@@ -140,6 +153,9 @@ pub fn get_download_with_proxy(
|
||||
headers: &[(&str, &str)],
|
||||
proxy: &crate::model::ProxySettings,
|
||||
) -> Result<ureq::Response> {
|
||||
if proxy.github_auto_fallback {
|
||||
return with_github_fallback(initial, headers, proxy, |route| get_download_with_proxy(initial, allow_http, idle_timeout, headers, route));
|
||||
}
|
||||
let initial = initial.to_owned();
|
||||
let proxy = proxy.clone();
|
||||
let headers: Vec<_> = headers
|
||||
@@ -194,7 +210,11 @@ fn get_blocking(
|
||||
for hop in 0..=5 {
|
||||
cancel.check()?;
|
||||
let routed = routed_url(¤t, proxy);
|
||||
let mut request = agent.get(&routed);
|
||||
#[cfg(test)]
|
||||
let transport_url = github_fallback::test_url(&routed);
|
||||
#[cfg(not(test))]
|
||||
let transport_url = routed.clone();
|
||||
let mut request = agent.get(&transport_url);
|
||||
for (key, value) in headers {
|
||||
request = request.set(key, value);
|
||||
}
|
||||
@@ -249,11 +269,9 @@ pub fn read_cancel(
|
||||
}
|
||||
|
||||
fn routed_url(url: &url::Url, proxy: &crate::model::ProxySettings) -> String {
|
||||
if proxy.github_fallback_attempt && !github_fallback::eligible(url.as_str(), &[]) { return url.to_string(); }
|
||||
let host = url.host_str().unwrap_or("");
|
||||
let github = host == "github.com"
|
||||
|| host.ends_with(".github.com")
|
||||
|| host == "githubusercontent.com"
|
||||
|| host.ends_with(".githubusercontent.com");
|
||||
let github = github_fallback::github_host(host);
|
||||
if github
|
||||
&& proxy.github_enabled
|
||||
&& !url
|
||||
@@ -270,6 +288,17 @@ fn routed_url(url: &url::Url, proxy: &crate::model::ProxySettings) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn github_fallback_restarts_a_truncated_plugin_download_from_zero() {
|
||||
let (target,server)=github_fallback::local_truncated_server();
|
||||
let source=format!("https://github.com/framely-test/{}/asset",rand::random::<u64>());
|
||||
let _routes=github_fallback::TestRoutes::new(vec![(source.clone(),target.clone()),(format!("{}/{}",github_fallback::PREFIX,source),target)]);
|
||||
let mut progress=vec![];
|
||||
let staged=crate::package::stage_request_proxy(&serde_json::json!({"url":source}),&Default::default(),|received,_|{progress.push(received);Ok(())}).unwrap();
|
||||
assert_eq!(std::fs::read(&staged.path).unwrap(),b"new-data");
|
||||
assert!(progress.iter().filter(|&&received| received==0).count()>=2);
|
||||
server.join().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn download_progress_outlives_idle_timeout() {
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
@@ -441,6 +470,8 @@ mod proxy_tests {
|
||||
#[test]
|
||||
fn proxy_routing_and_validation() {
|
||||
let proxy = ProxySettings {
|
||||
github_auto_fallback: true,
|
||||
github_fallback_attempt: false,
|
||||
http_enabled: false,
|
||||
github_enabled: true,
|
||||
http: String::new(),
|
||||
@@ -493,6 +524,7 @@ mod proxy_tests {
|
||||
github_enabled: false,
|
||||
http: format!("http://{}", server.server_addr()),
|
||||
github: String::new(),
|
||||
..Default::default()
|
||||
};
|
||||
let worker = std::thread::spawn(move || {
|
||||
let request = server
|
||||
@@ -550,6 +582,7 @@ mod proxy_tests {
|
||||
github_enabled: true,
|
||||
http: String::new(),
|
||||
github: format!("http://{}", server.server_addr()),
|
||||
..Default::default()
|
||||
};
|
||||
let worker = std::thread::spawn(move || {
|
||||
let request = server
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Shared public-GitHub fallback policy for the core and desktop installer.
|
||||
use anyhow::Result;
|
||||
use std::io;
|
||||
|
||||
pub const PREFIX: &str = "https://gh-proxy.com";
|
||||
#[cfg(test)]
|
||||
static TEST_ROUTES: std::sync::Mutex<Vec<(String, String)>> = std::sync::Mutex::new(Vec::new());
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_url(url: &str) -> String {
|
||||
TEST_ROUTES
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|(source, _)| source == url)
|
||||
.map(|(_, target)| target.clone())
|
||||
.unwrap_or_else(|| url.into())
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) struct TestRoutes(pub Vec<(String, String)>);
|
||||
#[cfg(test)]
|
||||
impl TestRoutes {
|
||||
pub fn new(routes: Vec<(String, String)>) -> Self {
|
||||
TEST_ROUTES.lock().unwrap().extend(routes.clone());
|
||||
Self(routes)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl Drop for TestRoutes {
|
||||
fn drop(&mut self) {
|
||||
TEST_ROUTES
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(url, _)| !self.0.iter().any(|(source, _)| source == url));
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn local_truncated_server() -> (String, std::thread::JoinHandle<()>) {
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let address = format!("http://{}", listener.local_addr().unwrap());
|
||||
let worker = std::thread::spawn(move || {
|
||||
for body in [b"old".as_slice(), b"new-data".as_slice()] {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut stream = loop {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => break stream,
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
|
||||
Err(error) => panic!("local fallback server accept failed: {error}"),
|
||||
}
|
||||
assert!(Instant::now() < deadline, "local fallback server timed out");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
};
|
||||
// Accepted sockets can inherit O_NONBLOCK on macOS. Wait for
|
||||
// the complete request even when its headers arrive separately.
|
||||
stream.set_nonblocking(false).unwrap();
|
||||
let request_deadline = Instant::now() + Duration::from_secs(2);
|
||||
let mut request = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") {
|
||||
let remaining = request_deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.expect("local fallback server request timed out");
|
||||
stream.set_read_timeout(Some(remaining)).unwrap();
|
||||
let received = match stream.read(&mut chunk) {
|
||||
Ok(received) => received,
|
||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(error) => panic!("local fallback server read failed: {error}"),
|
||||
};
|
||||
assert!(received > 0, "local fallback server request ended before headers");
|
||||
request.extend_from_slice(&chunk[..received]);
|
||||
assert!(request.len() <= 8192, "local fallback server request too large");
|
||||
}
|
||||
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\n")
|
||||
.unwrap();
|
||||
stream.write_all(body).unwrap();
|
||||
}
|
||||
});
|
||||
(address, worker)
|
||||
}
|
||||
pub fn github_host(host: &str) -> bool {
|
||||
matches!(
|
||||
host,
|
||||
"github.com"
|
||||
| "api.github.com"
|
||||
| "raw.githubusercontent.com"
|
||||
| "objects.githubusercontent.com"
|
||||
| "release-assets.githubusercontent.com"
|
||||
)
|
||||
}
|
||||
pub fn eligible(initial: &str, headers: &[(&str, &str)]) -> bool {
|
||||
let Ok(url) = url::Url::parse(initial) else {
|
||||
return false;
|
||||
};
|
||||
url.scheme() == "https" && url.host_str().is_some_and(github_host)
|
||||
&& url.port_or_known_default() == Some(443)
|
||||
&& url.username().is_empty() && url.password().is_none()
|
||||
&& url.fragment().is_none()
|
||||
// Only pagination and Framely's fixed public topic search, never arbitrary queries or signed asset addresses.
|
||||
&& url.query_pairs().all(|(key, value)| {
|
||||
if matches!(key.as_ref(), "page" | "per_page") { return !value.is_empty() && value.bytes().all(|c| c.is_ascii_digit()); }
|
||||
url.host_str() == Some("api.github.com") && url.path() == "/search/repositories"
|
||||
&& matches!((key.as_ref(), value.as_ref()), ("q", "topic:framely-plugin archived:false fork:false") | ("sort", "stars") | ("order", "desc"))
|
||||
})
|
||||
&& headers.iter().all(|(key, _)| matches!(key.to_ascii_lowercase().as_str(), "accept" | "accept-encoding" | "user-agent" | "if-none-match" | "if-modified-since" | "cache-control"))
|
||||
}
|
||||
fn certificate_error(error: &(dyn std::error::Error + 'static)) -> bool {
|
||||
let message = error.to_string().to_ascii_lowercase();
|
||||
message.contains("certificate")
|
||||
|| message.contains("certvalid")
|
||||
|| message.contains("unknownissuer")
|
||||
|| message.contains("invalid peer")
|
||||
}
|
||||
pub fn retryable(error: &anyhow::Error) -> bool {
|
||||
if error.chain().any(certificate_error) {
|
||||
return false;
|
||||
}
|
||||
if error.to_string() == "网络请求超时" {
|
||||
return true;
|
||||
}
|
||||
error.chain().any(|cause| {
|
||||
if let Some(error) = cause.downcast_ref::<ureq::Error>() {
|
||||
return match error {
|
||||
ureq::Error::Status(code, response) => {
|
||||
matches!(code, 408 | 429 | 500..=599)
|
||||
|| *code == 403
|
||||
&& (response.header("X-RateLimit-Remaining") == Some("0")
|
||||
|| response.header("Retry-After").is_some())
|
||||
}
|
||||
ureq::Error::Transport(error) => matches!(
|
||||
error.kind(),
|
||||
ureq::ErrorKind::Dns
|
||||
| ureq::ErrorKind::ConnectionFailed
|
||||
| ureq::ErrorKind::Io
|
||||
| ureq::ErrorKind::ProxyConnect
|
||||
),
|
||||
};
|
||||
}
|
||||
cause.downcast_ref::<io::Error>().is_some_and(|e| {
|
||||
matches!(
|
||||
e.kind(),
|
||||
io::ErrorKind::TimedOut
|
||||
| io::ErrorKind::ConnectionReset
|
||||
| io::ErrorKind::ConnectionAborted
|
||||
| io::ErrorKind::BrokenPipe
|
||||
| io::ErrorKind::UnexpectedEof
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
// Error display from HTTP clients can contain signed redirect URLs. Do not
|
||||
// surface those URLs when reporting both routes' errors.
|
||||
pub fn diagnostic(error: &anyhow::Error) -> String {
|
||||
error
|
||||
.chain()
|
||||
.map(|cause| {
|
||||
if let Some(ureq::Error::Status(code, _)) = cause.downcast_ref::<ureq::Error>() {
|
||||
return format!("HTTP {code}");
|
||||
}
|
||||
if let Some(ureq::Error::Transport(t)) = cause.downcast_ref::<ureq::Error>() {
|
||||
return format!("network {:?}", t.kind());
|
||||
}
|
||||
cause
|
||||
.to_string()
|
||||
.split_whitespace()
|
||||
.map(|word| {
|
||||
if word.contains("://") {
|
||||
"[URL hidden]"
|
||||
} else {
|
||||
word
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(": ")
|
||||
}
|
||||
pub fn run<T>(
|
||||
enabled: bool,
|
||||
initial: &str,
|
||||
headers: &[(&str, &str)],
|
||||
mut attempt: impl FnMut(bool) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
match attempt(false) {
|
||||
Err(first) if enabled && eligible(initial, headers) && retryable(&first) => {
|
||||
eprintln!("GitHub connection failed; trying gh-proxy.com once");
|
||||
attempt(true).map_err(|second| {
|
||||
anyhow::anyhow!(
|
||||
"GitHub primary route failed: {}; gh-proxy.com fallback failed: {}",
|
||||
diagnostic(&first),
|
||||
diagnostic(&second)
|
||||
)
|
||||
})
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn local_fallback_server_waits_for_complete_fragmented_headers() {
|
||||
use std::{io::{Read, Write}, net::TcpStream, time::Duration};
|
||||
|
||||
let (address, server) = local_truncated_server();
|
||||
for expected in [b"old".as_slice(), b"new-data".as_slice()] {
|
||||
let mut stream = TcpStream::connect(address.trim_start_matches("http://")).unwrap();
|
||||
// Leave time for accept before sending a partial request. The
|
||||
// server must wait for the final header delimiter before replying.
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
stream.write_all(b"GET /asset HTTP/1.1\r\nHost: localhost\r\n").unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_millis(50))).unwrap();
|
||||
let mut byte = [0u8; 1];
|
||||
let error = stream.read(&mut byte).expect_err("server replied before headers completed");
|
||||
assert!(matches!(error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut));
|
||||
stream.write_all(b"\r\n").unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).unwrap();
|
||||
let header_end = response.windows(4).position(|bytes| bytes == b"\r\n\r\n").unwrap() + 4;
|
||||
assert_eq!(&response[header_end..], expected);
|
||||
}
|
||||
server.join().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn github_fallback_only_public_exact_hosts_without_credentials() {
|
||||
assert!(eligible(
|
||||
"https://api.github.com/repos/a/b/releases?page=1&per_page=100",
|
||||
&[]
|
||||
));
|
||||
assert!(eligible("https://api.github.com/search/repositories?q=topic%3Aframely-plugin+archived%3Afalse+fork%3Afalse&sort=stars&order=desc&per_page=100&page=1", &[]));
|
||||
assert!(!eligible("https://api.github.com/search/repositories?q=secret", &[]));
|
||||
assert!(!eligible("https://github.com/search/repositories?q=topic%3Aframely-plugin+archived%3Afalse+fork%3Afalse", &[]));
|
||||
for u in [
|
||||
"https://github.com.evil/a",
|
||||
"https://foo.github.com/a",
|
||||
"https://user@github.com/a",
|
||||
"https://github.com/a?token=secret",
|
||||
"https://release-assets.githubusercontent.com/a?X-Amz-Signature=secret",
|
||||
"https://gh-proxy.com/https://github.com/a",
|
||||
"http://github.com/a",
|
||||
"https://github.com:8443/a",
|
||||
] {
|
||||
assert!(!eligible(u, &[]), "{u}");
|
||||
}
|
||||
for h in ["Authorization", "cookie", "X-API-KEY", "X-GitHub-Token"] {
|
||||
assert!(!eligible("https://github.com/a", &[(h, "secret")]));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn github_fallback_runs_once_and_does_not_retry_validation_or_certificates() {
|
||||
let mut calls = vec![];
|
||||
let result = run(true, "https://github.com/a", &[], |fallback| {
|
||||
calls.push(fallback);
|
||||
if fallback {
|
||||
Ok(7)
|
||||
} else {
|
||||
Err(io::Error::from(io::ErrorKind::ConnectionReset).into())
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result, 7);
|
||||
assert_eq!(calls, vec![false, true]);
|
||||
for enabled in [false, true] {
|
||||
for message in [
|
||||
"Download SHA256 mismatch",
|
||||
"Operation cancelled",
|
||||
"invalid peer certificate",
|
||||
] {
|
||||
let mut calls = 0;
|
||||
assert!(run::<()>(enabled, "https://github.com/a", &[], |_| {
|
||||
calls += 1;
|
||||
anyhow::bail!("{message}")
|
||||
})
|
||||
.is_err());
|
||||
assert_eq!(calls, 1);
|
||||
}
|
||||
}
|
||||
for (code, remaining, retry) in [
|
||||
(404, false, false),
|
||||
(403, false, false),
|
||||
(403, true, true),
|
||||
(429, false, true),
|
||||
(503, false, true),
|
||||
] {
|
||||
let response: ureq::Response = format!(
|
||||
"HTTP/1.1 {code} Test\r\n{}\r\n",
|
||||
if remaining {
|
||||
"X-RateLimit-Remaining: 0\r\n"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
retryable(&ureq::Error::Status(code, response).into()),
|
||||
retry
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-2
@@ -258,6 +258,28 @@ impl Jobs {
|
||||
let key = id.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = (|| -> Result<Value> {
|
||||
let mut request = request.clone();
|
||||
let mut github_source = None;
|
||||
let mut github_entry = None;
|
||||
if let Some(repo) = request["githubRepository"].as_str() {
|
||||
let mut source = crate::github_plugins::source(repo)?;
|
||||
let status = ipc::call(&socket, "status", json!({}))?;
|
||||
let saved: Vec<crate::model::Source> = serde_json::from_value(status["database"]["sources"].clone())?;
|
||||
if let Some(existing) = saved.iter().find(|s| request["source"].as_str() == Some(s.id.as_str())) {
|
||||
ensure!(existing.enabled && crate::github_plugins::repository(&existing).is_some_and(|r| r.eq_ignore_ascii_case(repo)), "GitHub source is disabled or changed");
|
||||
source = existing.clone();
|
||||
} else {
|
||||
ensure!(!saved.iter().any(|s| crate::github_plugins::repository(s).is_some_and(|r|r.eq_ignore_ascii_case(repo))), "GitHub source identity changed; refresh the plugin library");
|
||||
ensure!(request["source"].as_str() == Some(source.id.as_str()), "GitHub source identity mismatch");
|
||||
}
|
||||
let proxy = serde_json::from_value(status["database"]["proxy"].clone())?;
|
||||
let client = crate::github_plugins::Client::system(&proxy, &cancel, false)?;
|
||||
let entry = client.entries(repo)?.into_iter().find(|entry|
|
||||
request["pluginId"] == entry.id && request["version"] == entry.version
|
||||
&& request["sha256"] == entry.sha256 && request["url"] == entry.url).context("GitHub release selection changed or is invalid")?;
|
||||
request["allowHttp"] = json!(false);
|
||||
github_source = Some(source); github_entry = Some(entry);
|
||||
}
|
||||
jobs.patch(&key, json!({"phase":"downloading"}));
|
||||
let bytes = if let Some(staged) = staged {
|
||||
staged
|
||||
@@ -297,6 +319,10 @@ impl Jobs {
|
||||
ensure!(cancel.check().is_ok(), "下载已取消");
|
||||
jobs.patch(&key, json!({"phase":"verifying"}));
|
||||
let manifest = bytes.manifest()?;
|
||||
if let Some(entry) = &github_entry {
|
||||
ensure!(entry.sha256 == bytes.hash, "GitHub release package SHA256 mismatch");
|
||||
crate::github_plugins::check_package(entry, &manifest)?;
|
||||
}
|
||||
if let Some(expected) = request["pluginId"].as_str() {
|
||||
ensure!(manifest.id == expected, "插件包 ID 与目录不一致");
|
||||
}
|
||||
@@ -324,12 +350,12 @@ impl Jobs {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}));
|
||||
jobs.patch(&key, json!({"phase":"resolving"}));
|
||||
let batch = match crate::planner::prepare_staged(
|
||||
let batch = match crate::planner::prepare_staged_sources(
|
||||
&database,
|
||||
bytes.clone(),
|
||||
request["source"].as_str().map(str::to_owned),
|
||||
choices,
|
||||
cancel.clone(),
|
||||
cancel.clone(), github_source.into_iter().collect(),
|
||||
) {
|
||||
Ok(batch) => {
|
||||
info["plan"] = json!(batch.plan);
|
||||
|
||||
+21
-1
@@ -6,6 +6,7 @@ mod desktop;
|
||||
mod diagnostics;
|
||||
mod file_browser;
|
||||
mod gamepad;
|
||||
mod github_plugins;
|
||||
mod http;
|
||||
mod ipc;
|
||||
mod jobs;
|
||||
@@ -33,7 +34,7 @@ use anyhow::{Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde_json::{json, Value};
|
||||
use std::{fs, path::PathBuf};
|
||||
use std::{fs, path::{Path, PathBuf}};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about = "Framely plugin manager")]
|
||||
@@ -170,9 +171,27 @@ enum Cmd {
|
||||
#[arg(long)]
|
||||
output: PathBuf,
|
||||
},
|
||||
/// Generate release.json from finalized plugin packages.
|
||||
ReleaseJson {
|
||||
#[arg(long)]
|
||||
packages: PathBuf,
|
||||
#[arg(long)]
|
||||
output: PathBuf,
|
||||
},
|
||||
}
|
||||
fn run() -> Result<()> {
|
||||
match Cli::parse().command {
|
||||
Cmd::ReleaseJson { packages, output } => {
|
||||
let value = github_plugins::release_json(&packages)?;
|
||||
let parent = output.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
fs::create_dir_all(parent)?;
|
||||
use std::io::Write;
|
||||
let mut file = tempfile::NamedTempFile::new_in(parent)?;
|
||||
file.write_all(&serde_json::to_vec_pretty(&value)?)?;
|
||||
file.as_file().sync_all()?;
|
||||
file.persist(output)?;
|
||||
Ok(())
|
||||
}
|
||||
Cmd::ApkSteam { app, token } => apk::steam_wrapper(&app, &token),
|
||||
Cmd::ApkNative {
|
||||
app,
|
||||
@@ -369,6 +388,7 @@ fn run() -> Result<()> {
|
||||
let v = package::verify(&bytes)?;
|
||||
let publish = v.manifest.publish.clone().unwrap_or_default();
|
||||
entries.push(model::CatalogEntry {
|
||||
prerelease: !semver::Version::parse(&v.manifest.version).map(|v| v.pre.is_empty()).unwrap_or(true),
|
||||
run_as: Some(v.manifest.run_as()),
|
||||
dependencies: v.manifest.dependencies.clone(),
|
||||
optional_dependencies: v.manifest.optional_dependencies.clone(),
|
||||
|
||||
+118
-11
@@ -9,7 +9,7 @@ use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
os::unix::fs::{symlink, FileExt, MetadataExt, PermissionsExt},
|
||||
os::{fd::AsRawFd, unix::fs::{symlink, FileExt, MetadataExt, OpenOptionsExt, PermissionsExt}},
|
||||
path::{Component, Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
@@ -68,7 +68,6 @@ fn save_json(path: &Path, v: &Value) -> Result<()> {
|
||||
fs::rename(&temp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ViewPreferences {
|
||||
@@ -190,6 +189,9 @@ impl Drop for Download {
|
||||
}
|
||||
fn entry(p: &Path) -> Result<Value> {
|
||||
let m = fs::symlink_metadata(p)?;
|
||||
entry_metadata(p, &m)
|
||||
}
|
||||
fn entry_metadata(p: &Path, m: &fs::Metadata) -> Result<Value> {
|
||||
let link = m.file_type().is_symlink();
|
||||
let dir = if link {
|
||||
fs::metadata(p).is_ok_and(|m| m.is_dir())
|
||||
@@ -220,6 +222,43 @@ fn entry(p: &Path) -> Result<Value> {
|
||||
json!({"name":p.file_name().unwrap_or_default().to_string_lossy(),"path":p,"directory":dir,"symlink":link,"size":m.len(),"mediaRevision":media_revision,"modified":m.modified().ok().and_then(|t|t.duration_since(UNIX_EPOCH).ok()).map(|t|t.as_secs()),"mode":m.mode()&0o7777,"uid":m.uid(),"gid":m.gid(),"linkTarget":if link{fs::read_link(p).ok()}else{None}}),
|
||||
)
|
||||
}
|
||||
fn account_name(id: u32, group: bool) -> Option<String> {
|
||||
let mut buffer = vec![0u8; 65536];
|
||||
// Reentrant NSS lookups, with numeric IDs retained if no name is available.
|
||||
unsafe {
|
||||
let name = if group {
|
||||
let mut record: libc::group = std::mem::zeroed();
|
||||
let mut found = std::ptr::null_mut();
|
||||
if libc::getgrgid_r(id, &mut record, buffer.as_mut_ptr().cast(), buffer.len(), &mut found) != 0 || found.is_null() { return None; }
|
||||
record.gr_name
|
||||
} else {
|
||||
let mut record: libc::passwd = std::mem::zeroed();
|
||||
let mut found = std::ptr::null_mut();
|
||||
if libc::getpwuid_r(id, &mut record, buffer.as_mut_ptr().cast(), buffer.len(), &mut found) != 0 || found.is_null() { return None; }
|
||||
record.pw_name
|
||||
};
|
||||
(!name.is_null()).then(|| std::ffi::CStr::from_ptr(name).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
fn properties(p: &Path) -> Result<Value> {
|
||||
let m = fs::symlink_metadata(p)?;
|
||||
let mut result = entry_metadata(p, &m)?;
|
||||
let time = |value: std::io::Result<SystemTime>| value.ok().and_then(|v|v.duration_since(UNIX_EPOCH).ok()).map(|v|v.as_secs());
|
||||
result["regular"] = json!(m.is_file());
|
||||
result["accessed"] = json!(time(m.accessed()));
|
||||
result["created"] = json!(time(m.created()));
|
||||
result["changed"] = json!(m.ctime());
|
||||
result["allocated"] = json!(m.blocks().saturating_mul(512));
|
||||
result["inode"] = json!(m.ino().to_string());
|
||||
result["device"] = json!(m.dev().to_string());
|
||||
result["links"] = json!(m.nlink());
|
||||
result["owner"] = json!(account_name(m.uid(), false));
|
||||
result["group"] = json!(account_name(m.gid(), true));
|
||||
result["canChmod"] = json!(!m.file_type().is_symlink() && m.uid() == unsafe {libc::geteuid()});
|
||||
// Metadata describes the link itself, never a silently followed target.
|
||||
result["resolvedPath"] = json!(p.canonicalize().ok());
|
||||
Ok(result)
|
||||
}
|
||||
fn list(p: &Value, home: &Path) -> Result<Value> {
|
||||
let dir = PathBuf::from(
|
||||
p["path"]
|
||||
@@ -304,22 +343,30 @@ fn binary(bytes: &[u8]) -> bool {
|
||||
.any(|byte| (*byte < 0x20 && !matches!(*byte, b'\t' | b'\r' | b'\n')) || *byte == 0x7f)
|
||||
}
|
||||
fn read_text(p: &Path) -> Result<Value> {
|
||||
let m = fs::metadata(p)?;
|
||||
read_text_mode(p, false)
|
||||
}
|
||||
fn read_text_mode(p: &Path, as_text: bool) -> Result<Value> {
|
||||
let file = fs::OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK).open(p)?;
|
||||
let m = file.metadata()?;
|
||||
ensure!(
|
||||
m.is_file() && m.len() <= 5 * 1024 * 1024,
|
||||
"Text editor supports files up to 5 MiB"
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
fs::File::open(p)?
|
||||
file
|
||||
.take(5 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut bytes)?;
|
||||
ensure!(
|
||||
bytes.len() <= 5 * 1024 * 1024,
|
||||
"Text editor supports files up to 5 MiB"
|
||||
);
|
||||
ensure!(!binary(&bytes), "Binary files cannot be edited as text");
|
||||
let text = std::str::from_utf8(&bytes).context("Only UTF-8 text can be edited")?;
|
||||
Ok(json!({"text":text,"revision":fingerprint(&bytes)}))
|
||||
let read_only = binary(&bytes) || std::str::from_utf8(&bytes).is_err();
|
||||
if !as_text {
|
||||
ensure!(!binary(&bytes), "Binary files cannot be edited as text");
|
||||
std::str::from_utf8(&bytes).context("Only UTF-8 text can be edited")?;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Ok(json!({"text":text,"revision":fingerprint(&bytes),"readOnly":read_only}))
|
||||
}
|
||||
fn remove(p: &Path) -> Result<()> {
|
||||
let m = fs::symlink_metadata(p)?;
|
||||
@@ -558,7 +605,7 @@ impl Files {
|
||||
);
|
||||
let operation = p["operation"].as_str().unwrap_or("list");
|
||||
match operation {
|
||||
"list" => list(p, home),"properties" => entry(&path(p,"path")?),
|
||||
"list" => list(p, home),"properties" => properties(&path(p,"path")?),
|
||||
"view.preferences" => {
|
||||
let _guard = self.preferences.lock().unwrap();
|
||||
view_preferences(home, p)
|
||||
@@ -581,7 +628,7 @@ impl Files {
|
||||
json!([])
|
||||
})
|
||||
},
|
||||
"read" => read_text(&path(p,"path")?),
|
||||
"read" => read_text_mode(&path(p,"path")?, p["asText"] == true),
|
||||
"write" => {
|
||||
let file = path(p,"path")?.canonicalize()?;
|
||||
let current = read_text(&file)?;
|
||||
@@ -621,10 +668,17 @@ impl Files {
|
||||
},
|
||||
"chmod" => {
|
||||
let file = path(p,"path")?;
|
||||
ensure!(!fs::symlink_metadata(&file)?.file_type().is_symlink(),"Cannot change symlink permissions");
|
||||
// O_PATH permits changing an owned file even when it has mode
|
||||
// 000. Pin the inode so path replacement cannot redirect chmod.
|
||||
let handle = fs::OpenOptions::new().read(true).custom_flags(libc::O_PATH|libc::O_NOFOLLOW).open(&file)?;
|
||||
let metadata = handle.metadata()?;
|
||||
ensure!(!metadata.file_type().is_symlink(),"Cannot change symlink permissions");
|
||||
if let Some(expected) = p.get("identity") {
|
||||
ensure!(expected["device"] == metadata.dev().to_string() && expected["inode"] == metadata.ino().to_string(), "File changed outside the properties panel; reopen properties");
|
||||
}
|
||||
let mode = p["mode"].as_u64().context("Missing file permissions")?;
|
||||
ensure!(mode<=0o777,"Only normal user permission bits can be changed");
|
||||
fs::set_permissions(file, fs::Permissions::from_mode(mode as u32))?;
|
||||
fs::set_permissions(format!("/proc/self/fd/{}",handle.as_raw_fd()), fs::Permissions::from_mode((metadata.mode()&0o7000)|mode as u32))?;
|
||||
Ok(json!(true))
|
||||
},
|
||||
"trash.list" => Ok(json!(read_trash(home)?.into_iter().map(|t|json!({
|
||||
@@ -1241,6 +1295,59 @@ fn target_resolution(path: &Path, p: &Value) -> Result<Option<PathBuf>> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn detailed_properties_and_safe_permission_changes() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let file = temp.path().join("private.txt");
|
||||
fs::write(&file, "data").unwrap();
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o2600)).unwrap();
|
||||
let details = properties(&file).unwrap();
|
||||
assert_eq!(details["regular"], true);
|
||||
assert_eq!(details["size"], 4);
|
||||
assert!(details["inode"].is_string() && details["device"].is_string());
|
||||
assert!(details["allocated"].is_number() && details["links"].is_number());
|
||||
assert_eq!(details["canChmod"], true);
|
||||
let manager = Files::default();
|
||||
let identity = json!({"device":details["device"],"inode":details["inode"]});
|
||||
manager.api(temp.path(), &json!({"operation":"chmod","path":file,"mode":0o640,"identity":identity})).unwrap();
|
||||
assert_eq!(fs::metadata(&file).unwrap().mode() & 0o7777, 0o2640);
|
||||
assert!(manager.api(temp.path(), &json!({"operation":"chmod","path":file,"mode":0o1000})).is_err());
|
||||
let replacement = temp.path().join("replacement");
|
||||
fs::write(&replacement, "other").unwrap();
|
||||
fs::rename(&replacement, &file).unwrap();
|
||||
assert!(manager.api(temp.path(), &json!({"operation":"chmod","path":file,"mode":0o777,"identity":identity})).is_err());
|
||||
let link = temp.path().join("link");
|
||||
symlink(&file,&link).unwrap();
|
||||
let details = properties(&link).unwrap();
|
||||
assert_eq!(details["canChmod"], false);
|
||||
assert_eq!(details["symlink"], true);
|
||||
assert!(manager.api(temp.path(), &json!({"operation":"chmod","path":link,"mode":0o777})).is_err());
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0)).unwrap();
|
||||
manager.api(temp.path(), &json!({"operation":"chmod","path":file,"mode":0o600})).unwrap();
|
||||
assert_eq!(fs::metadata(&file).unwrap().mode() & 0o777, 0o600);
|
||||
}
|
||||
#[test]
|
||||
fn explicit_text_preview_never_allows_binary_overwrite() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let file = temp.path().join("unknown.data");
|
||||
fs::write(&file, "const answer = 42;\n").unwrap();
|
||||
assert_eq!(read_text_mode(&file,true).unwrap()["readOnly"], false);
|
||||
for data in [vec![255,254,0,1],b"binary\x01data".to_vec()] {
|
||||
fs::write(&file, &data).unwrap();
|
||||
let preview = read_text_mode(&file,true).unwrap();
|
||||
assert_eq!(preview["readOnly"], true);
|
||||
assert_eq!(preview["revision"], fingerprint(&data));
|
||||
assert!(Files::default().api(temp.path(), &json!({"operation":"write","path":file,"revision":preview["revision"],"text":"new","approve":true})).is_err());
|
||||
assert_eq!(fs::read(&file).unwrap(),data);
|
||||
}
|
||||
let fifo = temp.path().join("pipe");
|
||||
let path = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
|
||||
assert_eq!(unsafe {libc::mkfifo(path.as_ptr(),0o600)},0);
|
||||
assert!(read_text_mode(&fifo,true).is_err());
|
||||
let oversized = temp.path().join("large");
|
||||
fs::File::create(&oversized).unwrap().set_len(5*1024*1024+1).unwrap();
|
||||
assert!(read_text_mode(&oversized,true).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn media_revisions_track_replacements_and_symlink_targets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let image = temp.path().join("sample.png");
|
||||
|
||||
+16
-1
@@ -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,
|
||||
@@ -751,9 +754,13 @@ impl NetworkPanel {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ProxySettings {
|
||||
#[serde(skip)]
|
||||
pub(crate) github_fallback_attempt: bool,
|
||||
#[serde(default = "proxy_fallback_default")]
|
||||
pub github_auto_fallback: bool,
|
||||
#[serde(default)]
|
||||
pub http_enabled: bool,
|
||||
#[serde(default)]
|
||||
@@ -763,6 +770,10 @@ pub struct ProxySettings {
|
||||
#[serde(default)]
|
||||
pub github: String,
|
||||
}
|
||||
pub fn proxy_fallback_default() -> bool { true }
|
||||
impl Default for ProxySettings {
|
||||
fn default() -> Self { Self { http_enabled:false, github_enabled:false, http:String::new(), github:String::new(), github_auto_fallback:true, github_fallback_attempt:false } }
|
||||
}
|
||||
impl ProxySettings {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
ensure!(
|
||||
@@ -857,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>,
|
||||
@@ -1035,6 +1048,8 @@ pub struct Catalog {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogEntry {
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
|
||||
+15
-5
@@ -211,6 +211,12 @@ pub fn stage_request_proxy(
|
||||
proxy: &ProxySettings,
|
||||
mut progress: impl FnMut(u64, Option<u64>) -> Result<()>,
|
||||
) -> Result<Arc<Staged>> {
|
||||
if let Some(url) = v["url"].as_str() {
|
||||
return crate::http::with_github_fallback(url, &[], proxy, |route| stage_request_once(v, route, &mut progress));
|
||||
}
|
||||
stage_request_once(v, proxy, &mut progress)
|
||||
}
|
||||
fn stage_request_once(v: &serde_json::Value, proxy: &ProxySettings, progress: &mut dyn FnMut(u64, Option<u64>) -> Result<()>) -> Result<Arc<Staged>> {
|
||||
if let Some(encoded) = v.get("package").and_then(|v| v.as_str()) {
|
||||
let staged = Staged::create()?;
|
||||
let mut file = fs::OpenOptions::new().write(true).open(&staged.path)?;
|
||||
@@ -249,7 +255,7 @@ pub fn stage_request_proxy(
|
||||
received += n as u64;
|
||||
progress(received, total)?;
|
||||
}
|
||||
ensure!(total.is_none_or(|n| n == received), "Truncated download");
|
||||
if total.is_some_and(|n| n != received) { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "Truncated download").into()); }
|
||||
file.sync_all()?;
|
||||
let staged = staged.seal()?;
|
||||
if let Some(expected) = v["sha256"].as_str() {
|
||||
@@ -413,6 +419,13 @@ pub fn from_request_proxy(
|
||||
proxy: &ProxySettings,
|
||||
mut progress: impl FnMut(u64, Option<u64>) -> Result<()>,
|
||||
) -> Result<Vec<u8>> {
|
||||
if let Some(url) = v["url"].as_str() {
|
||||
return crate::http::with_github_fallback(url, &[], proxy, |route| from_request_once(v, route, &mut progress));
|
||||
}
|
||||
from_request_once(v, proxy, &mut progress)
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn from_request_once(v: &serde_json::Value, proxy: &ProxySettings, progress: &mut dyn FnMut(u64, Option<u64>) -> Result<()>) -> Result<Vec<u8>> {
|
||||
if let Some(s) = v.get("package").and_then(|v| v.as_str()) {
|
||||
let bytes = STANDARD.decode(s)?;
|
||||
progress(bytes.len() as u64, Some(bytes.len() as u64))?;
|
||||
@@ -444,10 +457,7 @@ pub fn from_request_proxy(
|
||||
bytes.extend_from_slice(&buffer[..n]);
|
||||
progress(bytes.len() as u64, total)?;
|
||||
}
|
||||
ensure!(
|
||||
total.is_none_or(|t| t == bytes.len() as u64),
|
||||
"Truncated download"
|
||||
);
|
||||
if total.is_some_and(|n| n != bytes.len() as u64) { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "Truncated download").into()); }
|
||||
if let Some(expected) = v["sha256"].as_str() {
|
||||
ensure!(digest(&bytes) == expected, "Download SHA256 mismatch");
|
||||
}
|
||||
|
||||
+19
-2
@@ -212,6 +212,7 @@ impl Resolver<'_> {
|
||||
if let Some(e) = &manifest.engines {
|
||||
e.check()?;
|
||||
}
|
||||
if crate::github_plugins::repository(&source).is_some() { crate::github_plugins::check_package(entry, &manifest)?; }
|
||||
ensure!(
|
||||
manifest.id == entry.id && manifest.version == entry.version,
|
||||
"依赖包 ID 或版本与目录不一致"
|
||||
@@ -311,19 +312,35 @@ pub fn prepare_staged(
|
||||
source: Option<String>,
|
||||
choices: BTreeMap<String, String>,
|
||||
cancel: crate::jobs::Cancellation,
|
||||
) -> Result<Prepared> {
|
||||
prepare_staged_sources(db, bytes, source, choices, cancel, vec![])
|
||||
}
|
||||
pub fn prepare_staged_sources(
|
||||
db: &Database, bytes: std::sync::Arc<crate::package::Staged>, source: Option<String>,
|
||||
choices: BTreeMap<String, String>, cancel: crate::jobs::Cancellation, additional: Vec<Source>,
|
||||
) -> Result<Prepared> {
|
||||
cancel.check()?;
|
||||
let mut sources = db.sources.clone();
|
||||
for candidate in additional {
|
||||
candidate.validate()?;
|
||||
if let Some(old) = sources.iter().find(|s| s.id == candidate.id) {
|
||||
ensure!(old.enabled && old.url == candidate.url, "GitHub plugin source is disabled or changed");
|
||||
} else {
|
||||
ensure!(sources.len() < 200, "插件源数量超过 200");
|
||||
sources.push(candidate);
|
||||
}
|
||||
}
|
||||
let manifest = bytes.manifest()?;
|
||||
if let Some(e) = &manifest.engines {
|
||||
e.check()?;
|
||||
}
|
||||
let root = manifest.id.clone();
|
||||
if let Some(source) = &source {
|
||||
ensure!(db.sources.iter().any(|s| &s.id == source), "未知安装来源");
|
||||
ensure!(sources.iter().any(|s| &s.id == source && s.enabled), "未知安装来源");
|
||||
}
|
||||
let mut resolver = Resolver {
|
||||
db,
|
||||
sources: db.sources.clone(),
|
||||
sources,
|
||||
catalogs: BTreeMap::new(),
|
||||
histories: Default::default(),
|
||||
selected: BTreeMap::new(),
|
||||
|
||||
+18
-3
@@ -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)
|
||||
@@ -1140,6 +1145,16 @@ impl Service {
|
||||
if let Err(error)=self.save() {self.db.casting.recording_region_visible=previous;return Err(error);}
|
||||
Ok(json!(true))
|
||||
}
|
||||
"cast.recordingIndicator.visible.save" => {
|
||||
let enabled = p["enabled"].as_bool().context("Missing indicator visibility")?;
|
||||
let previous = self.db.casting.recording_indicator_visible;
|
||||
self.db.casting.recording_indicator_visible = enabled;
|
||||
if let Err(error) = self.save() {
|
||||
self.db.casting.recording_indicator_visible = previous;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(json!({"enabled":enabled}))
|
||||
}
|
||||
"cast.panel.start" => {
|
||||
let executable = self.root.join("current/bin/framely-panel-grab");
|
||||
ensure!(executable.is_file(), "缺少内置屏幕采集助手");
|
||||
@@ -1632,8 +1647,8 @@ impl Service {
|
||||
config.http = config.http.trim().to_owned();
|
||||
config.github = config.github.trim().trim_end_matches('/').to_owned();
|
||||
config.validate()?;
|
||||
self.db.proxy = config;
|
||||
self.save()?;
|
||||
let previous = std::mem::replace(&mut self.db.proxy, config);
|
||||
if let Err(error) = self.save() { self.db.proxy = previous; return Err(error); }
|
||||
Ok(json!(true))
|
||||
}
|
||||
"network.save" | "network.password.setup" => {
|
||||
|
||||
+555
-47
@@ -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,7 +146,47 @@ 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"))?;
|
||||
let data = if remote {
|
||||
String::from_utf8(data)?.replacen("<head>", "<head><meta name=\"framely-remote\" content=\"true\">", 1).into_bytes()
|
||||
} else { data };
|
||||
let data = if guest {
|
||||
String::from_utf8(data)?.replacen("<head>", "<head><meta name=\"framely-cast-guest\" content=\"true\">", 1).into_bytes()
|
||||
} else { data };
|
||||
let mut response = Response::from_data(data)
|
||||
.with_header(header("Content-Type", "text/html; charset=utf-8"))
|
||||
.with_header(header("Cache-Control", "no-store"))
|
||||
.with_header(header("Referrer-Policy", "no-referrer"))
|
||||
.with_header(header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
|
||||
if let Some(cookie) = network_cookie { response = response.with_header(header("Set-Cookie", &cookie)); }
|
||||
r.respond(response)?;
|
||||
Ok(())
|
||||
}
|
||||
fn watch_cookie(r: &Request) -> Option<String> {
|
||||
r.headers().iter().filter(|h| h.field.equiv("Cookie"))
|
||||
.flat_map(|h| h.value.as_str().split(';'))
|
||||
@@ -283,18 +393,18 @@ impl Agent {
|
||||
&& r.method() == &Method::Get
|
||||
&& (path == "/" || path == "/manager" || path == "/cast/watch")
|
||||
{
|
||||
r.respond(
|
||||
Response::empty(StatusCode(302))
|
||||
.with_header(header("Location", if path == "/cast/watch" { "/cast/watch" } else { "/manager" }))
|
||||
.with_header(header(
|
||||
"Set-Cookie",
|
||||
&format!(
|
||||
"framely-network={}; HttpOnly; SameSite=Strict; Path=/",
|
||||
self.network_key.lock().unwrap()
|
||||
),
|
||||
)),
|
||||
)?;
|
||||
return Ok(());
|
||||
// A cross-site redirect chain may withhold Strict cookies on
|
||||
// every hop. Commit a page instead of redirecting to itself;
|
||||
// subsequent same-site asset/API requests carry the cookie.
|
||||
let cookie = format!("framely-network={}; HttpOnly; SameSite=Strict; Path=/", self.network_key.lock().unwrap());
|
||||
if path == "/" {
|
||||
r.respond(Response::empty(StatusCode(302))
|
||||
.with_header(header("Location", "/manager"))
|
||||
.with_header(header("Set-Cookie", &cookie))
|
||||
.with_header(header("Cache-Control", "no-store")))?;
|
||||
return Ok(());
|
||||
}
|
||||
return self.send_page(r, true, false, Some(cookie));
|
||||
}
|
||||
if path == "/setup" && r.method() == &Method::Post {
|
||||
ensure!(
|
||||
@@ -313,7 +423,7 @@ impl Agent {
|
||||
match self.core("network.password.setup", json!({"password":fields.get("password"),"confirmPassword":fields.get("confirmPassword")})) {
|
||||
Ok(_) => {
|
||||
*self.network_key.lock().unwrap() = random_key();
|
||||
r.respond(Response::empty(StatusCode(303)).with_header(header("Location", "/manager")))?;
|
||||
r.respond(Response::empty(StatusCode(303)).with_header(header("Location", "/manager")).with_header(header("Cache-Control", "no-store")))?;
|
||||
}
|
||||
Err(error) => {
|
||||
r.respond(Response::from_string(format!("{error}。请返回重试。"))
|
||||
@@ -368,6 +478,7 @@ impl Agent {
|
||||
if verified && key_before == *self.network_key.lock().unwrap() {
|
||||
r.respond(
|
||||
Response::empty(StatusCode(302))
|
||||
.with_header(header("Cache-Control", "no-store"))
|
||||
.with_header(header("Location", if destination { "/cast/watch" } else { "/manager" }))
|
||||
.with_header(header(
|
||||
"Set-Cookie",
|
||||
@@ -387,7 +498,7 @@ impl Agent {
|
||||
return Ok(());
|
||||
}
|
||||
if r.method() == &Method::Get && (path == "/" || path == "/manager" || path == "/cast/watch") {
|
||||
r.respond(Response::from_string(if configured { if path == "/cast/watch" { NETWORK_LOGIN.replace("<form method=\"post\" action=\"/login\">", "<form method=\"post\" action=\"/login\"><input type=\"hidden\" name=\"next\" value=\"/cast/watch\">") } else { NETWORK_LOGIN.into() } } else { NETWORK_SETUP.into() }).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'")))?;
|
||||
r.respond(Response::from_string(if configured { if path == "/cast/watch" { NETWORK_LOGIN.replace("<form method=\"post\" action=\"/login\">", "<form method=\"post\" action=\"/login\"><input type=\"hidden\" name=\"next\" value=\"/cast/watch\">") } else { NETWORK_LOGIN.into() } } else { NETWORK_SETUP.into() }).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Cache-Control","no-store")).with_header(header("Content-Security-Policy","default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'")))?;
|
||||
} else {
|
||||
r.respond(
|
||||
Response::empty(StatusCode(401))
|
||||
@@ -398,17 +509,21 @@ impl Agent {
|
||||
}
|
||||
if remote && r.method() == &Method::Get && path == "/" {
|
||||
r.respond(
|
||||
Response::empty(StatusCode(302)).with_header(header("Location", "/manager")),
|
||||
Response::empty(StatusCode(302)).with_header(header("Location", "/manager")).with_header(header("Cache-Control", "no-store")),
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
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.
|
||||
@@ -418,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!({}));}}}
|
||||
@@ -425,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;
|
||||
@@ -445,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 (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)};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/") {
|
||||
@@ -577,6 +839,7 @@ impl Agent {
|
||||
if let Some(key) = path.strip_prefix("/boot/") {
|
||||
ensure!(key == self.web_key, "Invalid boot token");
|
||||
let response = Response::empty(StatusCode(302))
|
||||
.with_header(header("Cache-Control", "no-store"))
|
||||
.with_header(header(
|
||||
"Set-Cookie",
|
||||
&format!(
|
||||
@@ -586,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"
|
||||
@@ -946,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"
|
||||
@@ -956,22 +1246,7 @@ impl Agent {
|
||||
|| path == "/launcher"
|
||||
|| path.starts_with("/window/")
|
||||
{
|
||||
let data = fs::read(self.assets.join("index.html"))?;
|
||||
let data = if remote {
|
||||
String::from_utf8(data)?
|
||||
.replacen(
|
||||
"<head>",
|
||||
"<head><meta name=\"framely-remote\" content=\"true\">",
|
||||
1,
|
||||
)
|
||||
.into_bytes()
|
||||
} else {
|
||||
data
|
||||
};
|
||||
let data = if guest { String::from_utf8(data)?.replacen("<head>", "<head><meta name=\"framely-cast-guest\" content=\"true\">", 1).into_bytes() } else { data };
|
||||
let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Cache-Control","no-store")).with_header(header("Referrer-Policy","no-referrer")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
|
||||
r.respond(response)?;
|
||||
return Ok(());
|
||||
return self.send_page(r, remote, guest, None);
|
||||
}
|
||||
r.respond(Response::from_string("Not found").with_status_code(404))?;
|
||||
Ok(())
|
||||
@@ -1060,6 +1335,7 @@ impl Agent {
|
||||
self.receivers.lock().unwrap().configure(&settings)?;
|
||||
Ok(result)
|
||||
},
|
||||
"cast.recordingIndicator.visible.save" => self.core(method, p),
|
||||
"cast.region.visible.save" => {
|
||||
let enabled=p["enabled"].as_bool().context("Missing region visibility")?;
|
||||
let mut settings:crate::cast::settings::Settings=serde_json::from_value(self.core("status",json!({}))?["database"]["casting"].clone())?;
|
||||
@@ -1072,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),"画幅调整仅支持屏幕采集的单眼画面");
|
||||
@@ -1280,9 +1635,10 @@ impl Agent {
|
||||
let _gate = APK_STORAGE_API_GATE.lock().unwrap();
|
||||
ensure!(!self.jobs.active_kind("apk:storage") && !self.jobs.active_kind("apk:operation"), "请等待 APK 操作或存储迁移完成");
|
||||
let home = crate::steam::home()?;
|
||||
let proxy:crate::model::ProxySettings = serde_json::from_value(self.core("status", json!({}))?["database"]["proxy"].clone())?;
|
||||
let ticket = p["ticket"].as_str().context("Missing APK review")?.to_owned();
|
||||
self.jobs.task_progress("apk:operation", move |cancel, progress| {
|
||||
crate::apk::q2f_convert(&home, &ticket, &cancel, |value| { progress(value); Ok(()) })
|
||||
crate::apk::q2f_convert(&home, &ticket, &cancel, |value| { progress(value); Ok(()) }, &proxy)
|
||||
})
|
||||
}
|
||||
"apk.cleanup.list" => crate::apk::cleanup_list(&crate::steam::home()?),
|
||||
@@ -1436,6 +1792,7 @@ impl Agent {
|
||||
}
|
||||
"catalog" => self.catalog(),
|
||||
"catalog.start" => {
|
||||
let refresh = p["refresh"].as_bool().unwrap_or(false);
|
||||
let status = self.core("status", json!({}))?;
|
||||
let sources: Vec<Source> =
|
||||
serde_json::from_value(status["database"]["sources"].clone())?;
|
||||
@@ -1444,11 +1801,11 @@ impl Agent {
|
||||
let kind = format!(
|
||||
"catalog:{}",
|
||||
crate::package::digest(&serde_json::to_vec(
|
||||
&json!({"sources":sources,"proxy":proxy})
|
||||
&json!({"sources":sources,"proxy":proxy,"refresh":refresh})
|
||||
)?)
|
||||
);
|
||||
self.jobs.task_progress(&kind, move |cancel, progress| {
|
||||
fetch_catalogs_progress(sources, proxy, cache, &cancel, progress.as_ref())
|
||||
fetch_library(sources, proxy, cache, &cancel, progress.as_ref(), refresh)
|
||||
})
|
||||
}
|
||||
"catalog.versions.start" => {
|
||||
@@ -1461,10 +1818,16 @@ impl Agent {
|
||||
let status = self.core("status", json!({}))?;
|
||||
let sources: Vec<Source> =
|
||||
serde_json::from_value(status["database"]["sources"].clone())?;
|
||||
ensure!(!sources.iter().any(|s| s.id == source_id && !s.enabled), "插件源不存在或已停用");
|
||||
let source = sources
|
||||
.into_iter()
|
||||
.find(|s| s.id == source_id && s.enabled)
|
||||
.context("插件源不存在或已停用")?;
|
||||
.map(Ok).unwrap_or_else(|| {
|
||||
let repo = p["githubRepository"].as_str().context("插件源不存在或已停用")?;
|
||||
let source = crate::github_plugins::source(repo)?;
|
||||
ensure!(source.id == source_id, "GitHub source identity mismatch");
|
||||
Ok::<_, anyhow::Error>(source)
|
||||
})?;
|
||||
let proxy = self.proxy()?;
|
||||
self.jobs.task(
|
||||
&format!("versions:{}:{}", source.id, plugin_id),
|
||||
@@ -1672,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"
|
||||
@@ -1693,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"
|
||||
@@ -1738,11 +2120,11 @@ impl Agent {
|
||||
fn catalog(&self) -> Result<Value> {
|
||||
let status = self.core("status", json!({}))?;
|
||||
let sources: Vec<Source> = serde_json::from_value(status["database"]["sources"].clone())?;
|
||||
Ok(fetch_catalogs_proxy(
|
||||
fetch_library(
|
||||
sources,
|
||||
serde_json::from_value(status["database"]["proxy"].clone())?,
|
||||
self.catalog_cache.clone(),
|
||||
))
|
||||
self.catalog_cache.clone(), &Default::default(), &|_| {}, false,
|
||||
)
|
||||
}
|
||||
}
|
||||
pub(crate) fn fetch_catalog_proxy_cancel(
|
||||
@@ -1752,6 +2134,13 @@ pub(crate) fn fetch_catalog_proxy_cancel(
|
||||
) -> Result<Catalog> {
|
||||
cancel.check()?;
|
||||
source.validate()?;
|
||||
if let Some(repo) = crate::github_plugins::repository(source) {
|
||||
let client = crate::github_plugins::Client::system(proxy, cancel, false)?;
|
||||
return Ok(crate::github_plugins::catalog(&source.name, &client.entries(&repo)?));
|
||||
}
|
||||
if proxy.github_auto_fallback {
|
||||
return crate::http::with_github_fallback(&source.url, &[], proxy, |route| fetch_catalog_proxy_cancel(source, route, cancel));
|
||||
}
|
||||
let response = crate::http::get_with_proxy_cancel(
|
||||
&source.url,
|
||||
source.allow_http,
|
||||
@@ -1789,7 +2178,14 @@ pub(crate) fn fetch_plugin_versions_cancel(
|
||||
) -> Result<Vec<CatalogEntry>> {
|
||||
source.validate()?;
|
||||
valid_id(plugin_id)?;
|
||||
if let Some(repo) = crate::github_plugins::repository(source) {
|
||||
let client = crate::github_plugins::Client::system(proxy, cancel, false)?;
|
||||
return Ok(client.entries(&repo)?.into_iter().filter(|p| p.id == plugin_id).collect());
|
||||
}
|
||||
let url = url::Url::parse(&source.url)?.join(&format!("plugins/{plugin_id}/versions.json"))?;
|
||||
if proxy.github_auto_fallback {
|
||||
return crate::http::with_github_fallback(url.as_str(), &[], proxy, |route| fetch_plugin_versions_cancel(source, plugin_id, route, cancel));
|
||||
}
|
||||
let response = crate::http::get_with_proxy_cancel(
|
||||
url.as_str(),
|
||||
source.allow_http,
|
||||
@@ -1869,6 +2265,7 @@ fn fetch_catalogs(
|
||||
) -> Value {
|
||||
fetch_catalogs_proxy(sources, Default::default(), cache)
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn fetch_catalogs_proxy(
|
||||
sources: Vec<Source>,
|
||||
proxy: crate::model::ProxySettings,
|
||||
@@ -1885,6 +2282,49 @@ fn fetch_catalogs_cancel(
|
||||
) -> Result<Value> {
|
||||
fetch_catalogs_progress(sources, proxy, cache, cancel, &|_| {})
|
||||
}
|
||||
fn fetch_library(
|
||||
sources: Vec<Source>, proxy: ProxySettings,
|
||||
cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>, cancel: &crate::jobs::Cancellation,
|
||||
progress: &(dyn Fn(Value) + Send + Sync), refresh: bool,
|
||||
) -> Result<Value> {
|
||||
let client = crate::github_plugins::Client::system(&proxy, cancel, refresh)?;
|
||||
let (mut result, discovered) = std::thread::scope(|scope| {
|
||||
let searching = scope.spawn(|| client.discover());
|
||||
let normal = sources.iter().filter(|s| crate::github_plugins::repository(s).is_none()).cloned().collect();
|
||||
let result = fetch_catalogs_progress(normal, proxy.clone(), cache.clone(), cancel, progress);
|
||||
(result, searching.join().unwrap_or_else(|_| Err(anyhow::anyhow!("GitHub discovery failed"))))
|
||||
});
|
||||
cancel.check()?;
|
||||
let mut repositories: Vec<_> = sources.iter().filter(|s| s.enabled && crate::github_plugins::repository(s).is_some()).cloned().collect();
|
||||
match discovered {
|
||||
Ok(discovered) => repositories.extend(discovered.into_iter().filter(|s| !sources.iter().any(|old|
|
||||
old.id == s.id || crate::github_plugins::repository(old).is_some_and(|repo|
|
||||
crate::github_plugins::repository(s).is_some_and(|new|repo.eq_ignore_ascii_case(&new)))))),
|
||||
Err(error) => client.warnings.lock().unwrap().push(crate::http::github_fallback::diagnostic(&error)),
|
||||
}
|
||||
{
|
||||
let discovered = repositories;
|
||||
let values = result.as_mut().map_err(|e| anyhow::anyhow!("{e:#}"))?.as_array_mut().context("Invalid catalog results")?;
|
||||
for chunk in discovered.chunks(6) {
|
||||
cancel.check()?;
|
||||
let batch = std::thread::scope(|scope| chunk.iter().map(|source| {
|
||||
let client = &client;
|
||||
scope.spawn(move || match client.entries(&crate::github_plugins::repository(source).unwrap()) {
|
||||
Ok(entries) => json!({"source":source,"catalog":crate::github_plugins::catalog(&source.name,&entries),"versions":entries,"fetchedAt":crate::service::now_ms()}),
|
||||
Err(error) => json!({"source":source,"error":crate::http::github_fallback::diagnostic(&error)}),
|
||||
})
|
||||
}).collect::<Vec<_>>().into_iter().map(|h| h.join().unwrap_or_else(|_| json!({"error":"GitHub release worker failed"}))).collect::<Vec<_>>());
|
||||
values.extend(batch);
|
||||
progress(json!({"completedSources":values.len(),"totalSources":sources.iter().filter(|s|s.enabled && crate::github_plugins::repository(s).is_none()).count()+discovered.len(),"partial":values}));
|
||||
}
|
||||
}
|
||||
let warnings = client.warnings.lock().unwrap();
|
||||
if !warnings.is_empty() {
|
||||
result.as_mut().map_err(|e| anyhow::anyhow!("{e:#}"))?.as_array_mut().unwrap().push(json!({"source":{"id":"github-discovery","name":"GitHub","url":"https://github.com/topics/framely-plugin","enabled":true,"allowHttp":false},
|
||||
"warning":warnings.iter().take(20).cloned().collect::<Vec<_>>().join("\n")}));
|
||||
}
|
||||
result
|
||||
}
|
||||
fn fetch_catalogs_progress(
|
||||
sources: Vec<Source>,
|
||||
proxy: crate::model::ProxySettings,
|
||||
@@ -2089,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")
|
||||
@@ -2103,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,
|
||||
@@ -2237,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)
|
||||
@@ -2255,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");
|
||||
@@ -2275,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(())
|
||||
@@ -2638,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"),
|
||||
@@ -3448,6 +3912,50 @@ mod tests {
|
||||
.set("Origin", "http://other.example")
|
||||
.send_json(json!({"method":"ui.events"}))
|
||||
.is_err());
|
||||
agent.core("agreement.accept", json!({"version":crate::model::AGREEMENT_VERSION,"userAgreement":true,"privacyStatement":true})).unwrap();
|
||||
agent.core("network.save", json!({"enabled":true,"port":15915,"passwordEnabled":false})).unwrap();
|
||||
// Reproduce a cross-site navigation whose redirect chain never sends
|
||||
// Strict cookies. Each direct landing must terminate without a hop.
|
||||
for path in ["/manager", "/cast/watch"] {
|
||||
for _ in 0..3 {
|
||||
let page = client.get(&format!("{origin}{path}"))
|
||||
.set("Sec-Fetch-Site", "cross-site")
|
||||
.set("Sec-Fetch-Mode", "navigate")
|
||||
.call().unwrap();
|
||||
assert_eq!(page.status(), 200);
|
||||
assert!(page.header("Location").is_none());
|
||||
assert_eq!(page.header("Cache-Control"), Some("no-store"));
|
||||
assert_eq!(page.header("Referrer-Policy"), Some("no-referrer"));
|
||||
let grant = page.header("Set-Cookie").unwrap();
|
||||
assert!(grant.contains("HttpOnly") && grant.contains("SameSite=Strict") && grant.contains("Path=/"));
|
||||
let html = page.into_string().unwrap();
|
||||
assert!(html.contains("framely-remote"));
|
||||
assert!(!html.contains("framely-cast-guest"));
|
||||
}
|
||||
}
|
||||
let landing = client.get(&origin).call().unwrap();
|
||||
assert_eq!(landing.status(), 302);
|
||||
assert_eq!(landing.header("Location"), Some("/manager"));
|
||||
assert_eq!(landing.header("Cache-Control"), Some("no-store"));
|
||||
// Only the landing grants a cookie; it does not authorize other
|
||||
// cookie-less routes or bypass same-origin checks on later requests.
|
||||
for path in ["/api", "/manager-api/files"] {
|
||||
assert!(matches!(client.post(&format!("{origin}{path}"))
|
||||
.set("Origin", &origin).send_json(json!({"method":"ui.events"})),
|
||||
Err(ureq::Error::Status(401, _))));
|
||||
}
|
||||
assert!(client.post(&format!("{origin}/api"))
|
||||
.set("Cookie", cookie).set("Origin", "http://other.example")
|
||||
.send_json(json!({"method":"ui.events"})).is_err());
|
||||
assert!(client.post(&format!("{origin}/api"))
|
||||
.set("Cookie", cookie).set("Origin", &origin)
|
||||
.send_json(json!({"method":"ui.events"})).is_ok());
|
||||
agent.core("network.save", json!({"enabled":true,"port":15915,"passwordEnabled":true})).unwrap();
|
||||
let login = client.get(&format!("{origin}/manager")).call().unwrap();
|
||||
assert_eq!(login.status(), 200);
|
||||
assert!(login.header("Set-Cookie").is_none());
|
||||
assert_eq!(login.header("Cache-Control"), Some("no-store"));
|
||||
assert!(login.into_string().unwrap().contains("action=\"/login\""));
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
worker.join().unwrap();
|
||||
core_worker.join().unwrap();
|
||||
|
||||
@@ -369,12 +369,16 @@ pub fn fetch_cancel(
|
||||
ensure!(v.len() <= 2048 && !v.contains(['\r', '\n']), "无效更新时间");
|
||||
headers.push(("If-Modified-Since", v));
|
||||
}
|
||||
let response = crate::http::get_with_proxy(
|
||||
if proxy.github_auto_fallback {
|
||||
return crate::http::with_github_fallback(url, &headers, proxy, |route| fetch_cancel(url, etag, modified, route, cancel));
|
||||
}
|
||||
let response = crate::http::get_with_proxy_cancel(
|
||||
url,
|
||||
false,
|
||||
std::time::Duration::from_secs(20),
|
||||
&headers,
|
||||
proxy,
|
||||
cancel,
|
||||
)?;
|
||||
if response.status() == 304 {
|
||||
return Ok((None, None, None));
|
||||
|
||||
@@ -1344,6 +1344,25 @@ fn network_panel_defaults_persist_and_validate() {
|
||||
assert_eq!(restored.db.network_panel.port, 16000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_indicator_visibility_persists_and_rolls_back_on_save_failure() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut core = accepted_service(root.path(), 1000).unwrap();
|
||||
assert!(core.db.casting.recording_indicator_visible);
|
||||
core.handle("cast.recordingIndicator.visible.save", json!({"enabled":false})).unwrap();
|
||||
assert!(!accepted_service(root.path(), 1000).unwrap().db.casting.recording_indicator_visible);
|
||||
assert!(core.handle("cast.recordingIndicator.visible.save", json!({"enabled":"yes"})).is_err());
|
||||
// Making the state directory unavailable must not change the in-memory flag.
|
||||
let moved = root.path().with_extension("indicator-save-failure");
|
||||
fs::rename(root.path(), &moved).unwrap();
|
||||
fs::write(root.path(), "not a directory").unwrap();
|
||||
let result = core.handle("cast.recordingIndicator.visible.save", json!({"enabled":true}));
|
||||
fs::remove_file(root.path()).unwrap();
|
||||
fs::rename(moved, root.path()).unwrap();
|
||||
assert!(result.is_err());
|
||||
assert!(!core.db.casting.recording_indicator_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_settings_persist_and_reject_invalid_values() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -1376,6 +1395,17 @@ fn proxy_settings_persist_and_reject_invalid_values() {
|
||||
assert_eq!(core.db.proxy, crate::model::ProxySettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_fallback_defaults_on_and_opt_out_persists() {
|
||||
let legacy: ProxySettings = serde_json::from_value(json!({"http":"","github":""})).unwrap();
|
||||
assert!(legacy.github_auto_fallback);
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut core = accepted_service(root.path(), 1000).unwrap();
|
||||
core.handle("proxy.save", json!({"http":"","github":"","githubAutoFallback":false})).unwrap();
|
||||
assert!(!accepted_service(root.path(), 1000).unwrap().db.proxy.github_auto_fallback);
|
||||
assert!(core.handle("proxy.save", json!({"githubAutoFallback":"false"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_launch_agreement_gates_operations_and_persists_consent() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -2135,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();
|
||||
|
||||
+9
-5
@@ -291,6 +291,8 @@ impl Updater {
|
||||
cancel.check()?;
|
||||
let result = resolve_release(&source, channel, &cancel, &mut |url, limit| {
|
||||
crate::model::validate_url(url, false)?;
|
||||
let headers=[("User-Agent", "Framely"),("Accept", "application/vnd.github+json")];
|
||||
crate::http::with_github_fallback(url,&headers,&proxy,|proxy| {
|
||||
let response = crate::http::get_with_proxy_cancel(
|
||||
url,
|
||||
false,
|
||||
@@ -303,6 +305,7 @@ impl Updater {
|
||||
&cancel,
|
||||
)?;
|
||||
crate::http::read_cancel(response, limit, &cancel)
|
||||
})
|
||||
})?;
|
||||
if let Some((release, bytes, version)) = result {
|
||||
let info = json!({"release":release,"version":version,"channel":channel});
|
||||
@@ -348,7 +351,8 @@ impl Updater {
|
||||
progress(value);
|
||||
Ok(())
|
||||
};
|
||||
let result = (|| -> Result<Value> {
|
||||
let result = crate::http::with_github_fallback(&release.url, &[], &proxy, |proxy| (|| -> Result<Value> {
|
||||
cancel.check()?;
|
||||
report(json!({"phase":"downloading","received":0,"total":release.size,"version":release.version}))?;
|
||||
let response = crate::http::get_download_with_proxy(&release.url, false, Duration::from_secs(120), &[], &proxy)?;
|
||||
if let Some(length) = response.header("Content-Length") {
|
||||
@@ -374,16 +378,16 @@ impl Updater {
|
||||
}
|
||||
output.sync_all()?;
|
||||
drop(output);
|
||||
ensure!(received == release.size, "下载未完成");
|
||||
if received != release.size { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "下载未完成").into()); }
|
||||
verify_archive(&archive, &release, &cancel, report)?;
|
||||
fs::write(stage.join("descriptor.json"), descriptor)?;
|
||||
fs::write(stage.join("descriptor.json"), &descriptor)?;
|
||||
cancel.commit(|| {
|
||||
report(json!({"phase":"ready","version":release.version,"received":received,"verified":received,"total":release.size}))?;
|
||||
*updater.prepared.lock().unwrap() = Some((stage.clone(), release.clone(), source_url));
|
||||
*updater.prepared.lock().unwrap() = Some((stage.clone(), release.clone(), source_url.clone()));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!(release))
|
||||
})();
|
||||
})());
|
||||
if let Err(e) = &result {
|
||||
let _ = fs::remove_dir_all(stage);
|
||||
let _ = write_status(&state_path, json!({"phase":"failed","error":e.to_string()}));
|
||||
|
||||
@@ -34,6 +34,8 @@ The template and bundled SDK use `AGPL-3.0-only`. See Framely's `docs/developer-
|
||||
|
||||
For community GitHub registration, source may omit `downloadUrl`; default assets use `<id>-<version>.framely`. Release packages need a temporary Manifest with the derived URL. See Framely's `docs/developer-guide/publishing.md` for commands, Release digests, custom URLs and database window-dimension validation. The direct packaging command above is for core testing and does not establish community registration acceptance.
|
||||
|
||||
For automatic GitHub discovery, add topic `framely-plugin` and upload `release.json` alongside each Release's final packages and images. Generate it with `framely release-json --packages ./release --output ./release/release.json`; include all generated files in your publishing workflow. No npm publication or catalog is needed. See Framely's `docs/github-plugin-discovery.md` for metadata and old-release compatibility.
|
||||
|
||||
Launcher support requires Framely 0.4.3-preview.2+. `actions.ts` is built separately; edit `engines.framely`, `ui.launch` and `ui.launcherActions` in the manifest. See the bilingual launcher developer guide.
|
||||
|
||||
启动台功能要求 Framely 0.4.3-preview.2 或更新版本。`actions.ts` 单独构建;在清单中调整 `engines.framely`、`ui.launch` 和 `ui.launcherActions`,详见中英双语启动台开发文档。
|
||||
@@ -33,3 +33,5 @@ cargo run --manifest-path "$FRAMELY_REPO/Cargo.toml" --locked -- verify my-plugi
|
||||
模板及随附 SDK 使用 `AGPL-3.0-only`。完整开发到发布说明见 Framely 的 `docs/zh-CN/developer-guide/README.md`;模板的生命周期回调不会删除用户数据。
|
||||
|
||||
用于社区 GitHub Release 登记时,源码可省略 `downloadUrl`,默认附件名为 `<id>-<version>.framely`。发布包要用包含自动生成地址的临时清单打包;完整命令、Release digest 校验、自定义地址和数据库窗口尺寸校验规则见 Framely 的 `docs/zh-CN/developer-guide/publishing.md`。上面的直接打包命令用于本体测试,不代表社区登记已经通过。
|
||||
|
||||
需要 GitHub 自动发现时,为仓库添加 `framely-plugin` topic,每个 Release 上传最终包及 `release.json`。使用 `framely release-json --packages ./release --output ./release/release.json` 自动生成元数据和发布图片,将发行目录内所有文件加入自己的发布流程;无需 npm 发布或目录文件。格式及旧 Release 兼容见 Framely 的 `docs/zh-CN/github-plugin-discovery.md`。
|
||||
@@ -0,0 +1,15 @@
|
||||
import {build} from 'esbuild';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
|
||||
globalThis.document={documentElement:{lang:''}};
|
||||
const bundle=await build({entryPoints:['ui/src/apk-errors.ts'],bundle:true,platform:'node',format:'cjs',write:false});
|
||||
const module={exports:{}};
|
||||
new Function('module','exports',bundle.outputFiles[0].text)(module,module.exports);
|
||||
const {apkError}=module.exports;
|
||||
assert.equal(apkError(new Error('New installation failed; newly created container was cleaned up: /test/baked: missing')),'安装失败,本次新建的容器已清理:/test/baked: missing');
|
||||
assert.equal(apkError(new Error('Installation failed; container retained because safe cleanup failed: install; cleanup: ownership')),'安装失败,安全清理未完成,容器已保留:install; cleanup: ownership');
|
||||
assert.equal(apkError(new Error('Installation failed; existing container and data retained (backup: backup-1): missing\npath')),'更新失败,原容器和数据已保留。备份:backup-1。详情:missing\npath');
|
||||
assert.match(apkError('APK state is unknown; reconcile before changing the application'),/APK 状态未知/);
|
||||
assert.equal(apkError(new Error('unmapped diagnostic /path')),'unmapped diagnostic /path');
|
||||
console.log('APK failure outcomes are localized and diagnostic details are preserved.');
|
||||
@@ -0,0 +1,45 @@
|
||||
import React,{useRef,useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {FramingEditor} from '../ui/src/cast-framing-editor';
|
||||
import type {CastSettings} from '../ui/src/casting';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
import '../ui/src/casting.css';
|
||||
import '../ui/src/cast-spatial-framing.css';
|
||||
configureLanguage('zh-CN',[]);
|
||||
let commits:Partial<CastSettings>[]=[];
|
||||
const initial:CastSettings={source:'screen',codec:'h265',output:'eye',eye:'right',width:1600,height:1200,fps:60,bitrateMbps:8,horizontalFov:60,centerX:0,centerY:0,systemAudio:true,microphone:false,airplay:false,dlna:false,receiverName:'Framely'};
|
||||
function Fixture(){
|
||||
const handle=useRef<HTMLButtonElement>(null),[settings,setSettings]=useState(initial);
|
||||
return <div className="app"><FramingEditor spatial moveHandle={handle} settings={settings} view={{tanHalfHorizontal:1.4,centerTanX:0,centerTanY:0,aspect:1}} onChange={patch=>{commits.push(patch);setSettings(s=>({...s,...patch}));}} onError={error=>{throw Error(error);}}/><button ref={handle}>拖动调整录制位置</button></div>;
|
||||
}
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));
|
||||
const check=(value:unknown,message:string)=>{if(!value)throw Error(message);};
|
||||
const close=(a:number,b:number)=>Math.abs(a-b)<.03;
|
||||
const pointer=(target:EventTarget,type:string,x:number,y:number,buttons=1,id=8)=>target.dispatchEvent(new PointerEvent(type,{bubbles:true,cancelable:true,button:0,buttons,pointerId:id,clientX:x,clientY:y}));
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
for(let i=0;i<100&&!document.querySelector('.cast-crop-box');i++)await wait(20);
|
||||
await wait(100);
|
||||
const box=document.querySelector<HTMLElement>('.cast-crop-box')!,handle=document.querySelector<HTMLButtonElement>('.app>button')!;
|
||||
const before=box.getBoundingClientRect();
|
||||
pointer(handle,'pointerdown',200,300);pointer(handle,'pointermove',230,280);
|
||||
const moved=box.getBoundingClientRect();check(close(moved.x-before.x,30)&&close(moved.y-before.y,-20),`Handle did not translate crop: ${moved.x-before.x}, ${moved.y-before.y}`);
|
||||
check(close(moved.width,before.width)&&close(moved.height,before.height),'Pan resized crop');
|
||||
pointer(document,'pointerup',230,280,0);await wait();
|
||||
check(commits.length===1&&commits[0].centerX!>0&&commits[0].centerY!<0,'Pan not saved');
|
||||
check(close(commits[0].horizontalFov!,60),'Pan changed FOV');
|
||||
const count=commits.length;pointer(handle,'pointermove',400,400,0);pointer(handle,'pointermove',450,450,1);await wait();
|
||||
check(commits.length===count&&close(box.getBoundingClientRect().x,moved.x),'Hover continued pan');
|
||||
pointer(box,'pointerdown',200,300);pointer(box,'pointermove',190,310);pointer(document,'pointerup',190,310,0);await wait();
|
||||
check(commits.length===count+1,'Frame interior no longer draggable');
|
||||
pointer(handle,'pointerdown',200,300);pointer(handle,'pointermove',210,310);window.dispatchEvent(new Event('framely.pointerRelease'));await wait();
|
||||
check(!handle.hasAttribute('data-dragging'),'Native release left drag armed');
|
||||
const released=box.getBoundingClientRect();pointer(handle,'pointermove',500,500,1);await wait();
|
||||
check(close(box.getBoundingClientRect().x,released.x),'Native release did not stop pan');
|
||||
pointer(handle,'pointerdown',200,300);pointer(handle,'pointermove',220,320);pointer(document,'pointercancel',220,320,0);await wait();
|
||||
check(!handle.hasAttribute('data-dragging'),'Cancel left drag armed');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}
|
||||
};
|
||||
createRoot(document.getElementById('root')!).render(<Fixture/>);
|
||||
console.log('FRAMELY_VIEW_READY');
|
||||
@@ -22,6 +22,13 @@ for(const ratio of [4/3,3/4,16/9,9/16,1]){
|
||||
assert.equal(gesture.move(2,300,300,0,{width:1000,height:1000},ratio,minimum),null);
|
||||
}
|
||||
const reference={tanHalfHorizontal:1.4,centerTanX:0,centerTanY:0,aspect:1};
|
||||
const pan=new FrameGesture();pan.begin(3,400,400,rect,null);
|
||||
const moved=pan.move(3,440,370,1,{width:1000,height:1000},ratio,minimum);
|
||||
close(moved.x,rect.x+.04);close(moved.y,rect.y-.03);close(moved.width,rect.width);close(moved.height,rect.height);
|
||||
assert.equal(pan.move(4,600,600,1,{width:1000,height:1000},ratio,minimum),null);
|
||||
const edge=pan.move(3,5000,-5000,1,{width:1000,height:1000},ratio,minimum);
|
||||
close(edge.x,1-rect.width);close(edge.y,0);close(edge.width,rect.width);
|
||||
assert.equal(pan.finish(3),true);assert.equal(pan.move(3,400,400,1,{width:1000,height:1000},ratio,minimum),null);
|
||||
const saved=normalizeRegion({width:Math.round(1600*ratio),height:1600,horizontalFov:10,centerX:0,centerY:0},reference,{width:.24,height:.28});
|
||||
const displayed=frameFromSettings(saved,saved.width/saved.height,reference);assert.ok(displayed.width>=.24-1e-10&&displayed.height>=.28-1e-10);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Render the real quick panel with simulated API responses. No device changes.
|
||||
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));
|
||||
const until=async(f:()=>unknown)=>{for(let i=0;i<150;i++){if(f())return;await wait(30);}throw Error('Timed out: '+document.body.innerText);};
|
||||
history.replaceState(null,'','/?fixture=1#casting');
|
||||
const casting={source:'screen',codec:'h265',output:'eye',eye:'right',width:2560,height:1920,fps:90,bitrateMbps:12,horizontalFov:null,centerX:.5,centerY:.5,systemAudio:true,microphone:false,airplay:false,dlna:false,receiverName:'Framely',recordingIndicatorVisible:true,recordingRegionVisible:false};
|
||||
let streaming=false,recording=false,failSave=false,framing=0,opens=0;
|
||||
const calls:string[]=[];
|
||||
window.fetch=async(_url,init)=>{
|
||||
const {method,params}=JSON.parse(String(init?.body));calls.push(method);let result:any=true;
|
||||
if(method==='status')result={version:'test',agreement:{version:1,accepted:true},database:{casting,language:new URLSearchParams(location.search).get('lang')??'zh-CN',plugins:{},sources:[],safeMode:false},running:[],notifications:[],inbox:[]};
|
||||
else if(method==='language.list')result={packs:[],invalidFiles:[]};
|
||||
else if(method==='ui.events')result={cursor:0,events:[]};
|
||||
else if(method==='cast.status')result={running:streaming,recording:{active:recording,elapsedSeconds:recording?83:0},watchAddresses:['http://192.168.5.67:15915/cast/watch']};
|
||||
else if(method==='cast.start')streaming=true;
|
||||
else if(method==='cast.stop')streaming=false;
|
||||
else if(method==='cast.record.start')recording=true;
|
||||
else if(method==='cast.record.stop')recording=false;
|
||||
else if(method==='cast.recordingIndicator.visible.save'){if(failSave)return new Response(JSON.stringify({error:'Fixture save failed'}));casting.recordingIndicatorVisible=params.enabled;}
|
||||
else if(method==='cast.region.visible.save')casting.recordingRegionVisible=params.enabled;
|
||||
else if(method==='cast.settings.save'){if(params.width!==2560||params.codec!=='h265')throw Error('Receiver reset capture config');Object.assign(casting,params);}
|
||||
else if(method==='cast.framing.open')framing++;
|
||||
else if(method==='host.manager.open')opens++;
|
||||
return new Response(JSON.stringify({result}));
|
||||
};
|
||||
const button=(selector:string)=>document.querySelector<HTMLButtonElement>(selector)!;
|
||||
const click=async(selector:string)=>{await until(()=>!button(selector).disabled);button(selector).click();await wait(150);};
|
||||
const checkLayout=()=>{
|
||||
if(document.documentElement.scrollWidth>innerWidth)throw Error('Horizontal overflow');
|
||||
for(const e of document.querySelectorAll<HTMLElement>('.cast-quick button')){
|
||||
const r=e.getBoundingClientRect();if(r.width<44||r.height<44||r.left<0||r.right>innerWidth)throw Error('Invalid hit target: '+e.textContent);
|
||||
}
|
||||
for(const e of document.querySelectorAll<HTMLElement>('.cast-quick .ui-switch'))if(e.getBoundingClientRect().width!==60)throw Error('Switch width regressed');
|
||||
if(innerWidth>=500){const a=button('.cast-stream-actions>button'),b=button('.cast-record-button');if(a.getBoundingClientRect().top!==b.getBoundingClientRect().top)throw Error('Primary actions not aligned');}
|
||||
};
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
await until(()=>!!document.querySelector('nav button'));[...document.querySelectorAll<HTMLButtonElement>('nav button')].find(b=>b.textContent==='串流')!.click();
|
||||
await until(()=>document.querySelector('.cast-quick')&&!button('.cast-stream-actions>button').disabled);await wait(180);checkLayout();
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_CAST_IDLE');
|
||||
await click('.cast-record-button');await until(()=>recording&&!button('.cast-record-button').disabled);
|
||||
if(streaming||calls.includes('cast.start'))throw Error('Recording started streaming');
|
||||
if(!button('.cast-quick-framing-row>button').disabled)throw Error('Framing allowed during recording');
|
||||
failSave=true;await click('[aria-label="显示录制指示器"]');await until(()=>document.body.textContent?.includes('Fixture save failed'));
|
||||
if(button('[aria-label="显示录制指示器"]').getAttribute('aria-checked')!=='true')throw Error('Failed save changed switch');
|
||||
failSave=false;await click('[aria-label="显示录制指示器"]');await until(()=>!casting.recordingIndicatorVisible&&!button('.cast-record-button').disabled);
|
||||
if(!recording)throw Error('Hiding indicator stopped recording');
|
||||
await click('.cast-record-button');await until(()=>!recording&&!button('.cast-stream-actions>button').disabled);
|
||||
await click('.cast-quick-framing-row>button');await until(()=>framing===1&&!button('.cast-stream-actions>button').disabled);
|
||||
await click('[aria-label="录制区域显示"]');await until(()=>casting.recordingRegionVisible&&!button('.cast-stream-actions>button').disabled);
|
||||
await click('[aria-label="AirPlay"]');await until(()=>casting.airplay&&!button('.cast-stream-actions>button').disabled);
|
||||
await click('[aria-label="DLNA"]');await until(()=>casting.dlna&&!button('.cast-stream-actions>button').disabled);
|
||||
await click('.cast-stream-actions>button');await until(()=>streaming&&!button('.cast-record-button').disabled);
|
||||
await click('.cast-record-button');await until(()=>recording&&!button('.cast-record-button').disabled);await wait(200);checkLayout();
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_CAST_ACTIVE');await wait(100);
|
||||
await click('.cast-stream-actions>button');await until(()=>!streaming&&!button('.cast-record-button').disabled);
|
||||
await click('.cast-record-button');await until(()=>!recording&&!button('.cast-quick>header button').disabled);
|
||||
await click('.cast-quick>header button');await until(()=>opens===1);
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}
|
||||
};
|
||||
void import('../ui/src/main');
|
||||
@@ -0,0 +1,78 @@
|
||||
// The actual FileManager/CodeMirror workspace with generated demonstration code.
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {FileManager} from '../ui/src/file-manager';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import {Brand} from '../ui/src/icons';
|
||||
import '../ui/src/style.css';
|
||||
configureLanguage('zh-CN',[]);
|
||||
const home='/home/steamos',code=`// 串流配置示例,仅用于界面验证\r\ninterface CaptureSettings {\r\n width: number;\r\n height: number;\r\n fps: number;\r\n codec: 'h264' | 'h265';\r\n}\r\n\r\nconst settings: CaptureSettings = {\r\n width: 2560,\r\n height: 1920,\r\n fps: 90,\r\n codec: 'h265',\r\n};\r\n\r\nfunction resolution(config: CaptureSettings): string {\r\n const { width, height } = config;\r\n return width + ' × ' + height;\r\n}\r\n\r\nexport { settings, resolution };\r\n`;
|
||||
let text=code;
|
||||
window.fetch=async(url,init)=>{
|
||||
if(String(url)==='/api')return new Response(JSON.stringify({result:true}));
|
||||
const p=JSON.parse(String(init?.body));let result:any;
|
||||
if(p.operation==='preferences')result=[];
|
||||
else if(p.operation==='view.preferences')result={view:'list',folders:{}};
|
||||
else if(p.operation==='list')result={path:p.path??home,parent:'/',entries:p.hidden?[]:[{name:'capture.ts',path:home+'/capture.ts',directory:false,symlink:false,size:code.length,mode:420,uid:1000,gid:1000}],total:p.hidden?0:1,roots:[{name:'Home',path:home}]};
|
||||
else if(p.operation==='read')result={text,revision:'example',readOnly:false};
|
||||
else if(p.operation==='write'){text=p.text;result={text,revision:'saved',readOnly:false};}
|
||||
else throw Error('Unexpected operation '+p.operation);
|
||||
return new Response(JSON.stringify({result}));
|
||||
};
|
||||
createRoot(document.getElementById('root')!).render(<div className="app manager"><header className="shell-header"><Brand wordmark/><span>文件</span></header><div className="workspace tools-workspace"><main className="content file-content"><div className="file-workspace-frame"><FileManager/></div></main></div></div>);
|
||||
const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms));
|
||||
const until=async(f:()=>unknown)=>{for(let n=0;n<150;n++){if(f())return;await wait();}throw Error('Timeout '+document.body.innerText);};
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
await until(()=>document.querySelector('.file-list-scroll')?.getAttribute('aria-busy')==='false');document.querySelector<HTMLButtonElement>('.file-name')!.click();await until(()=>document.querySelector('.cm-content[contenteditable=true]'));await wait(250);
|
||||
const workspace=document.querySelector<HTMLElement>('.file-editor-workspace')!,area=workspace.getBoundingClientRect(),root=document.querySelector('.file-manager')!.getBoundingClientRect(),surface=document.querySelector('.file-code-surface')!.getBoundingClientRect();
|
||||
if(area.width<root.width-2||area.height<root.height-2||surface.height<300)throw Error('Editor does not fill workspace');
|
||||
const line=document.querySelector('.cm-line')!.getBoundingClientRect(),number=[...document.querySelectorAll<HTMLElement>('.cm-lineNumbers .cm-gutterElement')].find(e=>e.getBoundingClientRect().height>0&&getComputedStyle(e).visibility!=='hidden')!.getBoundingClientRect();
|
||||
if(Math.abs(line.top-number.top)>2)throw Error(`Gutter is not aligned: ${line.top} / ${number.top}`);
|
||||
if(document.querySelectorAll('.cm-line span').length<8||!document.body.textContent?.includes('CRLF'))throw Error('Syntax or line ending status missing');
|
||||
if(!document.querySelector('.file-layout')?.hasAttribute('inert'))throw Error('Editor left background controls interactive');
|
||||
if(document.querySelector('.cm-tooltip-autocomplete'))throw Error('Unexpected completion popup');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="语言模式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));const menu=document.querySelector('[role=listbox]')!.getBoundingClientRect();if(menu.bottom>innerHeight||menu.height<100)throw Error('Language dropdown clipped');
|
||||
[...document.querySelectorAll<HTMLButtonElement>('[role=option]')].find(e=>e.textContent==='JSON')!.click();await wait();document.querySelector<HTMLButtonElement>('[aria-label="语言模式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));[...document.querySelectorAll<HTMLButtonElement>('[role=option]')].find(e=>e.textContent==='TypeScript')!.click();await wait(200);
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="语言模式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));document.querySelector('[role=option]')!.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await until(()=>!document.querySelector('[role=listbox]'));if(!document.querySelector('.file-editor-workspace'))throw Error('Language menu Escape closed editor');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_CODE_EDITOR_ZH');await wait(200);
|
||||
[...document.querySelectorAll<HTMLButtonElement>('.file-code-toolbar button')].find(e=>e.textContent?.includes('查找替换'))!.click();await until(()=>document.querySelector('.file-code-search'));
|
||||
await wait();const searchSurface=document.querySelector('.file-code-surface')!.getBoundingClientRect(),scroller=document.querySelector('.cm-scroller')!.getBoundingClientRect();if(Math.abs(searchSurface.top-scroller.top)>2||Math.abs(searchSurface.height-scroller.height)>2)throw Error('Find widget consumes document space');
|
||||
const search=document.querySelector<HTMLElement>('.file-code-search')!,find=search.querySelector<HTMLInputElement>('input[name=Find]')!,replace=search.querySelector<HTMLInputElement>('input[name=Replace]')!;
|
||||
const set=(input:HTMLInputElement,value:string)=>{input.value=value;input.dispatchEvent(new Event('input',{bubbles:true}));};
|
||||
set(find,'width');await wait();if(!search.querySelector('output')?.textContent?.includes('/ 4'))throw Error('Wrong search match count');
|
||||
find.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));await wait();if(!search.querySelector('output')?.textContent?.startsWith('1 /'))throw Error('Find did not select first result');
|
||||
find.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',shiftKey:true,bubbles:true}));await wait();if(!search.querySelector('output')?.textContent?.startsWith('4 /'))throw Error('Previous did not wrap');
|
||||
search.querySelector<HTMLButtonElement>('[name="Toggle replace"]')!.click();if(replace.closest<HTMLElement>('.file-search-replacement')!.hidden)throw Error('Replace row did not expand');
|
||||
set(replace,'pixels');search.querySelector<HTMLButtonElement>('[name="replace all"]')!.click();await wait();if(!document.querySelector('.cm-content')?.textContent?.includes('pixels')||search.querySelector('output')?.textContent!=='– / 0')throw Error('Replace all failed');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="撤销"]')!.click();await wait();if(!search.querySelector('output')?.textContent?.includes('/ 4'))throw Error('Undo did not restore matches');
|
||||
search.querySelector<HTMLButtonElement>('[name=regexp]')!.click();set(find,'[');await wait();if(find.getAttribute('aria-invalid')!=='true'||!search.querySelector<HTMLButtonElement>('[name=next]')!.disabled||!search.textContent?.includes('正则表达式无效'))throw Error('Invalid regex not reported');
|
||||
set(find,'width');search.querySelector<HTMLButtonElement>('[name=regexp]')!.click();
|
||||
search.querySelector<HTMLButtonElement>('[name="match case"]')!.click();set(find,'WIDTH');await wait();if(!search.textContent?.includes('没有匹配结果'))throw Error('Case toggle ignored');
|
||||
search.querySelector<HTMLButtonElement>('[name="match case"]')!.click();search.querySelector<HTMLButtonElement>('[name="by word"]')!.click();set(find,'wid');await wait();if(!search.textContent?.includes('没有匹配结果'))throw Error('Whole word toggle ignored');
|
||||
set(find,'');find.focus();(window as any).__framelyCommitKeyboard('width');await wait();if(!search.querySelector('output')?.textContent?.includes('/ 4'))throw Error('Native keyboard did not update search');
|
||||
await wait(1200);console.log('FRAMELY_PREVIEW_INSTALL_CODE_EDITOR_SEARCH_ZH');await wait(200);
|
||||
if(search.getBoundingClientRect().right>innerWidth+1||find.getBoundingClientRect().width<100)throw Error('Find widget is clipped');
|
||||
const status=document.querySelector('.file-editor-status')!;if(status.scrollWidth>status.clientWidth||status.getBoundingClientRect().right>area.right+1)throw Error('Status bar is clipped');
|
||||
find.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await until(()=>!document.querySelector('.file-code-search'));if(!document.querySelector('.file-editor-workspace'))throw Error('Search Escape closed file');
|
||||
const content=document.querySelector<HTMLElement>('.cm-content')!;content.focus();content.dispatchEvent(new KeyboardEvent('keydown',{key:'Home',code:'Home',ctrlKey:true,bubbles:true}));await wait();(window as any).__framelyEditKeyboard('// ');await wait();
|
||||
if(!document.querySelector('.file-editor-status output')?.textContent?.includes('列 4'))throw Error('Cursor status not updated');
|
||||
const [save,close]=[...document.querySelectorAll<HTMLButtonElement>('.file-editor-heading button')];save.click();await until(()=>text.startsWith('// // ')&&save.disabled&&!close.disabled);
|
||||
if(text.replaceAll('\r\n','').includes('\n'))throw Error('Saving changed CRLF line endings');
|
||||
const host=document.createElement('div');host.style.cssText='position:fixed;left:-5000px;width:500px;height:300px';document.body.append(host);let modified=false;
|
||||
let saves=0;
|
||||
const writable=window.FramelyCodeEditor!.mount({parent:host,path:'save.txt',value:'hello hello',readOnly:false,label:'Save shortcut test',phrases:{},onChange:()=>{modified=true;},onSave:()=>{saves++;}});writable.find();
|
||||
host.querySelector<HTMLButtonElement>('[name="Toggle replace"]')!.click();
|
||||
const mac=/Mac|iPhone|iPad/.test(navigator.platform);
|
||||
for(const target of [host.querySelector<HTMLElement>('.cm-content')!,...host.querySelectorAll<HTMLInputElement>('.file-code-search input')]){
|
||||
target.focus();const before=saves,event=new KeyboardEvent('keydown',{key:'s',code:'KeyS',ctrlKey:!mac,metaKey:mac,bubbles:true,cancelable:true});target.dispatchEvent(event);
|
||||
if(saves!==before+1||!event.defaultPrevented)throw Error('Save shortcut failed in '+(target.getAttribute('name')??'document'));
|
||||
}
|
||||
if(modified)throw Error('Save shortcut changed document text');writable.destroy();
|
||||
const readonly=window.FramelyCodeEditor!.mount({parent:host,path:'readonly.txt',value:'hello hello',readOnly:true,label:'Readonly test',phrases:{},onChange:()=>{modified=true;},onSave:()=>{}});readonly.find();
|
||||
const panel=host.querySelector('.file-code-search')!,readonlyFind=panel.querySelector<HTMLInputElement>('[name=Find]')!;set(readonlyFind,'hello');panel.querySelector<HTMLButtonElement>('[name="Toggle replace"]')!.click();
|
||||
if(!panel.querySelector<HTMLInputElement>('[name=Replace]')!.disabled||!panel.querySelector<HTMLButtonElement>('[name="replace all"]')!.disabled)throw Error('Read-only replacement allowed');
|
||||
readonlyFind.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));if(modified)throw Error('Read-only search mutated text');readonly.destroy();host.remove();
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}
|
||||
};
|
||||
console.log('FRAMELY_VIEW_READY');
|
||||
@@ -0,0 +1,30 @@
|
||||
import React,{useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {FileCodeEditor} from '../ui/src/file-code-editor';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import {installKeyboard} from '../sdk/src/keyboard';
|
||||
import '../ui/src/style.css';
|
||||
import '../ui/src/file-manager.css';
|
||||
configureLanguage('en-US',[]);installKeyboard(async()=>true);
|
||||
let attempts=0;
|
||||
const append=document.head.append.bind(document.head);
|
||||
document.head.append=(...nodes:(Node|string)[])=>{const script=nodes.find(n=>n instanceof HTMLScriptElement) as HTMLScriptElement|undefined;if(script?.src.endsWith('/assets/code-editor.js')&&attempts++===0){setTimeout(()=>script.dispatchEvent(new Event('error')),20);return;}append(...nodes);};
|
||||
function Fixture(){const [value,setValue]=useState('original'),[readOnly,setReadOnly]=useState(false);return <div className="app"><div className="file-manager"><h1>Editor fallback test</h1><FileCodeEditor path="test.ts" value={value} onChange={setValue} readOnly={readOnly} onSave={()=>{}}/><output id="fallback-state">{value}</output><button onClick={()=>setReadOnly(true)}>Read-only</button></div></div>;}
|
||||
createRoot(document.getElementById('root')!).render(<Fixture/>);
|
||||
const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms));
|
||||
const until=async(f:()=>unknown)=>{for(let n=0;n<150;n++){if(f())return;await wait();}throw Error('Timeout '+document.body.innerText);};
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
await until(()=>document.querySelector('textarea'));
|
||||
const fallback=document.querySelector('textarea')!;fallback.focus();(window as any).__framelyCommitKeyboard('汉😃\nline');await until(()=>document.querySelector('#fallback-state')?.textContent==='汉😃\nline');
|
||||
fallback.setSelectionRange(fallback.value.length,fallback.value.length);
|
||||
(window as any).__framelyEditKeyboard('\b');await until(()=>document.querySelector('#fallback-state')?.textContent==='汉😃\nlin');
|
||||
[...document.querySelectorAll('button')].find(b=>b.textContent==='Retry')!.click();await until(()=>document.querySelector('.cm-content[contenteditable=true]'));
|
||||
if(attempts!==2||document.querySelector('textarea')||!document.querySelector('.cm-content')!.textContent?.includes('lin'))throw Error('Retry lost fallback content');
|
||||
const content=document.querySelector<HTMLElement>('.cm-content')!;content.focus();await wait();(window as any).__framelyEditKeyboard('中文');await wait();
|
||||
if(!document.querySelector('#fallback-state')?.textContent?.includes('中文'))throw Error('Native editor input failed after retry');
|
||||
[...document.querySelectorAll('button')].find(b=>b.textContent==='Read-only')!.click();await until(()=>content.contentEditable==='false');const previous=document.querySelector('#fallback-state')?.textContent;
|
||||
(window as any).__framelyEditKeyboard('not allowed');await wait();if(document.querySelector('#fallback-state')?.textContent!==previous)throw Error('Read-only editor accepted native input');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error+' '+(error as Error).stack);}
|
||||
};
|
||||
console.log('FRAMELY_VIEW_READY');
|
||||
@@ -9,6 +9,7 @@ const until=async(f:()=>unknown)=>{for(let n=0;n<150;n++){if(f())return;await wa
|
||||
const home='/home/steamos',downloads=home+'/Downloads';
|
||||
const longFolder='一个很长的文件夹名称 - Project documents and archived screenshots';
|
||||
let binaryMode=false;
|
||||
let savedText='fixture text',permissionMode=420,failPermissions=false;
|
||||
let preferences:any={view:'list',folders:{[home]:{sort:'size',descending:true},[downloads]:{sort:'modified',descending:false}}};
|
||||
const calls:any[]=[],downloadsClicked:string[]=[];const anchorClick=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){downloadsClicked.push(this.getAttribute('href')??'');};
|
||||
const files=(directory:string)=>[
|
||||
@@ -17,6 +18,7 @@ const files=(directory:string)=>[
|
||||
{name:'notes.txt',path:directory+'/notes.txt',directory:false,symlink:false,size:64,modified:1791350000,mode:420,uid:1000,gid:1000},
|
||||
{name:'movie.webm',path:directory+'/movie.webm',directory:false,symlink:true,size:2048,modified:1791340000,mode:420,uid:1000,gid:1000}];
|
||||
window.fetch=async(url,init)=>{
|
||||
if(String(url)==='/api'){const request=JSON.parse(String(init?.body));calls.push(request);return new Response(JSON.stringify({result:true}));}
|
||||
if(String(url)!=='/manager-api/files')throw Error('Unexpected request '+url);
|
||||
const p=JSON.parse(String(init?.body));calls.push(p);let result:any;
|
||||
if(p.operation==='preferences')result=[];
|
||||
@@ -35,7 +37,10 @@ window.fetch=async(url,init)=>{
|
||||
result={path:directory,parent:'/',entries,total:entries.length,roots:[{name:'Home',path:home},{name:'Downloads',path:downloads}]};
|
||||
}else if(p.operation==='preview'){const canvas=document.createElement('canvas');canvas.width=80;canvas.height=60;const ctx=canvas.getContext('2d')!;ctx.fillStyle='#8bb4ce';ctx.fillRect(0,0,80,60);result={url:p.path.endsWith('.png')?canvas.toDataURL(): 'data:video/webm;base64,AA==',token:p.path};}
|
||||
else if(p.operation==='preview.release')result=true;
|
||||
else if(p.operation==='read'){if(p.path.endsWith('notes.txt'))result={text:'fixture text',revision:'revision'};else return new Response(JSON.stringify({error:'Binary files cannot be edited as text'}));}
|
||||
else if(p.operation==='read'){if(p.path.endsWith('notes.txt'))result={text:savedText,revision:'revision',readOnly:false};else if(p.asText)result={text:'binary\u0000preview',revision:'binary',readOnly:true};else return new Response(JSON.stringify({error:'Binary files cannot be edited as text'}));}
|
||||
else if(p.operation==='write'){savedText=p.text;result={text:savedText,revision:'saved',readOnly:false};}
|
||||
else if(p.operation==='properties'){const e=files(home).find(e=>e.path===p.path)!;result={...e,regular:!e.directory&&!e.symlink,mode:permissionMode,owner:'steamos',group:'steamos',canChmod:!e.symlink,inode:'9007199254740993',device:'2049',links:1,allocated:8192,accessed:1791360000,changed:1791360000,linkTarget:e.symlink?'notes.txt':undefined,resolvedPath:e.symlink?home+'/notes.txt':e.path};}
|
||||
else if(p.operation==='chmod'){if(failPermissions)return new Response(JSON.stringify({error:'Fixture permission denied'}));if(p.identity.inode!=='9007199254740993')throw Error('Properties lost inode precision');permissionMode=p.mode;result=true;}
|
||||
else if(p.operation==='download')result={url:'/fixture-download'};
|
||||
else throw Error('Unexpected operation '+p.operation);
|
||||
return new Response(JSON.stringify({result}));
|
||||
@@ -49,6 +54,17 @@ const listing=()=>calls.filter(p=>p.operation==='list'&&!p.hidden).at(-1);
|
||||
const changeAddress=async(value:string)=>{const input=document.querySelector<HTMLInputElement>('.file-address input')!;Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(input,value);input.dispatchEvent(new Event('input',{bubbles:true}));await wait();input.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));};
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
configureLanguage('en-US',[]);render('one');await until(ready);await wait(200);
|
||||
if(innerWidth<1000){
|
||||
const item=(name:string)=>[...document.querySelectorAll<HTMLButtonElement>('.file-name')].find(e=>e.textContent?.trim()===name)!;
|
||||
const context=async(name:string)=>{await until(ready);item(name).dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:300,clientY:400}));await until(()=>document.querySelector('.file-context-menu'));};
|
||||
await context('notes.txt');button('Properties').click();await until(()=>document.querySelector('.file-properties-modal')?.getAttribute('aria-busy')==='false');
|
||||
if(document.querySelectorAll('.file-permission-grid button').length!==9)throw Error('Mobile permissions missing');await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PROPERTIES_EN');await wait(100);button('Close').click();await until(()=>!document.querySelector('.file-properties-modal'));
|
||||
await context('notes.txt');button('Edit as text').click();await until(()=>document.querySelector('.cm-content[contenteditable=true]'));const content=document.querySelector<HTMLElement>('.cm-content')!;content.focus();await wait();(window as any).__framelyEditKeyboard('中文');await wait();if(!content.textContent?.includes('中文'))throw Error('Mobile native editing failed');await wait(100);console.log('FRAMELY_PREVIEW_INSTALL_CODE_EDITOR_EN');await wait(100);button('Save').click();await until(()=>!button('Close').disabled&&button('Save').disabled&&savedText.includes('中文'));button('Close').click();await until(()=>!document.querySelector('.file-editor-modal'));
|
||||
await context('A very long photograph name with spaces.png');await until(()=>!button('Edit as text').disabled);button('Edit as text').click();await until(()=>document.querySelector('.cm-content[contenteditable=false]'));if(!button('Save').disabled)throw Error('Mobile binary preview writable');button('Close').click();await until(()=>!document.querySelector('.file-editor-modal'));
|
||||
configureLanguage('zh-CN',[]);render('mobile-zh');await wait();await until(ready);await context('notes.txt');button('属性').click();await until(()=>document.querySelector('.file-properties-modal')?.getAttribute('aria-busy')==='false');await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PROPERTIES_ZH');await wait(100);
|
||||
if(document.documentElement.scrollWidth>innerWidth)throw Error('Mobile page overflow');
|
||||
HTMLAnchorElement.prototype.click=anchorClick;console.log('FRAMELY_BRIDGE_PASS');return;
|
||||
}
|
||||
if(calls.some(p=>p.operation==='preview'))throw Error('List view fetched thumbnails');
|
||||
if(listing().sort!=='size'||listing().descending!==true)throw Error('Home sort not restored on initial canonical path');
|
||||
await until(()=>[...document.querySelectorAll('.file-tree-entry button>span')].some(node=>node.textContent===longFolder));
|
||||
@@ -72,7 +88,34 @@ const changeAddress=async(value:string)=>{const input=document.querySelector<HTM
|
||||
if(listing().sort!=='size')throw Error('Preferences failed across remount');
|
||||
await changeAddress('/alias');await until(()=>ready()&&document.querySelector<HTMLInputElement>('.file-address input')!.value===home);
|
||||
if(listing().sort!=='size')throw Error('Canonical folder preference not restored');
|
||||
const tile=document.querySelector<HTMLButtonElement>('.file-tile-open[title="notes.txt"]')!;tile.click();await until(()=>document.querySelector('.file-editor'));button('Close').click();
|
||||
const tile=document.querySelector<HTMLButtonElement>('.file-tile-open[title="notes.txt"]')!;
|
||||
tile.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:500,clientY:400}));await until(()=>document.querySelector('.file-context-menu'));button('Properties').click();await until(()=>document.querySelector('.file-properties-modal')?.getAttribute('aria-busy')==='false');
|
||||
await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PROPERTIES_EN');await wait(100);
|
||||
if(document.querySelectorAll('.file-permission-grid [role=checkbox]').length!==9||!document.querySelector('.file-property-grid')?.textContent?.includes('9007199254740993'))throw Error('Detailed properties missing');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="Owner: Execute / enter"]')!.click();await wait();
|
||||
if(document.querySelector<HTMLInputElement>('.file-permissions input')!.value!=='744')throw Error('Permission grid and octal mode disagree');
|
||||
const modeInput=document.querySelector<HTMLInputElement>('.file-permissions input')!;
|
||||
const setMode=async(value:string)=>{Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(modeInput,value);modeInput.dispatchEvent(new Event('input',{bubbles:true}));await wait();};
|
||||
await setMode('888');if(!button('Save permissions').disabled)throw Error('Invalid octal mode accepted');
|
||||
await setMode('640');failPermissions=true;button('Save permissions').click();await until(()=>document.body.textContent?.includes('Fixture permission denied'));if(!document.querySelector('.file-properties-modal'))throw Error('Permission failure closed panel');
|
||||
failPermissions=false;await wait();button('Save permissions').click();await until(()=>!document.querySelector('.file-properties-modal'));if(permissionMode!==416)throw Error('Permission save failed');
|
||||
tile.click();await until(()=>document.querySelector('.cm-content[contenteditable=true]'));await wait(100);
|
||||
if(!document.querySelector('.cm-lineNumbers')||document.querySelector('.cm-tooltip-autocomplete'))throw Error('Editor features incorrect');
|
||||
await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_CODE_EDITOR_EN');await wait(100);
|
||||
const content=document.querySelector<HTMLElement>('.cm-content')!;content.focus();await wait();(window as any).__framelyEditKeyboard('中文');await wait();if(!content.textContent?.includes('中文'))throw Error('Native keyboard input missing');
|
||||
(window as any).__framelyEditKeyboard('\n');(window as any).__framelyEditKeyboard('\b');await wait();
|
||||
document.querySelector<HTMLButtonElement>('.file-code-toolbar [aria-label="Undo"]')!.click();await wait();document.querySelector<HTMLButtonElement>('.file-code-toolbar [aria-label="Redo"]')!.click();await wait();
|
||||
button('Close').click();await until(()=>document.querySelector('.modal[aria-label="Unsaved changes"]'));button('Cancel').click();await until(()=>!document.querySelector('.modal[aria-label="Unsaved changes"]'));if(document.querySelector('.cm-content')!==content)throw Error('Canceling close discarded editor history');
|
||||
const probe=document.createElement('div');probe.style.cssText='position:fixed;left:-10000px;width:400px;height:200px';document.body.append(probe);
|
||||
const syntax=window.FramelyCodeEditor!.mount({parent:probe,value:'const enabled: boolean = true;\nfunction demo() {\n return enabled;\n}\n',path:'settings.ts',readOnly:false,label:'Syntax test',phrases:{},onChange:()=>{},onSave:()=>{}});await wait(150);
|
||||
if(probe.querySelectorAll('.cm-line span').length<3||!probe.querySelector('.cm-foldGutter'))throw Error('Code highlighting or folding missing');if(document.querySelector('.cm-tooltip-autocomplete'))throw Error('Completion popup enabled');syntax.destroy();probe.remove();
|
||||
button('Find and replace').click();await until(()=>document.querySelector('.cm-search'));document.querySelector<HTMLInputElement>('.cm-search input')!.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await wait();if(!document.querySelector('.file-editor-modal'))throw Error('Search Escape closed file');
|
||||
button('Save').click();await until(()=>savedText.includes('中文'));await until(()=>!button('Close').disabled);button('Close').click();await until(()=>!document.querySelector('.file-editor-modal'));
|
||||
// Text entry is available even for images, without invoking media preview.
|
||||
await until(ready);document.querySelector<HTMLButtonElement>('.file-tile-open[title="A very long photograph name with spaces.png"]')!.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:500,clientY:400}));await until(()=>document.querySelector('.file-context-menu')&&!button('Edit as text').disabled);button('Edit as text').click();await until(()=>document.querySelector('.cm-content[contenteditable=false]'));
|
||||
if(!button('Save').disabled||!document.body.textContent?.includes('read-only to protect'))throw Error('Binary preview is writable');button('Close').click();await until(()=>!document.querySelector('.file-editor-modal'));
|
||||
document.querySelector<HTMLButtonElement>('.file-tile-open[title="movie.webm"]')!.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:500,clientY:400}));await until(()=>document.querySelector('.file-context-menu'));button('Properties').click();await until(()=>document.querySelector('.file-properties-modal')?.getAttribute('aria-busy')==='false');
|
||||
if(!button('Save permissions').disabled||document.querySelector('.file-permission-grid button:not(:disabled)'))throw Error('Symlink permissions editable');button('Close').click();await until(()=>!document.querySelector('.file-properties-modal'));
|
||||
document.querySelector<HTMLButtonElement>('.file-tile-open[title="A very long photograph name with spaces.png"]')!.click();await until(()=>document.querySelector('.file-media-overlay img'));await until(()=>document.querySelector('.file-media-overlay img')?.getAttribute('src'));document.querySelector<HTMLButtonElement>('.file-media-overlay [aria-label="Close"]')!.click();await until(()=>!document.querySelector('.file-media-overlay'));
|
||||
await wait(400);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MANAGER_ICONS_EN');await wait(300);
|
||||
configureLanguage('zh-CN',[]);render('three');await wait();await until(()=>ready()&&document.querySelector('.icons-medium'));await select('展示方式','大图标');await until(()=>document.querySelector('.icons-large'));
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {Store,catalogChoices,githubRepository,Entry,Source,CatalogResult} from '../ui/src/store';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
configureLanguage('zh-CN',[]);
|
||||
const source:Source={id:'community',name:'社区插件源',url:'https://example.org/catalog.json',enabled:true,allowHttp:false};
|
||||
const github:Source={id:'github-fixture',name:'example/plugin',url:'https://github.com/example/plugin',enabled:true,allowHttp:false};
|
||||
const plugin=(version:string,sha='a',compatible=true):Entry=>({id:'example.test',name:'示例插件',author:'Example',description:'从 GitHub Release 自动发现,与现有插件库合并显示。',version,apiVersion:1,compatible,url:`https://github.com/example/plugin/releases/download/v${version}/plugin.framely`,sha256:sha.repeat(64),runAs:'steamos'});
|
||||
const versions=[plugin('2.0.0','d',false),plugin('1.1.0-preview.10','b'),plugin('1.1.0-preview.10','c'),plugin('1.0.1','e')];
|
||||
const data:CatalogResult[]=[{source,catalog:{name:source.name,plugins:[plugin('1.0.0')]}},{source:github,catalog:{name:github.name,plugins:[versions[1]]},versions}];
|
||||
const requests:any[]=[],inspected:any[]=[];
|
||||
window.fetch=async(_url,init)=>{
|
||||
const {method,params}=JSON.parse(String(init?.body));requests.push({method,params});let result:any=true;
|
||||
if(method==='catalog.start')result={job:'library'};
|
||||
if(method==='catalog.versions.start')result={job:`history-${params.source}`};
|
||||
if(method==='job.status')result=params.job==='library'?{id:params.job,phase:'done',result:data}:params.job==='history-community'?{id:params.job,phase:'failed',error:'历史版本请求失败'}:{id:params.job,phase:'done',result:versions};
|
||||
return new Response(JSON.stringify({result}));
|
||||
};
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
root.render(<div className="app manager"><header className="shell-header"><strong>framely</strong></header><div className="workspace"><nav><button>已安装</button><button className="active">插件库</button><button>插件源</button></nav><main className="content"><Store sources={[source]} plugins={{'example.test':{source:source.id,manifest:{version:'1.0.0'}}}} busy={false} onSources={()=>{}} onInspect={async request=>{inspected.push(request);}}/></main></div></div>);
|
||||
const wait=(ms=50)=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
async function until(test:()=>any){for(let i=0;i<100;i++){if(test())return;await wait();}throw Error('Timed out: '+document.body.innerText.slice(0,250));}
|
||||
const click=(selector:string)=>{const button=document.querySelector<HTMLButtonElement>(selector);if(!button)throw Error('Missing '+selector);button.click();};
|
||||
async function select(label:string,option:string){click(`[aria-label="${label}"]`);await until(()=>!!document.querySelector('[role=option]'));const button=[...document.querySelectorAll<HTMLButtonElement>('[role=option]')].find(b=>b.textContent?.includes(option));if(!button)throw Error('Missing option '+option);button.click();await wait();}
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
if(catalogChoices(data).length!==5)throw Error('Different hashes were deduplicated');
|
||||
if(catalogChoices([...data,{source:{...github,id:'duplicate'},catalog:{name:'Duplicate',plugins:[versions[1]]}}]).length!==5)throw Error('Identical package was duplicated');
|
||||
if(catalogChoices(data,'all','stable')[1].entry.version!=='1.0.1')throw Error('Stable versions were lost');
|
||||
if(githubRepository({...github,url:'https://github.com.evil/example/plugin'}))throw Error('Similar host accepted');
|
||||
await until(()=>document.querySelectorAll('.store-card').length===1);
|
||||
if(document.querySelector('.store-version')?.textContent!=='1.1.0-preview.10')throw Error('Highest compatible version not recommended');
|
||||
await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_GITHUB_STORE');
|
||||
await select('版本通道','仅正式版');
|
||||
if(document.querySelector('.store-version')?.textContent!=='1.0.1')throw Error('Stable filter incorrect');
|
||||
await select('版本通道','所有版本');click('.store-title');
|
||||
await until(()=>document.body.textContent?.includes('历史版本暂时不可用'));
|
||||
click('[aria-label="可选安装版本"]');await wait();
|
||||
const options=[...document.querySelectorAll<HTMLElement>('[role=option]')];
|
||||
const duplicates=options.filter(o=>o.textContent?.includes('1.1.0-preview.10'));
|
||||
if(duplicates.length!==2||duplicates[0].textContent===duplicates[1].textContent)throw Error('Same-version hash choices are ambiguous');
|
||||
const menu=document.querySelector<HTMLElement>('[role=listbox]')!;menu.scrollIntoView({block:'nearest'});await wait();
|
||||
const content=document.querySelector<HTMLElement>('.content')!;
|
||||
if(content.scrollWidth>content.clientWidth+1||menu.scrollWidth>menu.clientWidth+1)throw Error('Version menu overflows horizontally');
|
||||
await wait(350);console.log('FRAMELY_PREVIEW_INSTALL_GITHUB_STORE_VERSIONS');
|
||||
options.find(o=>o.textContent?.startsWith('1.0.1'))!.click();await wait();click('.detail-heading button');
|
||||
if(inspected[0]?.githubRepository!=='example/plugin'||inspected[0]?.sha256!=='e'.repeat(64))throw Error('Install origin/hash not preserved');
|
||||
click('.back-link');await wait();
|
||||
const refresh=[...document.querySelectorAll<HTMLButtonElement>('.list-heading button')].find(b=>b.textContent==='刷新')!;refresh.click();
|
||||
await until(()=>requests.some(r=>r.method==='catalog.start'&&r.params.refresh===true));
|
||||
if(requests.filter(r=>r.method==='catalog.versions.start').some(r=>r.params.source===github.id&&r.params.githubRepository!=='example/plugin'))throw Error('History origin missing');
|
||||
if(document.documentElement.scrollWidth>innerWidth+1)throw Error('Page overflows viewport');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}};
|
||||
until(()=>!!document.querySelector('.store-card')).then(()=>console.log('FRAMELY_VIEW_READY'));
|
||||
@@ -4,6 +4,9 @@
|
||||
#include <cassert>
|
||||
int main(){
|
||||
RecordingIndicatorClick click;
|
||||
RecordingIndicator hidden;hidden.cancel_input();hidden.destroy();
|
||||
assert(!hidden.active());assert(!hidden.hovered(0));
|
||||
vr::HmdMatrix34_t pose{};assert(hidden.pointer(0,pose,true,false).empty());
|
||||
assert(!click.update(true,true));assert(!click.update(false,true));
|
||||
assert(!click.update(true,false));assert(!click.update(false,true));
|
||||
assert(!click.update(true,true));assert(click.update(false,true));assert(!click.update(false,true));
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React,{useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {CastQuick} from '../ui/src/cast-quick';
|
||||
import {Casting,type CastSettings} from '../ui/src/casting';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import '../ui/src/style.css';
|
||||
|
||||
let config:CastSettings={source:'screen',codec:'h264',output:'eye',eye:'right',width:1920,height:1440,fps:30,bitrateMbps:8,horizontalFov:null,centerX:0,centerY:0,systemAudio:true,microphone:false,airplay:false,dlna:false,receiverName:'Framely'};
|
||||
let recording={active:true,id:'same-recording',elapsedSeconds:75},fail=true;
|
||||
const calls:string[]=[];
|
||||
window.fetch=async(_url,init)=>{
|
||||
const {method,params}=JSON.parse(String(init?.body));calls.push(method);
|
||||
if(method==='cast.recordingIndicator.visible.save'){
|
||||
if(fail)return new Response(JSON.stringify({error:'Simulated save failure'}));
|
||||
config={...config,recordingIndicatorVisible:params.enabled};
|
||||
}
|
||||
if(method==='cast.record.stop')recording={...recording,active:false};
|
||||
return new Response(JSON.stringify({result:method==='cast.status'?{running:false,recording,dominantEye:1,watchPath:'/cast/watch'}:true}));
|
||||
};
|
||||
const root=createRoot(document.getElementById('root')!);
|
||||
function Preview({manager}:{manager:boolean}){const [value,setValue]=useState(config);const refresh=async()=>{setValue({...config});};return <div className="app"><main className="content">{manager?<Casting config={value} refresh={refresh} network={{enabled:true,port:15915,passwordEnabled:false}}/>:<CastQuick config={value} refresh={refresh}/>}</main></div>;}
|
||||
const wait=()=>new Promise(r=>setTimeout(r,40));
|
||||
const until=async(check:()=>unknown)=>{for(let i=0;i<100;i++){if(check())return;await wait();}throw Error('UI timeout '+JSON.stringify({config,recording,calls,checked:toggle()?.getAttribute('aria-checked')})+' '+document.body.innerText);};
|
||||
const toggle=()=>document.querySelector<HTMLButtonElement>('[role=switch][aria-label="显示录制指示器"]')!;
|
||||
const stop=()=>Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(b=>b.textContent?.includes('停止录制'))!;
|
||||
(async()=>{try{
|
||||
configureLanguage('zh-CN',[]);root.render(<Preview manager={false}/>);
|
||||
await until(()=>toggle()&&!toggle().disabled&&stop());
|
||||
if(toggle().getAttribute('aria-checked')!=='true')throw Error('Legacy default is not visible');
|
||||
toggle().click();await until(()=>document.body.textContent?.includes('Simulated save failure')&&!toggle().disabled);
|
||||
if(toggle().getAttribute('aria-checked')!=='true')throw Error('Failed save changed switch');
|
||||
fail=false;toggle().click();await until(()=>toggle().getAttribute('aria-checked')==='false'&&!toggle().disabled);
|
||||
if(recording.id!=='same-recording'||recording.elapsedSeconds!==75||!recording.active||stop().disabled)throw Error('Visibility changed the recording');
|
||||
stop().click();await until(()=>!recording.active&&!toggle().disabled&&!stop());
|
||||
recording={...recording,active:true};root.render(<Preview key="manager" manager/>);
|
||||
await until(()=>document.querySelector('.cast-page-heading')&&toggle()&&toggle().getAttribute('aria-checked')==='false'&&!toggle().disabled&&stop());
|
||||
toggle().click();await until(()=>toggle().getAttribute('aria-checked')==='true'&&!toggle().disabled);
|
||||
if(calls.some(m=>['cast.settings.save','cast.start','cast.stop','cast.record.start'].includes(m)))throw Error('Visibility restarted capture');
|
||||
stop().click();await until(()=>!recording.active);
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}})();
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import {webcrypto} from 'node:crypto';
|
||||
const read=name=>fs.readFileSync(new URL('../src/apk/'+name,import.meta.url),'utf8');
|
||||
const palette=vm.runInNewContext('('+read('steam_artwork_palette.js')+')');
|
||||
const rgba=color=>Uint8Array.from(Array.from({length:64*64},()=>color).flat());
|
||||
const pink=palette(rgba([255,105,153,255]),64,64);
|
||||
assert.equal(pink.selected,'#ff6999');assert.equal(pink.accent,'#cc547a');
|
||||
assert.equal(pink.neutral,false);
|
||||
assert.equal(JSON.stringify(pink),JSON.stringify(palette(rgba([255,105,153,255]),64,64)));
|
||||
assert.equal(palette(rgba([250,250,250,255]),64,64).neutral,true);
|
||||
assert.equal(palette(rgba([255,0,0,0]),64,64).neutral,true);
|
||||
const blue=rgba([26,114,230,255]);blue.set([255,0,0,255],0);
|
||||
assert.equal(palette(blue,64,64).selected,'#1a72e6');
|
||||
assert.throws(()=>palette(new Uint8Array(2),64,64));
|
||||
|
||||
// Real Steam contract: unsupported type 4 aliases the landscape destination.
|
||||
const suffix={0:'p',1:'_hero',2:'_logo',3:'',4:''},id=0x92345678;
|
||||
const encode=color=>Buffer.from(JSON.stringify({width:1,height:1,color})).toString('base64');
|
||||
class Image {
|
||||
set src(uri){const v=JSON.parse(Buffer.from(uri.split(',')[1],'base64').toString());Object.assign(this,v);queueMicrotask(()=>this.onload());}
|
||||
}
|
||||
class Canvas {
|
||||
constructor(color){this.width=1;this.height=1;this.color=color;}
|
||||
getContext(){return {drawImage:image=>{this.color=image.color;},getImageData:()=>({data:Uint8ClampedArray.from(this.color)})};}
|
||||
}
|
||||
class FileReader {
|
||||
readAsDataURL(blob){blob.arrayBuffer().then(b=>{this.result='data:image/png;base64,'+Buffer.from(b).toString('base64');this.onload();});}
|
||||
}
|
||||
const files=new Map(),writes=[];let race=null,failOnce=false,failKind=null;
|
||||
const fetch=async url=>{const key=url.split('?')[0];if(race){const run=race;race=null;run(key);}const data=files.get(key);return data?new Response(Buffer.from(data,'base64')):new Response(null,{status:404});};
|
||||
const SteamClient={Apps:{SetCustomArtworkForApp:async(app,data,ext,kind)=>{
|
||||
assert.equal(app,id);assert.equal(ext,'png');
|
||||
assert.ok([0,1,2,3].includes(kind),'Never send the shortcut icon as custom artwork');
|
||||
if(kind===failKind)throw Error('persistent simulated Steam failure');
|
||||
if(failOnce){failOnce=false;throw Error('simulated Steam failure');}
|
||||
files.set(`/customimages/${id}${suffix[kind]}.png`,data);writes.push(kind);
|
||||
}}};
|
||||
const apply=vm.runInNewContext('('+read('steam_artwork_apply.js')+')',{Image,FileReader,document:{createElement:()=>new Canvas()},TextEncoder,Uint8Array,crypto:webcrypto,fetch,SteamClient,setTimeout,clearTimeout,queueMicrotask});
|
||||
let color=[100,50,10,255];
|
||||
const render=async()=>[0,1,2,3].map(kind=>({kind,data:encode(color),canvas:new Canvas(color)}));
|
||||
const params={image:'unused',ownership:null,legacy:[]};
|
||||
let owned=await apply(params,id,render,palette);
|
||||
assert.deepEqual(writes,[0,1,2,3]);assert.equal(Object.keys(owned.hashes).length,4);
|
||||
params.ownership=owned;writes.length=0;
|
||||
await apply(params,id,render,palette);assert.equal(writes.length,0,'Identical artwork should not be rewritten');
|
||||
files.set(`/customimages/${id}.png`,encode([250,0,0,255]));
|
||||
color=[20,60,90,255];writes.length=0;
|
||||
owned=await apply(params,id,render,palette);
|
||||
assert.ok(!writes.includes(3),'User landscape must survive');assert.equal(owned.hashes[3],undefined);assert.equal(writes.length,3);
|
||||
params.ownership=owned;files.set(`/customimages/${id}p.jpg`,encode([0,250,0,255]));
|
||||
color=[30,70,90,255];writes.length=0;
|
||||
owned=await apply(params,id,render,palette);assert.ok(!writes.includes(0),'Alternate custom extension must survive');
|
||||
params.ownership={...owned,appId:id+1};writes.length=0;
|
||||
await apply(params,id,render,palette);assert.equal(writes.length,0,'Ownership from another shortcut must not be trusted');
|
||||
files.clear();params.ownership=null;params.legacy=[{kind:0,data:encode([1,2,3,255])}];
|
||||
files.set(`/customimages/${id}p.png`,params.legacy[0].data);writes.length=0;failOnce=true;
|
||||
owned=await apply(params,id,render,palette);assert.equal(writes.length,4,'Exact legacy artwork should migrate, with bounded retry');
|
||||
params.ownership=owned;params.legacy=[];writes.length=0;
|
||||
// Change a file between inspection and the last pre-write check.
|
||||
let inspections=0;
|
||||
const racingFetch=async url=>{if(url.startsWith(`/customimages/${id}p.png`)&&++inspections===2)files.set(`/customimages/${id}p.png`,encode([210,20,20,255]));return fetch(url);};
|
||||
const racingApply=vm.runInNewContext('('+read('steam_artwork_apply.js')+')',{Image,FileReader,document:{createElement:()=>new Canvas()},TextEncoder,Uint8Array,crypto:webcrypto,fetch:racingFetch,SteamClient,setTimeout,clearTimeout,queueMicrotask});
|
||||
color=[90,80,70,255];await racingApply(params,id,render,palette);assert.ok(!writes.includes(0),'Concurrent user change must survive');
|
||||
files.clear();writes.length=0;params.ownership=null;failKind=1;
|
||||
const checkpoint=await apply(params,id,render,palette);
|
||||
assert.equal(checkpoint.failed,true);assert.ok(checkpoint.hashes[0]);assert.deepEqual(writes,[0]);
|
||||
params.ownership=checkpoint;failKind=null;writes.length=0;
|
||||
const recovered=await apply(params,id,render,palette);
|
||||
assert.equal(recovered.failed,undefined);assert.deepEqual(writes,[1,2,3]);
|
||||
params.ownership=recovered;params.ownership.hashes[4]=recovered.hashes[3];params.ownership.hashes[3]='f'.repeat(64);
|
||||
color=[70,60,50,255];writes.length=0;
|
||||
const repaired=await apply(params,id,render,palette);
|
||||
assert.ok(writes.includes(3),'Exact type-4 icon overwrite must be repaired');assert.equal(repaired.hashes[4],undefined);
|
||||
const unsupported=await apply(params,id,async()=>[{kind:4,data:encode(color),canvas:new Canvas(color)}],palette);
|
||||
assert.equal(unsupported.failed,true,'Unsupported type must be rejected before writing');
|
||||
console.log('Steam artwork: generic palette, 80% intensity, deterministic output, ownership, custom images, legacy upgrade, retry, race and partial-failure recovery checks passed');
|
||||
@@ -0,0 +1,44 @@
|
||||
window.runInstallReviewChecks=async()=>{
|
||||
try{
|
||||
await document.fonts.ready;
|
||||
const check=(ok,message)=>{if(!ok)throw Error(message);};
|
||||
const source=document.createElement('canvas');source.width=256;source.height=256;
|
||||
const c=source.getContext('2d');c.fillStyle='#ff6999';c.fillRect(0,0,256,256);
|
||||
c.fillStyle='white';c.fillRect(60,60,136,136);
|
||||
const p={image:source.toDataURL(),name:'哔哩哔哩HD',package:'test.example'};
|
||||
const sample=document.createElement('canvas');sample.width=sample.height=64;sample.getContext('2d').drawImage(source,0,0,64,64);
|
||||
const palette=selectPalette(sample.getContext('2d').getImageData(0,0,64,64).data,64,64);
|
||||
check(palette.selected==='#ff6999'&&palette.accent==='#cc547a','Wrong 80% palette');
|
||||
const a=await renderArtwork(p,selectPalette);
|
||||
const expected={0:[600,900],1:[3840,1240],2:[1280,360],3:[920,430]};
|
||||
check(a.length===4&&!a.some(v=>v.kind===4),'Only four supported custom-artwork types may be rendered');
|
||||
for(const v of a){check(v.canvas.width===expected[v.kind][0]&&v.canvas.height===expected[v.kind][1],'Wrong artwork dimensions');check(v.data.length>100,'Missing PNG');}
|
||||
const logo=a.find(v=>v.kind===2).canvas;
|
||||
check(logo.getContext('2d').getImageData(0,0,1,1).data[3]===0,'Logo lost alpha');
|
||||
const decoded=await new Promise((resolve,reject)=>{const image=new Image();image.onload=()=>resolve(image);image.onerror=reject;image.src='data:image/png;base64,'+a.find(v=>v.kind===2).data;});
|
||||
const roundtrip=document.createElement('canvas');roundtrip.width=logo.width;roundtrip.height=logo.height;roundtrip.getContext('2d').drawImage(decoded,0,0);
|
||||
const before=logo.getContext('2d').getImageData(0,0,logo.width,logo.height).data,after=roundtrip.getContext('2d').getImageData(0,0,logo.width,logo.height).data;
|
||||
check(before.every((v,i)=>v===after[i]),'PNG roundtrip changes ownership pixel hashes');
|
||||
const portrait=a.find(v=>v.kind===0).canvas,ctx=portrait.getContext('2d');
|
||||
const pixels=ctx.getImageData(0,475,600,100).data;let min=600,max=0,count=0;
|
||||
for(let i=0;i<pixels.length;i+=4)if(pixels[i]>245&&pixels[i+1]>245&&pixels[i+2]>245){const x=(i/4)%600;min=Math.min(min,x);max=Math.max(max,x);count++;}
|
||||
check(count>500,'Chinese title missing');check(Math.abs((min+max)/2-300)<8,'Portrait title is not centered');
|
||||
let accentPixels=0;const all=ctx.getImageData(0,0,600,900).data;
|
||||
for(let i=0;i<all.length;i+=4)if(all[i]===204&&all[i+1]===84&&all[i+2]===122)accentPixels++;
|
||||
check(accentPixels>1000,'Main ornaments do not preserve 80% source color');
|
||||
const b=await renderArtwork(p,selectPalette);check(a.every((v,i)=>v.data===b[i].data),'Rendering is not deterministic');
|
||||
const long=await renderArtwork({...p,name:'极长的应用名称 & <script>window.bad=true</script> '.repeat(3)},selectPalette);
|
||||
check(long.length===4&&!window.bad,'Unsafe title rendering');
|
||||
const hero=a.find(v=>v.kind===1).canvas;
|
||||
const cropped=document.createElement('canvas');cropped.width=1280;cropped.height=307;
|
||||
const sourceHeight=307/(1280/3840);
|
||||
cropped.getContext('2d').drawImage(hero,0,(1240-sourceHeight)/2,3840,sourceHeight,0,0,1280,307);
|
||||
const visible=cropped.getContext('2d').getImageData(0,0,1280,307).data;let visibleAccent=0;
|
||||
for(let i=0;i<visible.length;i+=4)if(visible[i]===204&&visible[i+1]===84&&visible[i+2]===122)visibleAccent++;
|
||||
check(visibleAccent>6000,'Main hero motif is lost under Frame cover cropping');
|
||||
document.getElementById('root').textContent='Steam artwork renderer passed: 4 artwork types, CJK title, alpha, centering, palette, deterministic output and real Frame hero crop';
|
||||
// Let the existing probe observe a completed frame before announcing success.
|
||||
await new Promise(r=>setTimeout(r,250));console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(e){console.error('FRAMELY_BRIDGE_FAIL '+e);}
|
||||
};
|
||||
console.log('FRAMELY_VIEW_READY');
|
||||
@@ -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)
|
||||
|
||||
+28
-12
@@ -106,18 +106,34 @@ class CEFRuntimeTests(unittest.TestCase):
|
||||
requirement = json.loads((core / 'CEF_RUNTIME.json').read_text())
|
||||
data = (folder / requirement['archive']).read_bytes()
|
||||
# The downloaded helper imports the shipped bootstrap module.
|
||||
for bad in (True, False):
|
||||
store = folder / ('bad-store' if bad else 'download-store')
|
||||
store.mkdir()
|
||||
response = io.BytesIO(data[:-1] if bad else data)
|
||||
with patch('urllib.request.OpenerDirector.open', return_value=response):
|
||||
if bad:
|
||||
with self.assertRaises(ValueError):
|
||||
runtime.prepare(core, store)
|
||||
self.assertFalse((store / 'cef' / requirement['id']).exists())
|
||||
else:
|
||||
cached = runtime.prepare(core, store)
|
||||
self.assertEqual((cached / 'lib/cef/libcef.so').read_text(), 'browser')
|
||||
tampered = data[:-1] + bytes([data[-1] ^ 1])
|
||||
for case, payload, failure, message, attempts in [
|
||||
('tampered', tampered, ValueError, 'SHA256 mismatch', 1),
|
||||
('truncated', data[:-1], RuntimeError, 'IncompleteRead', 2),
|
||||
('valid', data, None, '', 1)]:
|
||||
with self.subTest(case=case):
|
||||
store = folder / (case + '-store')
|
||||
store.mkdir()
|
||||
# Each request owns a fresh HTTP-like response, including
|
||||
# the fallback request after a truncated primary body.
|
||||
def response(*_args, **_kwargs):
|
||||
result = io.BytesIO(payload)
|
||||
result.headers = {'Content-Length': str(len(payload))}
|
||||
return result
|
||||
with patch.dict('os.environ', {'FRAMELY_GITHUB_AUTO_FALLBACK': '1'}), \
|
||||
patch('urllib.request.OpenerDirector.open', side_effect=response) as request:
|
||||
if failure:
|
||||
with self.assertRaisesRegex(failure, message):
|
||||
runtime.prepare(core, store)
|
||||
self.assertFalse((store / 'cef' / requirement['id']).exists())
|
||||
else:
|
||||
cached = runtime.prepare(core, store)
|
||||
self.assertEqual((cached / 'lib/cef/libcef.so').read_text(), 'browser')
|
||||
self.assertEqual(request.call_count, attempts)
|
||||
self.assertEqual(request.call_args_list[0].args[0].full_url, requirement['url'])
|
||||
if attempts == 2:
|
||||
self.assertEqual(request.call_args_list[1].args[0].full_url,
|
||||
'https://gh-proxy.com/' + requirement['url'])
|
||||
|
||||
def test_runtime_extraction_rejects_links(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Focused network fallback tests: no public network or device access."""
|
||||
import http.server
|
||||
import errno
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
import pathlib
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('bootstrap', ROOT / 'tools/bootstrap.py')
|
||||
network = importlib.util.module_from_spec(spec)
|
||||
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):
|
||||
for wrapped in (False, True):
|
||||
with self.subTest(code=code, wrapped=wrapped):
|
||||
calls = []
|
||||
def attempt(fallback):
|
||||
calls.append(fallback)
|
||||
error = OSError(code, 'simulated network failure')
|
||||
raise urllib.error.URLError(error) if wrapped else error
|
||||
with self.assertRaisesRegex(RuntimeError, 'primary route failed.*fallback failed'):
|
||||
network.github_attempt(url, attempt, enabled=True)
|
||||
self.assertEqual(calls, [False, True])
|
||||
calls.clear()
|
||||
with self.assertRaises((OSError, urllib.error.URLError)):
|
||||
network.github_attempt(url, attempt, enabled=False)
|
||||
self.assertEqual(calls, [False])
|
||||
for code in (errno.ENOSPC, errno.EACCES, errno.ENOENT, errno.EIO):
|
||||
with self.subTest(code=code):
|
||||
calls = []
|
||||
def attempt(fallback):
|
||||
calls.append(fallback)
|
||||
raise OSError(code, 'simulated local failure')
|
||||
with self.assertRaises(OSError):
|
||||
network.github_attempt(url, attempt, enabled=True)
|
||||
self.assertEqual(calls, [False])
|
||||
|
||||
def test_q2f_checksum_failure_does_not_retry(self):
|
||||
spec = importlib.util.spec_from_file_location('q2f', ROOT / 'tools/q2f-runtime.py')
|
||||
runtime = importlib.util.module_from_spec(spec); spec.loader.exec_module(runtime)
|
||||
calls = []
|
||||
def fetch(_url, fallback=False):
|
||||
calls.append(fallback)
|
||||
response = io.BytesIO(b'payload'); response.headers = {'Content-Length':'7'}
|
||||
return response
|
||||
with tempfile.TemporaryDirectory() as temp, patch.object(runtime._network, 'fetch_once', fetch):
|
||||
with self.assertRaisesRegex(ValueError, 'checksum mismatch'):
|
||||
runtime.download({'url':'https://github.com/a/b/releases/download/v1/q2f','size':7,'sha256':'0'*64}, pathlib.Path(temp) / 'runtime')
|
||||
self.assertEqual(calls, [False])
|
||||
def test_scope_and_status_policy(self):
|
||||
self.assertTrue(network.public_github('https://api.github.com/repos/a/b/releases?page=1'))
|
||||
for url in ['http://github.com/a', 'https://github.com.evil/a', 'https://a.github.com/a',
|
||||
'https://user@github.com/a', 'https://github.com/a?token=secret',
|
||||
'https://gh-proxy.com/https://github.com/a', 'https://github.com/a?X-Amz-Signature=secret']:
|
||||
self.assertFalse(network.public_github(url), url)
|
||||
for code, headers, expected in [(404, {}, False), (403, {}, False),
|
||||
(403, {'X-RateLimit-Remaining': '0'}, True), (429, {}, True), (500, {}, True)]:
|
||||
self.assertEqual(network.retryable(urllib.error.HTTPError('https://github.com/a', code, '', headers, None)), expected)
|
||||
self.assertFalse(network.retryable(ssl.SSLCertVerificationError('bad certificate')))
|
||||
|
||||
def test_primary_success_disabled_fallback_and_dual_failure(self):
|
||||
calls = []
|
||||
self.assertEqual(network.github_attempt('https://github.com/a', lambda f: calls.append(f) or 9), 9)
|
||||
self.assertEqual(calls, [False])
|
||||
with patch.dict(os.environ, {'FRAMELY_GITHUB_AUTO_FALLBACK':'0'}):
|
||||
with self.assertRaises(ConnectionResetError):
|
||||
network.github_attempt('https://github.com/a', lambda _: (_ for _ in ()).throw(ConnectionResetError()))
|
||||
with self.assertRaisesRegex(RuntimeError, 'primary route failed.*fallback failed') as error:
|
||||
network.github_attempt('https://github.com/a', lambda _: (_ for _ in ()).throw(ConnectionResetError('https://secret?token=value')))
|
||||
self.assertNotIn('secret', str(error.exception))
|
||||
|
||||
def test_partial_body_is_discarded_using_local_http(self):
|
||||
calls = []
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *_): pass
|
||||
def do_GET(self):
|
||||
calls.append(self.path)
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Length', '8')
|
||||
self.end_headers()
|
||||
self.wfile.write(b'old' if self.path == '/primary' else b'new-data')
|
||||
self.close_connection = True
|
||||
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
dest = pathlib.Path(temp) / 'download'
|
||||
progress = []
|
||||
def fetch(_url, fallback=False):
|
||||
return urllib.request.urlopen(f'http://127.0.0.1:{server.server_port}/' + ('fallback' if fallback else 'primary'))
|
||||
with patch.object(network, 'fetch_once', fetch), patch.dict(os.environ, {'FRAMELY_GITHUB_AUTO_FALLBACK':'1'}):
|
||||
network.download('https://github.com/a/b/releases/download/v1/file', dest, 100, progress.append)
|
||||
self.assertEqual(dest.read_bytes(), b'new-data')
|
||||
self.assertEqual(calls, ['/primary', '/fallback'])
|
||||
self.assertEqual(progress, [0, 0, 8])
|
||||
finally:
|
||||
server.shutdown(); server.server_close(); thread.join()
|
||||
|
||||
def test_bootstrap_curl_failure_policy(self):
|
||||
# Fake curl and Python only; execute the actual public bootstrap script.
|
||||
for code, status, enabled, attempts in [(7, '000', '1', 2), (22, '404', '1', 1), (60, '000', '1', 1), (7, '000', '0', 1)]:
|
||||
with self.subTest(code=code, status=status, enabled=enabled), tempfile.TemporaryDirectory() as temp:
|
||||
root = pathlib.Path(temp); log = root / 'calls'
|
||||
curl = root / 'curl'
|
||||
curl.write_text(f'''#!/bin/sh
|
||||
echo called >> '{log}'
|
||||
case "$*" in *gh-proxy.com*) exit 0;; esac
|
||||
printf '%s' '{status}'
|
||||
exit {code}
|
||||
''')
|
||||
python = root / 'python3'; python.write_text('#!/bin/sh\nexit 0\n')
|
||||
curl.chmod(0o755); python.chmod(0o755)
|
||||
env = dict(os.environ, PATH=temp + os.pathsep + os.environ['PATH'], FRAMELY_GITHUB_AUTO_FALLBACK=enabled)
|
||||
subprocess.run(['bash', str(ROOT / 'install.sh')], env=env, capture_output=True)
|
||||
self.assertEqual(len(log.read_text().splitlines()), attempts)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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 {}
|
||||
@@ -5,11 +5,17 @@ import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = pathlib.Path(__file__).parents[1]
|
||||
|
||||
|
||||
class InstallHandoffTests(unittest.TestCase):
|
||||
def test_auto_proxy_preference_survives_service_handoff(self):
|
||||
with patch.dict(os.environ, {'FRAMELY_GITHUB_AUTO_FALLBACK':'0'}):
|
||||
result, args, _ = self.run_handoff('0::/system.slice/framely-session.service')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('--setenv=FRAMELY_GITHUB_AUTO_FALLBACK=0', args)
|
||||
def run_handoff(self, cgroup, repair=False, launch_status=0):
|
||||
source = (ROOT / 'packaging/install.sh').read_text()
|
||||
block = source.split('# A terminal opened by Framely', 1)[1].split('cd "$base"', 1)[0]
|
||||
|
||||
@@ -31,7 +31,9 @@ const SteamClient={{Apps:{{
|
||||
def test_clears_every_injected_image_and_shortcut_icon(self):
|
||||
result = self.run_cleanup('"/owned/./base.apk"')
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertEqual(result['calls'], [['clear', 2452903544, kind] for kind in [0, 1, 3, 4]]
|
||||
# Portrait, hero, transparent logo and landscape are artwork types
|
||||
# 0..3. The shortcut icon is cleared separately, never as type 4.
|
||||
self.assertEqual(result['calls'], [['clear', 2452903544, kind] for kind in [0, 1, 2, 3]]
|
||||
+ [['icon', 2452903544, '']])
|
||||
|
||||
def test_refuses_another_shortcut_with_the_same_id(self):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user