Compare commits

...
Author SHA1 Message Date
toorux 66fe29b024 chore: release v0.4.3-preview.6 2026-10-06 00:06:26 +08:00
toorux 85017b2c54 fix: isolate launcher operations and bound application handoffs 2026-10-06 00:05:29 +08:00
toorux 4fb5eeb5a7 Update README screenshots and installation instructions 2026-10-05 22:39:19 +08:00
toorux 3bbc502481 Fix batch install test ownership for CI runners 2026-10-05 22:12:54 +08:00
toorux 0037d4f0ba Release 0.4.3-preview.4 with smoother launcher and page scrolling 2026-10-05 22:02:55 +08:00
toorux 0d19314106 Release 0.4.3-preview.3 with launcher search and UI refinements 2026-10-05 21:04:49 +08:00
toorux e2348280c1 Clear uninstall safe mode guard after successful cleanup and reinstall 2026-10-05 19:28:34 +08:00
toorux 148bb96ab6 Release 0.4.3-preview.2 with spatial launcher and diagnostic exports 2026-10-05 19:22:32 +08:00
toorux c59fab881a Release 0.4.3-preview.1 with notification inbox and headset interaction fixes 2026-10-05 12:53:07 +08:00
toorux 4108d4e5be Show update confirmation in a modal and release 0.4.2-preview.12 2026-10-05 01:05:10 +08:00
toorux 1c6e36e63b Hide disabled apps in quick panel and release 0.4.2-preview.11 2026-10-05 00:45:57 +08:00
toorux 799433e041 Let plugins close their own surfaces and provide window chrome 2026-10-05 00:44:05 +08:00
toorux e02fde75f4 Automate verified updates and detect installer system proxies 2026-10-05 00:16:01 +08:00
toorux c4f672cba7 Embed installer icons and remove external assets from archives 2026-10-04 23:51:37 +08:00
toorux e54689bff6 Split CEF runtime packages and release preview updates 2026-10-04 23:32:36 +08:00
toorux fb1f67f39f Refresh app icons and add plugin library shortcut 2026-10-04 22:57:32 +08:00
165 changed files with 9080 additions and 475 deletions

No files matched your search

+5 -5
View File
@@ -63,8 +63,8 @@ jobs:
run: |
bash tools/package-release.sh target/cef
mkdir -p release-assets
archive=$(find release -maxdepth 1 -name 'framely-*-linux-arm64.tar.gz' -print -quit)
cp "$archive" release-assets/
archive=$(find release -maxdepth 1 -name 'framely-*-linux-arm64.tar.gz' ! -name '*-offline-linux-arm64.tar.gz' ! -name 'framely-cef-*' -print -quit)
cp release/*.tar.gz release/framely-cef.json release-assets/
cp tools/bootstrap.py release-assets/bootstrap.py
cp install.sh release-assets/install.sh
"$CARGO_TARGET_DIR/release/framely" release-manifest --archive "$archive" --url "https://github.com/$RELEASE_REPO/releases/download/$RELEASE_TAG/$(basename "$archive")" --output release-assets/framely-release.json --changelog "Framely $RELEASE_TAG"
@@ -93,8 +93,8 @@ jobs:
RELEASE_TAG: ${{ github.ref_name }}
run: |
cd release-assets
sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json > SHA256SUMS
printf 'Framely %s\n\nInstall on Frame over SSH using the repository install.sh.\n\nSHA256SUMS verifies downloaded archives; each Framely archive also includes an internal SHA256SUMS.\n' "$RELEASE_TAG" > release-notes.txt
sha256sum -- *.tar.gz bootstrap.py install.sh framely-release.json framely-cef.json > SHA256SUMS
printf 'Framely %s\n\nFirst install: choose the complete *-offline-linux-arm64.tar.gz package. Updates: choose the smaller Framely package without CEF. A separate framely-cef-* archive is available for runtime downloads.\n\nInstall on Frame over SSH using the repository install.sh. SHA256SUMS verifies archives; each archive also includes internal checksums.\n' "$RELEASE_TAG" > release-notes.txt
prerelease=false
latest=true
if [[ "$RELEASE_TAG" == *-* ]]; then
@@ -102,5 +102,5 @@ jobs:
latest=false
fi
gh release create "$RELEASE_TAG" --verify-tag --title "Framely $RELEASE_TAG" --notes-file release-notes.txt --draft --latest=false --prerelease="$prerelease"
gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json
gh release upload "$RELEASE_TAG" -- *.tar.gz SHA256SUMS bootstrap.py install.sh framely-release.json framely-cef.json
gh release edit "$RELEASE_TAG" --draft=false --latest="$latest" --prerelease="$prerelease"
Generated
+185 -2
View File
@@ -8,6 +8,26 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"version_check",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
@@ -85,6 +105,25 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bincode"
version = "2.0.0-rc.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb50c5a2ef4b9b1e7ae73e3a73b52ea24b20312d629f9c4df28260b7ad2c3c4"
dependencies = [
"bincode_derive",
"serde",
]
[[package]]
name = "bincode_derive"
version = "2.0.0-rc.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a45a23389446d2dd25dc8e73a7a3b3c43522b630cac068927f0649d43d719d2"
dependencies = [
"virtue",
]
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -113,6 +152,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -183,6 +224,12 @@ dependencies = [
"libc",
]
[[package]]
name = "crawdad"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fbd1ecd2ed790e11c8fbe034f9b3e7687404818d1bdfd8218d26ec645ec7c5"
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -208,6 +255,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "csv-core"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
dependencies = [
"memchr",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
@@ -291,7 +347,7 @@ dependencies = [
[[package]]
name = "framely"
version = "0.4.2-preview.8"
version = "0.4.3-preview.6"
dependencies = [
"anyhow",
"base64",
@@ -299,6 +355,7 @@ dependencies = [
"hex",
"libc",
"pbkdf2",
"pinyin",
"rand",
"semver",
"serde",
@@ -306,9 +363,12 @@ dependencies = [
"sha2",
"tempfile",
"tiny_http",
"unicode-normalization",
"ureq",
"url",
"vibrato",
"zip",
"zstd",
]
[[package]]
@@ -343,6 +403,15 @@ dependencies = [
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -487,7 +556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.17.1",
]
[[package]]
@@ -502,6 +571,16 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "libc"
version = "0.2.189"
@@ -570,6 +649,18 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pinyin"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e225f595052d9c46045755be4b8d7950b6d9f3c33e0c0b74ba58f11bbfa8c64b"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "potential_utf"
version = "0.1.6"
@@ -642,6 +733,35 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "ring"
version = "0.17.14"
@@ -888,6 +1008,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -900,6 +1026,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -954,6 +1089,26 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vibrato"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17bb75519548ea63e35dd02e9bc3cc672a25423a9b8757bd035f5387771eb4cd"
dependencies = [
"bincode",
"bincode_derive",
"crawdad",
"csv-core",
"hashbrown 0.12.3",
"regex",
]
[[package]]
name = "virtue"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b60dcd6a64dd45abf9bd426970c9843726da7fc08f44cd6fcebf68c21220a63"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -1215,3 +1370,31 @@ dependencies = [
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.1.0+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
dependencies = [
"cc",
"pkg-config",
]
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "framely"
version = "0.4.2-preview.8"
version = "0.4.3-preview.6"
edition = "2021"
license = "AGPL-3.0-only"
@@ -20,6 +20,10 @@ url = "2"
ureq = { version = "2.12", features = ["json"] }
zip = { version = "2.4", default-features = false, features = ["deflate"] }
pbkdf2 = "0.12.2"
pinyin = "0.11"
vibrato = { version = "=0.5.0", default-features = false }
zstd = "0.13"
unicode-normalization = "0.1"
[dev-dependencies]
tempfile = "3"
+19 -3
View File
@@ -7,11 +7,25 @@
[简体中文](README.zh-CN.md)
> Framely is still under rapid development. Preview releases may change frequently; features and interactions are still being refined.
A React plugin manager for Steam Frame, with a Rust core service, a separate CEF/OpenVR host, a network management panel, and a desktop installer. Manage plugins, sources, windows, notifications, and updates.
![Framely spatial launcher](docs/images/launcher.png)
![Framely plugin library](docs/images/plugin-library.png)
## Installation
The target device is a Linux ARM64 Steam Frame. Download the `Framely Installer` for your computer from [Releases](https://github.com/SteamFramelyHomebrew/framely/releases), connect to the device, and install Framely. Alternatively, run this in the Frame SSH terminal to install the latest stable release:
The target device is a Linux ARM64 Steam Frame. Enable developer mode and SSH on Frame, connect your computer and Frame to the same network, and prepare the `steamos` account password.
### Desktop installer
Download the package for your computer from the [Framely Installer release](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.9). Linux packages are `.tar.gz`; Windows and macOS packages are `.zip`. Extract and launch the installer, enter the Frame IP and SSH port, verify the SSH fingerprint, and connect with the `steamos` account. Select the Framely version, then confirm installation. Enable testing releases to install a Preview version. Installer and device versions are independent.
### Command installation
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
@@ -20,10 +34,12 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
For a Preview release, specify an actual published tag:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.2-preview.3
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.3-preview.6
```
Runtime packages include CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Open the Framely Dock icon in the SteamVR Dashboard for the quick menu, then use Settings → Manage plugins to open the manager.
Replace the example tag with the published version you want to install. Enter the sudo password when prompted. See the [installation guide](docs/user-guide/installation.md) for updates, repair, and uninstallation.
First installation uses the complete offline package with CEF; updates use the smaller core package and reuse CEF; the device needs neither Node.js nor Rust. Installation requests sudo and does not automatically disable SteamOS read-only protection. Press the main trigger on the Framely Dock icon in the SteamVR Dashboard to open the launcher, or the upper trigger to open the quick menu. Open the manager from the launcher’s Settings button. The trigger actions can be swapped in Launcher settings.
The network panel uses port `15915` by default. Visit `http://DEVICE_IP:15915` on your phone or computer. When no password is configured, the first visit requires setting and confirming a password before login. The device password and panel password are independent.
+19 -3
View File
@@ -7,11 +7,25 @@
[English](README.md)
> Framely 目前仍在快速迭代中,Preview 版本可能频繁更新,功能与交互仍在持续完善。
Steam Frame 的 React 插件管理器:Rust 核心服务、独立 CEF/OpenVR 宿主、网络管理面板和电脑端安装器。可管理插件、来源、窗口、通知和更新。
![Framely 空间启动台](docs/images/launcher.png)
![Framely 插件库](docs/images/plugin-library.png)
## 安装
目标为 Linux ARM64 Steam Frame。电脑端从 [Releases](https://github.com/SteamFramelyHomebrew/framely/releases) 下载对应平台的 `Framely Installer`,连接设备后安装本体。也可在 Frame 的 SSH 终端安装最新正式版:
目标设备为 Linux ARM64 Steam Frame。先在 Frame 上启用开发者模式与 SSH,确保电脑和 Frame 在同一网络,并准备好 `steamos` 账户密码。
### 安装器安装
从 [Framely Installer 发行下载页](https://github.com/SteamFramelyHomebrew/framely/releases/tag/installer-v0.4.1-preview.9) 下载适合电脑平台的安装器。Linux 提供 `.tar.gz`,Windows 和 macOS 提供 `.zip`。解压并启动安装器,填写 Frame 的 IP 和 SSH 端口,核对 SSH 指纹后使用 `steamos` 账户连接。选择 Framely 版本,再确认安装。安装 Preview 版本时需启用测试版本;安装器与 Frame 本体的版本号相互独立。
### 命令安装
**在 Frame 的 SSH 终端中**执行以下命令,安装最新正式版:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install
@@ -20,10 +34,12 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
安装 Preview 时指定实际已发布的标签:
```bash
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.2-preview.3
curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/install.sh | bash -s -- install --version v0.4.3-preview.6
```
运行包包含 CEF,设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。打开 SteamVR Dashboard 的 Framely Dock 图标进入快捷菜单,从“设置 → 管理插件”打开管理窗口。
将示例标签替换为需要安装的已发布版本,并在提示时输入 sudo 密码。更新、修复和卸载步骤见[安装指南](docs/zh-CN/user-guide/installation.md)。
首次安装使用包含 CEF 的完整离线包,更新使用不含 CEF 的本体包并复用运行库;同时提供独立 CEF 在线下载。设备无需 Node.js 或 Rust。安装会请求 sudo;不会自动解除 SteamOS 只读保护。在 SteamVR Dashboard 中指向 Framely Dock 图标,按主扳机打开启动台,按上扳机打开快捷面板;点击启动台右侧的设置按钮打开管理面板。可在“启动台”设置中互换扳机行为。
网络面板默认端口 `15915`,手机/电脑访问 `http://设备IP:15915`。未配置密码时首次访问必须设置并确认密码,保存后登录。设备密码与面板密码相互独立。
+2 -2
View File
@@ -1,8 +1,8 @@
# Framely branding assets / 品牌资源
The pixel window sprite is the shared identity for Framely and its installer. `framely-mark.svg` and `framely-logo.svg` are dark artwork on transparent backgrounds; `*-light.svg` variants are for dark surfaces. `framely-app-icon.svg` is the application tile on sky blue. The wordmark uses vector paths and needs no font installation.
The pixel window sprite is the shared identity for Framely and its installer. `framely-mark.svg` and `framely-logo.svg` are dark artwork on transparent backgrounds; `*-light.svg` variants are for dark surfaces. `framely-app-icon.svg` uses a charcoal tile with a sky blue sprite. Square marks center the window body, allowing the left tail to extend outward; the native Dock sprite is 10% smaller. The wordmark uses vector paths and needs no font installation.
像素窗口小精灵是本体和安装器的统一标识。`framely-mark.svg` 和 `framely-logo.svg` 为透明底深色版本,`*-light.svg` 用于深色背景;`framely-app-icon.svg` 为浅蓝底应用图标。字标已转成路径,无需安装字体。
像素窗口小精灵是本体和安装器的统一标识。`framely-mark.svg` 和 `framely-logo.svg` 为透明底深色版本,`*-light.svg` 用于深色背景;`framely-app-icon.svg` 为黑底蓝色应用图标。正方形图标以窗口主体居中,尾巴向左伸出;原生插件入口中的图形缩小 10%。字标已转成路径,无需安装字体。
After editing the SVGs, regenerate PNGs, the Windows ICO and native Dock artwork from the repository root, then commit the generated files together:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

+3 -3
View File
@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32" role="img" aria-labelledby="title desc" color="#17191c">
<title id="title">Framely</title>
<desc id="desc">Charcoal pixel window sprite on a sky blue application tile.</desc>
<desc id="desc">Sky blue pixel window sprite on a charcoal application tile, centered on its window body.</desc>
<g shape-rendering="crispEdges">
<rect width="32" height="32" rx="3" fill="#93c5ed" shape-rendering="geometricPrecision"/>
<path fill="#17191c" fill-rule="evenodd" transform="translate(3.4 4) scale(1.2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
<rect width="32" height="32" rx="3" fill="#17191c" shape-rendering="geometricPrecision"/>
<path fill="#93c5ed" fill-rule="evenodd" transform="translate(1.6 4) scale(1.2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 658 B

After

Width:  |  Height:  |  Size: 687 B

+2 -2
View File
@@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="384" height="384" viewBox="0 0 24 24" role="img" aria-labelledby="title desc" color="#93c5ed">
<title id="title">Framely</title>
<desc id="desc">Sky blue pixel window sprite for dark backgrounds.</desc>
<desc id="desc">Pixel window sprite with two square eyes and a raised hand.</desc>
<g shape-rendering="crispEdges">
<path fill="currentColor" fill-rule="evenodd" transform="translate(1.5 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
<path fill="currentColor" fill-rule="evenodd" transform="translate(0 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 548 B

After

Width:  |  Height:  |  Size: 555 B

+1 -1
View File
@@ -2,6 +2,6 @@
<title id="title">Framely</title>
<desc id="desc">Pixel window sprite with two square eyes and a raised hand.</desc>
<g shape-rendering="crispEdges">
<path fill="currentColor" fill-rule="evenodd" transform="translate(1.5 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
<path fill="currentColor" fill-rule="evenodd" transform="translate(0 2)" d="M6 3H18V4H19V14H18V15H16V16H15V17H13V15H10V16H9V17H7V15H6V14H5V10H3V9H2V8H3V9H5V4H6Z M7 6H17V13H7Z M14 4H15V5H14Z M16 4H17V5H16Z M9 8H11V10H9Z M13 10H15V12H13Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 557 B

After

Width:  |  Height:  |  Size: 555 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 189 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 B

After

Width:  |  Height:  |  Size: 278 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 B

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 B

After

Width:  |  Height:  |  Size: 480 B

+73
View File
@@ -0,0 +1,73 @@
Copyright 2000, 2001, 2002, 2003 Nara Institute of Science
and Technology.
Copyright 2023, LegalOn Technologies, Inc.
All Rights Reserved.
Use, reproduction, and distribution of this software is permitted.
Any copy of this software, whether in its original form or modified,
must include both the above copyright notice and the following
paragraphs.
Nara Institute of Science and Technology (NAIST),
the copyright holders, disclaims all warranties with regard to this
software, including all implied warranties of merchantability and
fitness, in no event shall NAIST be liable for
any special, indirect or consequential damages or any damages
whatsoever resulting from loss of use, data or profits, whether in an
action of contract, negligence or other tortuous action, arising out
of or in connection with the use or performance of this software.
A large portion of the dictionary entries
originate from ICOT Free Software. The following conditions for ICOT
Free Software applies to the current dictionary as well.
Each User may also freely distribute the Program, whether in its
original form or modified, to any third party or parties, PROVIDED
that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear
on, or be attached to, the Program, which is distributed substantially
in the same form as set out herein and that such intended
distribution, if actually made, will neither violate or otherwise
contravene any of the laws and regulations of the countries having
jurisdiction over the User or the intended distribution itself.
NO WARRANTY
The program was produced on an experimental basis in the course of the
research and development conducted during the project and is provided
to users as so produced on an experimental basis. Accordingly, the
program is provided without any warranty whatsoever, whether express,
implied, statutory or otherwise. The term "warranty" used herein
includes, but is not limited to, any warranty of the quality,
performance, merchantability and fitness for a particular purpose of
the program and the nonexistence of any infringement or violation of
any right of any third party.
Each user of the program will agree and understand, and be deemed to
have agreed and understood, that there is no warranty whatsoever for
the program and, accordingly, the entire risk arising from or
otherwise connected with the program is assumed by the user.
Therefore, neither ICOT, the copyright holder, or any other
organization that participated in or was otherwise related to the
development of the program and their respective officials, directors,
officers and other employees shall be held liable for any and all
damages, including, without limitation, general, special, incidental
and consequential damages, arising out of or otherwise in connection
with the use or inability to use the program or any product, material
or result produced or otherwise obtained by using the program,
regardless of whether they have been advised of, or otherwise had
knowledge of, the possibility of such damages at any time during the
project or thereafter. Each user will be deemed to have agreed to the
foregoing by his or her commencement of use of the program. The term
"use" as used herein includes, but is not limited to, the use,
modification, copying and distribution of the program and the
production of secondary products from the program.
In the case where the program, whether in its original form or
modified, was distributed or delivered to or received by a user from
any person, organization or entity other than ICOT, unless it makes or
grants independently of ICOT any specific warranty to the user in
writing, such person, organization or entity, will also be exempted
from and not be held liable to the user for any such damages as noted
above as far as the program is concerned.
÷÷
+7
View File
@@ -0,0 +1,7 @@
This software includes a binary version of data from
http://jaist.dl.sourceforge.net/project/mecab/mecab-ipadic/2.7.0-20070801/mecab-ipadic-2.7.0-20070801.tar.gz,
where the connection ids are remapped using CORE data in BCCWJ (except the PN category)
https://clrd.ninjal.ac.jp/bccwj/.
+21
View File
@@ -0,0 +1,21 @@
# Offline launcher search data
`ipadic.dic.zst` is the IPADic 2.7.0 dictionary compiled for Vibrato 0.5.0.
Vibrato is pinned to the matching reader format; upgrade the reader and dictionary together.
Source: https://github.com/daac-tools/vibrato/releases/download/v0.5.0/ipadic-mecab-2_7_0.tar.xz
Source archive SHA-256: `4764f983b7c3a9e1cb6a5ee945e00558efd812980e0dad61224f63ee3b0475d9`
Dictionary SHA-256: `82a6da70bb4a17be70f20ff44f650f9ad1d2b0b4fcb2f39c17fc797f92d0ab75`
Chinese pinyin uses rust-pinyin 0.11.0 and pinyin-data 0.15.0 (MIT); see `pinyin-LICENSE`.
Vibrato license notices are included as `vibrato-LICENSE-*`.
Keep `COPYING` and `NOTICE` with the dictionary. Release packaging copies this directory to
`share/search`, includes it in core updates and offline packages, and records its checksum.
The dictionary is loaded lazily by the session's search worker. No remote service is used.
中文:此目录包含 Vibrato 0.5.0 格式的 IPADic 2.7.0 离线日语读音词典。读入器与词典必须一起升级。
发布包复制到 `share/search`,包含于本体更新包和完整离线包,并纳入校验。
请保留 `COPYING` 和 `NOTICE`。词典由会话搜索线程按需加载,不使用远程服务。
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2016 mozillazg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 legalforce-research
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+4 -1
View File
@@ -8,7 +8,7 @@ Framely manages plugins on Steam Frame through device services, a SteamVR interf
1. [Install, update, repair and uninstall](user-guide/installation.md): desktop installer, SSH, Preview builds, local packages and rollback.
2. [Basic usage](user-guide/basic-usage.md): Dock, manager, enabling plugins, favorites and safe mode.
3. [Install and manage plugins](user-guide/plugins.md): store, sources, subscriptions, local packages and updates.
3. [Install and manage plugins](user-guide/plugins.md): plugin library, sources, subscriptions, local packages and updates.
4. [Network panel](user-guide/network-panel.md): address, first password setup, login and phone/desktop access.
## Plugin development and publishing
@@ -33,3 +33,6 @@ Framely manages plugins on Steam Frame through device services, a SteamVR interf
- [Framely releases and updates](releases.md)
- [Desktop installer](../installer/README.md)
- [Space launcher](launcher.md)
- [Launcher actions and host compatibility](developer-guide/launcher.md)
+4
View File
@@ -82,3 +82,7 @@ Inspect Framely status and systemd logs. Do not mix debug messages into backend
Default GitHub Releases use tag `v<version>` and asset `<id>-<version>.framely`; source may omit `downloadUrl`. Community registration derives it automatically. Prepare a temporary packaged Manifest containing the final URL as shown in [publishing](publishing.md), because current `framely pack` does not read Git remotes. Set an explicit URL for custom downloads. Publish the Release/assets with usage, runtime-user details, changes and actual test coverage, then host a catalog or register a fixed source commit. Database registration also checks expected whole-package hashes and field compatibility.
For updates: bump version → regenerate the release Manifest (update explicit custom URLs if used) → rebuild/verify → device test → new Release → update the source pin. Never replace old assets.
## Launcher actions and host compatibility
[Launcher actions and host compatibility](launcher.md)
+73
View File
@@ -0,0 +1,73 @@
# Launcher, actions and host compatibility
[简体中文](../zh-CN/developer-guide/launcher.md) · [Manifest](manifest.md) · [SDK](sdk.md)
Available from Framely **0.4.3-preview.2**. Existing plugins need no changes: a short press opens their quick page and a long press offers Quick panel, Manage plugin and Uninstall plugin. A missing quick page disables that menu item. Uninstall always asks for confirmation.
## Host version requirement
```json
"engines": {"framely": ">=0.4.3-preview.2 <0.5.0"}
```
`engines.framely` is a mandatory host compatibility check, separate from `apiVersion` (the protocol version). It is optional for existing plugins and required when using launcher declarations. A declared launcher feature must have an explicit version floor of `0.4.3-preview.2` or newer.
Ranges use the same parser as plugin dependencies: exact versions (`0.4.3` or `=0.4.3`), comparisons, whitespace/comma intersections, `^`, `~`, and wildcards. `||` and hyphen ranges are not supported. A normal range excludes preview versions; to include a preview, explicitly name its major/minor/patch and prerelease in a comparator. For example `>=0.4.3-preview.2 <0.5.0` matches later 0.4.3 previews and compatible stable releases, but does not automatically include 0.4.4 previews. Build metadata does not affect matching.
The host checks before installation side effects, updates, version rollback, and runtime startup. Incompatible versions remain visible in the library but cannot be installed; dependency resolution skips them. Downgrading Framely preserves incompatible plugins and their enabled preferences while blocking execution. Published older hosts reject the unknown fields rather than run a new-format package; their error messages cannot be retroactively upgraded.
## Entry behavior
```json
"ui": {
"quickPage": "page.js",
"windows": {"main": {"entry": "page.js", "title": "Example"}},
"launch": {
"launcher": {"type": "window", "window": "main"},
"quickPanel": {"type": "quickPage"},
"manager": {"type": "window", "window": "main"}
}
}
```
Omitted entry behavior opens the plugin's quick page. The manager's separate management button is unaffected. The built-in **Quick panel** menu action always opens the quick page regardless of entry configuration. Window references must name a declared window; a `quickPage` target requires a declared quick page.
`plugin.open` retains its existing backend-start meaning. It does not apply entry navigation.
## Long-press actions
Declare buttons in order; Framely renders them above its three built-in actions. At most 16 custom actions are allowed, with unique valid IDs and nonempty labels up to 120 bytes. Labels are developer-provided text; choose localized content in your published package as appropriate.
```json
"launcherActions": [
{"id": "open", "label": "Open window", "target": {"type": "window", "window": "main"}},
{"id": "connect", "label": "Connect", "target": {"type": "backend", "method": "connect", "params": {"profile": "default"}}},
{"id": "notify", "label": "Send notification", "target": {"type": "frontend", "entry": "actions.js"}}
]
```
A backend target requires a backend and receives `{params, context}`; reserved `framely.*` methods are prohibited. A frontend target names a hashed payload bundle. It runs in a dedicated sandbox only when invoked, without rendering the quick page, and uses the existing scoped SDK capabilities. Frontend actions have a 90-second host deadline; failures stay visible for retry. Actions do not gain plugin management or arbitrary host-command privileges.
Build a separate `actions.ts` bundle (the scaffold's build command does this automatically):
```ts
import {registerLauncherActions, framely} from '@framely/sdk';
registerLauncherActions({
notify: async context => {
await framely.notifications.send({
id: 'hello', title: 'Hello', body: `From ${context.source}`, inbox: true,
});
},
});
```
## Startup context
```ts
const initial = await framely.ui.launchContext.get();
const unsubscribe = framely.ui.launchContext.onChanged(context => {
console.log(context.source, context.trigger, context.actionId);
});
```
`source` is `launcher`, `quickPanel`, or `manager`; `trigger` is `shortPress` or `menuAction`; custom menu actions additionally include `actionId`. Initial context is available after loading, and subsequent activations of a reused page/window send `ui.launch`. Frontend action callbacks receive context directly; backend actions receive it with their parameters. A backend `onStart` lifecycle context also includes `launchContext` when startup was caused by an entry launch. Old calls without a known entry use the quick-panel default.
+5 -1
View File
@@ -39,7 +39,7 @@ Leave source `files` empty. `framely pack` scans payload and supplies hashes. At
| `screenshots` | Default empty; up to eight payload PNG/JPEG paths |
| `authorUrl` / `documentationUrl` / `homepage` | Optional HTTP/HTTPS links, no embedded credentials |
| `downloadUrl` | Optional fixed-version HTTPS package URL; GitHub database source may omit it for repository/version/ID derivation, but community packages need the resolved URL; see publishing |
| `publish` | Optional external store image configuration |
| `publish` | Optional external plugin library image configuration |
| `backend` / `lifecycle` / `ui` | Optional backend/hooks/pages |
| `dependencies` / `optionalDependencies` / `conflicts` | Default empty maps |
| `exclusiveResources` | Default empty array; matching names prevent simultaneous enabling |
@@ -96,3 +96,7 @@ Dependencies use ranges or `{version, source}`, with source pointing to an HTTPS
Manifest JSON is limited to 256 KiB and payload paths to 512 bytes. Payload paths allow ASCII letters/digits and `/._-+` only. Absolute paths, empty segments, `.`/`..`, symlinks and undeclared archive files are forbidden. Successful validation is not a safety review; users still assess runtime privileges.
`backend.uiVisibilityEvents` (boolean, default false) subscribes to manager-only `framely.ui.visibility` RPCs. Reply promptly. Delivery does not depend on mounted pages and requires a host supporting visibility events.
## Launcher actions and host compatibility
[Launcher actions and host compatibility](launcher.md)
+2 -2
View File
@@ -77,7 +77,7 @@ Automatic URLs always require a Release digest, even when a hash is supplied, an
### Icons and window compatibility
For the community database, set top-level `icon: "icon.png"` and include the same PNG path in source and payload. The database derives a GitHub Raw URL from the pinned repository/commit/path and verifies it against the packaged icon; `publish.icon` is unnecessary. Self-hosted catalogs use fixed HTTPS `publish.icon` / `publish.screenshots`. See [store metadata](../plugin-development.md).
For the community database, set top-level `icon: "icon.png"` and include the same PNG path in source and payload. The database derives a GitHub Raw URL from the pinned repository/commit/path and verifies it against the packaged icon; `publish.icon` is unnecessary. Self-hosted catalogs use fixed HTTPS `publish.icon` / `publish.screenshots`. See [plugin library metadata](../plugin-development.md).
The community database supports window `entry`, `title`, `dockIcon`, `localWeb`, `width`, `height`, `widthMeters`. Pixel width is an integer 640–2560, height an integer 360–1440, and physical width follows the core's float32 validation for 0.4–4.0 meters. Omitted dimensions normalize to 1600×900 and 3 meters, matching pack output. Custom dimensions participate in source/package comparison; undeclared changes are rejected. The template's default standalone window passed actual packaging and database validation. Registration still requires whole-package hash, ownership and other field checks.
@@ -92,7 +92,7 @@ mkdir -p target/plugin-catalog
cargo run --locked -- catalog --name 'My source' --base-url https://example.org/packages --packages ./packages --output ./target/plugin-catalog/catalog.json
```
Replace `example.org` with your HTTPS address. The CLI computes whole-package SHA256 and generates JSON; it does not upload packages or copy images. Without external `publish.icon`, the local CLI does not convert a packaged icon into a store URL.
Replace `example.org` with your HTTPS address. The CLI computes whole-package SHA256 and generates JSON; it does not upload packages or copy images. Without external `publish.icon`, the local CLI does not convert a packaged icon into a plugin library URL.
**Upload the entire generated directory, not only `catalog.json`:**
+41 -4
View File
@@ -25,7 +25,8 @@ registerPlugin({QuickPage, windows: {main: MainWindow}});
| `framely.windows.open(key)` | Declared key; asynchronous result | Open a declared window |
| `framely.windows.close(key)` | Key; asynchronous result | Close a window without disabling the backend |
| `framely.notifications.send(notification)` | Notification; asynchronous result | Send or replace by ID |
| `framely.notifications.remove(id)` | Notification ID; asynchronous result | Withdraw notification |
| `framely.notifications.remove(id)` | Notification ID; asynchronous result | Close the popup and remove the inbox copy |
| `framely.notifications.dismiss(id)` | Notification ID; asynchronous result | Close the popup, retaining the inbox copy |
| `framely.dependencies()` | `Promise<DependencyStatus[]>` | Query declared dependency state |
| `framely.language.get()` | `Promise<{preference, language}>` | Language preference and effective language |
| `framely.onEvent(callback)` | Unsubscribe function | Receive forwarded events |
@@ -43,14 +44,38 @@ try {
```tsx
await framely.notifications.send({
id: 'task', title: 'Done', body: 'File processed', durationMs: 8000,
id: 'task', title: 'Done', body: 'File processed', inbox: true, durationMs: 8000,
actions: [{id: 'open', label: 'Open', icon: '↗'}],
});
```
`id`, `title`, `body` are required. Optional `image` accepts PNG/JPEG data URLs or HTTPS, up to a 1 MiB string. At most three actions, each with `id`, `label`, `icon`. `durationMs` is 1000–60000; omission defaults to 8000 ms. Notification/action IDs follow core ID rules; action IDs must be unique within a notification. Title is at most 160 bytes, body 4096, nonempty action label 80, and action icon 16. Each plugin may send at most ten notifications per ten seconds; the global queue permits 64 concurrent notifications. Updating an existing ID does not occupy another slot.
`id`, `title`, `body` are required. `inbox` defaults to `false`: with popups enabled, only notifications explicitly sent with `inbox: true` appear in the quick panel's Notifications tab. Inbox messages survive popup expiry, closing the popup, plugin disable/restart, and Framely restart. They are removed by an action's policy, explicit removal, or plugin uninstall. Restored inbox messages do not replay as popups.
Buttons invoke backend `notification.action` with `{id, action}` and forward a page event of the same name. UI-only plugins can listen for the event. Reuse an ID to update; remove it to withdraw.
| Field | Default | Behavior |
| --- | --- | --- |
| `inbox` | `false` | Save the message in the inbox; user popup settings may also require saving it |
| `durationMs` | `8000` | Popup countdown in milliseconds; `1000-60000`, or `0` to stay open until dismissed |
| `actions[].closeOnClick` | `true` | Close the popup after this action succeeds |
| `actions[].removeFromInboxOnClick` | `true` | Remove the inbox copy after this action succeeds |
User notification settings take priority over sender preferences. If the global popup switch or this plugin's popup switch is off, Framely suppresses the popup and saves the message in the inbox even when `inbox` is false. Reading the inbox clears the launcher unread badge but leaves messages and actions available. Plugins cannot override popup or badge settings.
The two action policies are independent. For a status button that keeps the message, set both to `false`:
```tsx
await framely.notifications.send({
id: 'capture', title: 'Recording', body: 'Recording is in progress.',
inbox: true, durationMs: 0,
actions: [{id: 'status', label: 'View status',
closeOnClick: false, removeFromInboxOnClick: false}],
});
```
Popups display the remaining auto-close countdown; inbox entries show their received time. Closing a popup keeps its saved inbox copy. `remove(id)` withdraws both copies; `dismiss(id)` closes only the popup. Reuse the same ID to replace a message and restart its countdown. Failed actions leave the message available for retry. Buttons use their text `label`; the legacy `icon` string remains optional for compatibility.
Optional `image` accepts PNG/JPEG data URLs or HTTPS, up to a 1 MiB string. At most three actions, each with required `id` and `label`. Notification/action IDs follow core ID rules; action IDs must be unique within a notification. Title is at most 160 bytes, body 4096, nonempty action label 80, and optional action icon 16. Each plugin may send at most ten notifications per ten seconds; the global queue permits 64 simultaneous popups and 256 saved inbox messages. When full, sending a new message fails; remove older messages first. Replacing an existing ID does not occupy another slot.
Buttons invoke backend `notification.action` with `{id, action}` and forward a page event of the same name after a successful backend response. UI-only plugins can listen for the event. A disabled plugin's saved messages remain readable and removable, but their action buttons are disabled until it is enabled again.
## React hooks
@@ -117,3 +142,15 @@ Development preview simulates windows, notifications and some bridge operations.
`framely.ui.getVisibility()` returns `{ known, captureObscured, pageVisible, sequence, sessionId }`. `framely.ui.onVisibilityChanged(callback, onError?)` subscribes and immediately queries the current snapshot; it returns an unsubscribe function. `useVisibility()` provides the same fields and `error`. Capture visibility combines the actual visibility of the quick panel, manager and every plugin window, excluding notifications and dock icons. Remote browser pages have `pageVisible: false` and cannot report headset visibility.
Declare `backend.uiVisibilityEvents: true` to receive the reserved JSON-line RPC `framely.ui.visibility` at startup and on changes, independently of mounted pages. Reply with the request id. The backend snapshot includes `known`, `captureObscured`, `sequence` and `views`; `pageVisible` is frontend-only. A native heartbeat missing for more than two seconds makes `known` false. Capture backends should pause on `!known || captureObscured`. Existing backends do not receive this RPC without opting in. Node/TS can use `registerVisibility` from `@framely/sdk/visibility`; Python supports `serve(dispatch, visibility=callback)`.
`framely.ui.close()` closes the current native surface: the quick panel or the current plugin window. It does not stop the backend or recording. Requires a host with this bridge method.
Standalone plugin windows display the plugin page without a host title bar. Plugins provide their own title, navigation and close button using `framely.ui.close()` or `framely.windows.close(id)`. Quick panels retain host navigation.
## Launcher actions and host compatibility
[Launcher actions and host compatibility](launcher.md)
### Host page scrolling
The host bootstrap installs vertical trigger-drag scrolling for existing plugin pages as well as newly built SDK pages. It selects the scrollable ancestors under the pointer and chains at their boundaries. Controls such as inputs, textareas, editable content and sliders are excluded. Use `data-framely-no-scroll-drag` on an element (or ancestor) whose vertical dragging is handled by the plugin; native `draggable="true"` elements are also excluded. No new manifest field or plugin rebuild is required.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

+33
View File
@@ -0,0 +1,33 @@
# Space launcher
[简体中文](zh-CN/launcher.md)
Point at the Framely Dock icon: by default, use the primary trigger for the launcher and the upper trigger for the quick panel. **Manager → Launcher → Open launcher with the primary trigger** is enabled by default; turning it off swaps these shortcuts immediately. Existing saved trigger preferences are preserved. Other controllers keep the ordinary click entry.
The launcher is a native Dock dashboard panel, 3.6 meters wide, with the previous 2-meter height retained. Steam places and curves it and switches from the current Dock panel when it opens. It closes when the Dock is hidden. UI drawing is capped at 60 FPS independently of the headset refresh rate to limit pixel readback and texture upload load. This is a drawing limit, not a guaranteed measured frame rate. The launcher uses a 4320 × 2400 texture (3× the logical resolution), retains mipmap filtering for edge quality and uploads only changed regions. App ordering and search results are cached during drag animations. The icon area always reserves space for six columns and three rows, regardless of the number of apps. Each page holds up to 13 icons. Fill the first row with four icons, the second with five, then the third with four. The second row starts half a slot to the left; the third aligns with the first. Partial rows retain those starting positions: nine icons are 4 / 5, ten are 4 / 5 / 1. Collections of up to four icons use one centered row. The Done button uses the same translucent surface, border and highlights as the category selector. The visible gap beside the category selector is wider than the gap between adjacent icon artwork. The icon area is centered independently of the category selector, with a fixed 570-pixel height. All three row positions stay fixed, including when only one or two rows are populated. Button and joystick page changes slide and fade over 300 ms. Category changes slide vertically and fade in over 360 ms, with the category selector’s active surface sliding to the selected item; reduced-motion mode skips these transitions. Switching to another Dock panel replaces the launcher normally. Framely does not change other windows’ transparency or input settings. Closing the launcher returns to Steam’s Dock panel. Steam uses local cover artwork first and falls back to logos or initials. The right capsule opens the plugin library or management settings. Use the left category selector for Favorites, All, Plugins, Steam, Lepton or Desktop apps, the bottom dots to change pages, and the star on hover to favorite apps. Favorited apps show a filled blue star on hover; click it again to remove the favorite without changing the icon order. The transparent surface curves around you; only icons, names and categories are normally visible. Disabled plugins are hidden; safe mode prevents third-party plugin launch. Steam lists installed apps from the Frame's local libraries, not account-only or remote-streaming apps.
Press and release may drift by up to 36 UI pixels without changing the initial hit. Icon and category hit areas are larger than the artwork. Deliberate movement cancels stabilization. Hold a plugin icon with the primary trigger for 400 ms to open its action menu; ordinary jitter keeps the hold active. It contains developer actions plus Quick panel, Manage plugin and Uninstall plugin. Press **B** or click an unused area to close the launcher. During icon arrangement, the first press or click exits arrangement and keeps the launcher open. Hold the primary trigger and swipe horizontally: the current page follows your movement while the adjacent page slides into view. Release to snap to that page, or return to the current page if the drag is too short or reaches the boundary. By default the action menu closes ten seconds after the pointer leaves; returning cancels the timer. Configure the switch and delay (1–300 seconds) in **Manager → Launcher**. Uninstall requires confirmation and keeps plugin data.
Keep holding for **800 ms** to enter icon arrangement. Drag any icon (including Framely itself, which opens the management panel) onto another position; icons shift and automatically fill the 4 / 5 / 4 slots without gaps. The left drag edge is to the right of the category selector; the selector itself does not trigger paging. Hold a dragged icon at either edge of the icon area for 650 ms to turn a page; continue holding to move across further pages. Release to save the order, or choose **Done** to finish arranging. Closing or hiding the Dock cancels an unfinished drag. Manual icon order is preserved across favorites, search, refreshes and reopening. Favoriting never moves icons to the front; the Favorites category filters the same order. Apps without a saved position are appended in name order.
Either controller's stick switches categories vertically and pages horizontally. A gesture uses only its dominant axis; return below the neutral threshold before the next navigation step. This prevents a diagonal or simultaneous stick input from performing both actions.
Desktop applications come from the session user's XDG application directories, including Flatpak exports; hidden entries and duplicate Steam game shortcuts are omitted. Steam-installed APK applications appear in the Lepton category and launch through Steam. Sideloaded Lepton apps are discovered from installed APKs in each context's package database; empty contexts and Android system packages are excluded. These launch in their existing user-owned context. Missing Android labels/artwork fall back to the package name and initials. Downloaded but uninstalled APK files are not included. Enumeration does not start containers.
Steam launch feedback confirms handoff to Steam rather than game readiness. Plugin-library entries can specify `engines.framely`; update Framely if your version does not satisfy the shown range.
## Categories and favorites
The manager has a dedicated **Launcher** page for trigger assignment, menu timeout and the categories included in **All**. Plugins (including Framely), Steam, Lepton and Desktop apps are included by default. Turning a category off only removes it from All; its own category and Favorites remain accessible. Turning every category off leaves All empty.
Every app type can be favorited using the star shown on hover. Existing plugin and Steam favorites are preserved. Disabled plugins stay hidden; favorites for temporarily unavailable apps are retained. Each opening clears the search and resets pagination. Once local enumeration completes, Favorites opens if any favorite is currently available; otherwise All opens. Removing the final favorite while viewing Favorites shows an empty state without switching categories until the next opening.
## Search
Click the capsule at the top to open the VR keyboard. Search stays within the selected category and preserves its ordering. Clear the field to restore its apps. While the keyboard is open, launcher stick navigation and background dismissal are paused; B closes the keyboard first. The icon canvas keeps its fixed size and position while searching.
Search accepts original names, English word initials, Chinese pinyin and Japanese romaji / syllable initials. Matching ignores case, width, tone marks, spaces and punctuation; it supports prefixes and continuous fragments. Examples: `toushi`, `tiaose` and `tsts` find **透视调色**; `sakura` and `skr` find **さくら**; `toukyou` finds **東京**; `hla` finds **Half-Life: Alyx**. Chinese alternate character readings are supported within a bounded index; uncommon names or compound readings may differ from the intended pronunciation.
Indexes are built asynchronously and cached on the device. Chinese uses offline pinyin data; Japanese uses the bundled Vibrato / IPADic reading dictionary. Han names receive both Chinese and Japanese candidates regardless of interface language. Unknown dictionary readings retain their original text. Original-name search remains available before phonetic indexing completes. No app names are sent to an external search service.
Session API: `launcher.search.index` accepts `{names: string[]}` and returns a name-keyed map of `{terms: string[]}`. `launcher.favorite` accepts `{key, favorite}` with the same stable keys as launcher ordering. Plugin and Steam keys update their existing favorites; `framely`, `desktop:` and local `lepton:` keys use `launcherFavorites`. `launcher.settings.save` now includes `allCategories` (`plugin`, `steam`, `lepton`, `desktop`); omitted fields migrate to all four categories, while an explicit empty array means none. Search dictionaries and notices are included in both core updates and offline packages under `share/search`.
+2 -2
View File
@@ -79,9 +79,9 @@ Manifest supports payload PNG icon ≤1 MiB/1024×1024, details/tags/changelog a
}
```
The fragment above shows explicit URLs; GitHub registration source may omit `downloadUrl`, deriving repository, `v<version>` and `<id>-<version>.framely`. The packaged Manifest still requires the final URL; current pack does not derive it. See [publishing](developer-guide/publishing.md) for temporary-Manifest commands. Community icons normally use top-level `icon`, with derived and verified pinned Raw URLs, without `publish.icon`. The local catalog CLI uses external `publish` images and does not export packaged image paths; absent external icons use the store default. Community registration pins submodule commits, obtains expected SHA256 from Release digests or source `downloadSha256`, then validates downloaded package hashes, declarations and payloads. Generated channels provide catalog/history JSON without mirroring packages/images. It does not execute author code or establish reproducible source/binary equivalence.
The fragment above shows explicit URLs; GitHub registration source may omit `downloadUrl`, deriving repository, `v<version>` and `<id>-<version>.framely`. The packaged Manifest still requires the final URL; current pack does not derive it. See [publishing](developer-guide/publishing.md) for temporary-Manifest commands. Community icons normally use top-level `icon`, with derived and verified pinned Raw URLs, without `publish.icon`. The local catalog CLI uses external `publish` images and does not export packaged image paths; absent external icons use the plugin library default. Community registration pins submodule commits, obtains expected SHA256 from Release digests or source `downloadSha256`, then validates downloaded package hashes, declarations and payloads. Generated channels provide catalog/history JSON without mirroring packages/images. It does not execute author code or establish reproducible source/binary equivalence.
Catalog CLI only emits JSON, preferring `downloadUrl` over base URL/filename and using external publish images. It does not copy resources. Stores filter keywords/tags/origins/states, retain session caches on failures and install reviewed bytes without downloading again after confirmation.
Catalog CLI only emits JSON, preferring `downloadUrl` over base URL/filename and using external publish images. It does not copy resources. The plugin library filters keywords/tags/origins/states, retains session caches on failures and installs reviewed bytes without downloading again after confirmation.
## Scaffold and preview
+4
View File
@@ -84,3 +84,7 @@ Restart defaults to `on-failure`; `never` reports failure without retrying. Limi
States include starting/running/stopping/stopped/recovering/failed. Core management is serialized. Normal SIGTERM/SIGINT attempts to stop plugins; forced termination or power loss cannot guarantee hooks. Support safe recovery on next startup.
The log view includes backend and lifecycle logs; each retains a current 2 MiB file and a preceding copy. Failed pre-registration installs log to `/var/lib/framely/logs/<id>.lifecycle.log`. User data is under `/var/lib/framely/data/<id>/<identity>/`, relative to any custom core state root.
## Launch context
[Launch context](developer-guide/launcher.md)
+7 -7
View File
@@ -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.4.2-preview.8`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.6`), `.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.4.2-preview.12`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.8`), `.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,18 +47,18 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
```bash
# After committing and pushing the intended source:
git tag v0.4.2-preview.8
git push origin v0.4.2-preview.8
git tag v0.4.2-preview.12
git push origin v0.4.2-preview.12
# Independent installer release:
git tag installer-v0.4.1-preview.6
git push origin installer-v0.4.1-preview.6
git tag installer-v0.4.1-preview.8
git push origin installer-v0.4.1-preview.8
```
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.
Only tags publish. After every product build succeeds, publication creates a draft, uploads assets/checksums, then makes it public. Failed drafts are not stable download targets. Manual runs leave Actions artifacts only.
Device attachments include the runtime archive, `framely-release.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. Installer releases have five platform packages and checksums only. Desktop installers download archive/checksums without user-managed scripts.
Device attachments include the core archive without CEF, the complete `*-offline-linux-arm64.tar.gz` archive, a separate `framely-cef-*` runtime archive, `framely-release.json`, `framely-cef.json`, `bootstrap.py`, `install.sh` and `SHA256SUMS`. The update descriptor always targets the core archive. First installation selects the complete offline archive. CEF is verified and shared under `/home/.framely/cef/<runtime-id>`; missing or newly required runtimes download separately, and previous runtimes remain available for rollback. See [package selection and runtime recovery](user-guide/installation.md#release-packages-and-cef). Installer releases have five platform packages and checksums only. Desktop installers download archive/checksums without user-managed scripts.
External checksums cover downloaded archives; internal checksums cover extracted payload. Neither independently proves author identity. Online installation trusts the chosen repository/HTTPS, local installation the supplied archive/checksums. Redirects never downgrade HTTPS.
+38 -3
View File
@@ -9,10 +9,11 @@
Click Framely in the SteamVR Dashboard Dock. First use requires accepting the terms and privacy statement; plugins do not automatically start before consent.
- **Favorites**: open favorite plugin quick pages.
- **Installed**: browse local plugins and their pages.
- **Settings**: language, safe mode and the plugin manager entry.
- **Installed**: browse enabled local plugins and their pages.
- **Notifications**: view saved notifications and update reminders, and toggle floating notifications.
- **Settings**: choose an installed language, toggle safe mode, open the plugin manager or **Check for updates**. The update entry opens the manager’s About/update page. Install language files and download the language template from the manager’s Settings page.
The manager opens a separate large window with navigation for plugins, store, sources, settings and About. Installation, updates and source management happen here. A phone or computer can also use the [network panel](network-panel.md).
The manager opens a separate large window with navigation for plugins, plugin library, sources, settings, notification settings and About. Installation, updates and source management happen here. A phone or computer can also use the [network panel](network-panel.md).
## Plugins and windows
@@ -26,4 +27,38 @@ Settings provides language, network panel and proxy configuration. HTTP and GitH
Safe mode pauses third-party plugins for troubleshooting; leave it after resolving the problem. About shows the device version, updates and terms. Revoking consent disables all plugins; accepting again does not automatically re-enable them.
In About, 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.
Automatic update checks are enabled by default. Framely checks about 30 seconds after starting (once terms are accepted), then every 6 hours. In About, turn **Check for updates automatically** off or change the interval. Checks use the selected Stable/Testing channel and configured proxy, continue with the panel closed, and never download or install automatically. A newer version saves a notification to the inbox and shows a popup when notification settings allow it. **Ignore** dismisses that version's reminder; **Open updates** opens About's update page. The same version is not repeatedly announced, including after restart. Switching the source or channel clears the old reminder.
Floating notifications follow the headset near the upper-right of the view. Their input mask matches the visible card; short button clicks tolerate small tracking movements.
Notification popups display an auto-close countdown when the sender sets a duration. Closing a popup keeps any saved inbox copy. Remove a saved message from Notifications with its trash button. Action buttons close and remove messages by default; senders can configure different behavior. Ordinary notifications are not saved unless the sender opts in, except when popups are disabled.
The **Allow notifications** switch at the top of Notifications controls all floating popups. When off, new notifications are saved in the inbox even if the sender did not request it; active popups are also moved to the inbox. Turning popups back on does not replay old messages.
In the manager, **Notification settings** provides global **Floating notifications** and **Launcher badge** switches, plus independent **Badge** and **Popup** switches for Framely itself (including update reminders) and every installed plugin, including disabled plugins. Global switches take priority and preserve each app’s preferences. The Framely Dock launcher badge counts unread inbox messages from sources allowed to show a badge, displays `99+` for larger counts, and disappears when you view the inbox. Hidden headset panels do not mark messages as read. Reading keeps messages and their actions available; it does not remove them. Settings and read state survive restart.
Log commands are in the [installation guide](installation.md). A working manager does not establish that all plugins work. Enable plugins individually and verify their sources and runtime users.
## Export logs
Open **Settings → Recovery and diagnostics → Export logs**. In the headset, the ZIP is saved in `~/Downloads` on Frame and the panel shows its path. From the network panel, the ZIP downloads to your browser instead. Collection includes recent Framely/plugin logs, CEF renderer logs, GPU evidence, service status and version information. Missing sources or failed commands are recorded in `report.json`; they do not prevent exporting the remaining information. Each source and the total archive input are bounded.
If the panel cannot open, connect to Frame using the installer and choose **Export logs** in its footer. Select where to save the ZIP on your computer. It collects directly over SSH, includes the installer’s current operation log, and works when Framely services are stopped or its state file is damaged. No release package selection is required.
Plugin configuration, inbox contents and password files are excluded. Common credential patterns in logs are masked; review application logs before sharing the archive. Nothing is uploaded automatically.
For terminal recovery without a running daemon:
```bash
sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs.zip
```
The output must be a new file. If the binary cannot run, an extracted release also includes `tools/export-diagnostics.py`, usable with `sudo python3 tools/export-diagnostics.py --output /tmp/framely-logs.zip`.
## Scrolling in VR
Point the laser at a page and move either controller's stick to scroll, without clicking or focusing the page first. Scrolling follows the element under the laser, including nested plugin panels. Keyboard input pauses page scrolling. Launcher stick navigation keeps its category/page controls.
Hold the primary trigger and move the laser up or down to drag scrollable page content. Small movements remain ordinary clicks; once a vertical drag starts, releasing does not activate the pressed button. Text inputs, sliders and explicit custom drag targets keep their own interactions. The launcher retains its icon arrangement and horizontal page-drag gestures.
+15 -3
View File
@@ -6,7 +6,7 @@
## Before installation
The target is Linux ARM64 Steam Frame. The development baseline is SteamOS VR 0.4.2 and SteamVR build 20260928.6175029; other versions need validation. The runtime includes CEF, so users do not need Node.js or Rust on the device.
The target is Linux ARM64 Steam Frame. The development baseline is SteamOS VR 0.4.2 and SteamVR build 20260928.6175029; other versions need validation. The complete offline installation package includes CEF, so users do not need Node.js or Rust on the device.
Connect your computer and Frame to the same network. Enable developer mode and SSH on Frame, and prepare the `steamos` account password. Verify the SSH fingerprint. Installation uses sudo to create an account, systemd services and release directories. It does not automatically disable SteamOS read-only protection; it stops if required directories cannot be written.
@@ -40,14 +40,26 @@ Replace the example with an existing device release tag. An explicit tag selects
## Option 3: local archive
Download `framely-<version-and-build>-linux-arm64.tar.gz`, the external `SHA256SUMS`, and `bootstrap.py` from the same Release. Place them together on Frame:
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:
```bash
python3 bootstrap.py install --archive "./framely-<version-and-build>-linux-arm64.tar.gz" --checksums ./SHA256SUMS
python3 bootstrap.py install --archive "./framely-<version-and-build>-offline-linux-arm64.tar.gz" --checksums ./SHA256SUMS
```
Angle brackets are placeholders; substitute the actual filename. The engine verifies, stages and extracts under `/home`, then checks internal hashes, avoiding the device's limited `/tmp`.
## Release packages and CEF
Each device release provides three archives:
- `framely-<version-and-build>-offline-linux-arm64.tar.gz`: complete offline package including CEF; always used for first installation.
- `framely-<version-and-build>-linux-arm64.tar.gz`: core package without CEF; selected automatically for updates by the desktop installer, SSH engine and in-app updater.
- `framely-cef-<runtime-id>-linux-arm64.tar.gz`: separate CEF runtime, available as an online download. `framely-cef.json` provides its URL, size and SHA256.
CEF is stored under `/home/.framely/cef/<runtime-id>`. Updates reuse the exact verified runtime; matching CEF files from older full installations migrate automatically. If the required runtime is missing or a release changes CEF, the core installation downloads the separate matching CEF archive over HTTPS and verifies its size, SHA256 and internal checksums. A complete offline package supplies the runtime without downloading it.
Each release refers to its own CEF version, so rollback retains the previous runtime. Repair verifies it; uninstall removes the CEF cache with the programs. Local first installation must use the offline package. Local updates can use the core package when its required CEF is already installed; otherwise runtime recovery needs network access.
## Verify installation
```bash
+1 -1
View File
@@ -24,7 +24,7 @@ Change passwords from authenticated settings or the device's native manager. If
## Available operations
Manage sources and subscriptions, browse the store, upload local packages, confirm installation, enable/disable plugins, change settings and check device updates. Backends always run on Frame; uploading a file transfers it from your phone/computer.
Manage sources and subscriptions, browse the plugin library, upload local packages, confirm installation, enable/disable plugins, change settings and check device updates. Backends always run on Frame; uploading a file transfers it from your phone/computer.
VR windows, controller vibration and SteamVR keyboard need headset validation; a browser cannot replace those interactions. Some external links open in the device's default browser.
+3 -3
View File
@@ -4,9 +4,9 @@
[Documentation](../README.md) · [Network panel](network-panel.md)
## Install from the store
## Install from the plugin library
1. Open **Store** in the manager. Current code initializes community stable/testing sources; availability depends on the actual service and device configuration. If no catalog is available, add a developer's complete HTTPS `catalog.json` URL in Sources.
1. Open **Plugin library** in the manager. Current code initializes community stable/testing sources; availability depends on the actual service and device configuration. If no catalog is available, add a developer's complete HTTPS `catalog.json` URL in Sources.
2. Filter by keywords, tags, source or installation state.
3. Open details and review author, description, changes, runtime user and dependencies. The recommended version is shown first; history loads on demand.
4. Install or inspect the selected version. Read the confirmation after download, verification and dependency resolution.
@@ -49,7 +49,7 @@ Uninstall through the management dialog. Hooks handle external cleanup and saved
| Symptom | Action |
| --- | --- |
| Empty store | Check enabled sources, connectivity and filters |
| Empty plugin library | Check enabled sources, connectivity and filters |
| Download/hash failure | Check network and URL; download again without bypassing verification |
| Missing history | The source may lack its history file; use latest or a trusted local old package |
| Cannot enable | Read dependency/conflict errors and install required plugins |
+4 -1
View File
@@ -10,7 +10,7 @@ Framely 是 Steam Frame 的插件管理器,包含设备端服务、SteamVR 界
1. [安装、更新、修复与卸载](user-guide/installation.md):桌面安装器、SSH、Preview、本地包、回滚。
2. [基本使用](user-guide/basic-usage.md):Dock 入口、管理窗口、启停、收藏、安全模式。
3. [安装与管理插件](user-guide/plugins.md):商店、来源、订阅、本地包和更新。
3. [安装与管理插件](user-guide/plugins.md):插件库、来源、订阅、本地包和更新。
4. [网络管理面板](user-guide/network-panel.md):地址、首次密码、登录、手机/电脑使用。
## 插件开发与发布
@@ -35,3 +35,6 @@ Framely 是 Steam Frame 的插件管理器,包含设备端服务、SteamVR 界
- [Framely 发行与更新机制](releases.md)
- [电脑端安装器构建](../../installer/README.zh-CN.md)
- [空间启动台](launcher.md)
- [启动台操作与本体兼容](developer-guide/launcher.md)
+5 -1
View File
@@ -40,7 +40,7 @@ npm run dev
在浏览器打开 `http://127.0.0.1:5173`;修改页面后自动重载。预览只模拟窗口和通知,不启动 Python 后端,不拥有 Frame 权限;读取/保存按钮在预览中报出后端不可用是正常情况。不要据此声称设备功能已验收。
UI 与后端 API 见 [SDK 文档](sdk.md),字段、默认值和范围见 [Manifest 配置](manifest.md);窗口与商店资料补充见[插件开发参考](../plugin-development.md)。
UI 与后端 API 见 [SDK 文档](sdk.md),字段、默认值和范围见 [Manifest 配置](manifest.md);窗口与插件库资料补充见[插件开发参考](../plugin-development.md)。
## 4. 实现后端与生命周期
@@ -82,3 +82,7 @@ sudo /var/lib/framely/current/bin/framely install ./yourname.my-plugin-0.1.0.fra
GitHub Release 默认使用标签 `v<version>` 和附件 `<id>-<version>.framely`,源码清单可以省略 `downloadUrl`。社区登记会自动拼接地址;打包前按[发布指南](publishing.md)生成包含最终地址的临时清单,因为当前 `framely pack` 不会自己读取仓库地址。自定义下载地址再显式填写 `downloadUrl`。随后公开 Release、上传包,提供使用说明、运行用户、更新记录和实际测试范围,再自行托管插件源或登记固定源码提交。社区数据库还需校验整包预期哈希和字段兼容性。
后续更新:提升版本号 → 重新生成发布清单(自定义地址时更新地址)→ 构建打包并校验 → 实机验证 → 新 Release → 更新来源登记。不要替换旧版本附件。
## 启动台操作与本体兼容
[启动台操作与本体兼容](launcher.md)
+71
View File
@@ -0,0 +1,71 @@
# 启动台、操作与本体兼容要求
[English](../../developer-guide/launcher.md) · [Manifest](manifest.md) · [SDK](sdk.md)
从 Framely **0.4.3-preview.2** 开始支持。旧插件无需修改:短按默认打开快捷页面,长按显示“快捷面板、管理插件、卸载插件”。没有快捷页面时对应按钮置灰,卸载始终需要确认。
## 本体版本要求
```json
"engines": {"framely": ">=0.4.3-preview.2 <0.5.0"}
```
`engines.framely` 是强制执行的本体兼容条件;`apiVersion` 表示协议版本,两者独立。旧插件可以省略;使用启动台新增声明时必须填写,并明确约束最低版本为 `0.4.3-preview.2` 或更新版本。
与插件依赖共用范围解析:精确版本(`0.4.3` 或 `=0.4.3`)、比较符、空格或逗号连接的交集、`^`、`~` 和通配符。不支持 `||` 和连字符区间。普通范围不匹配预览版;需要在比较条件中显式写出对应主、次、补丁版本及预览标识。例如 `>=0.4.3-preview.2 <0.5.0` 接受后续 0.4.3 预览版和兼容正式版,但不会自动接受 0.4.4 预览版。构建标识不影响匹配。
安装、更新、降级安装和启动均检查兼容性;不兼容安装在执行钩子或替换文件之前被拒绝。插件库仍展示不兼容版本及要求,依赖解析跳过这些版本。本体降级后保留插件文件及启用偏好,但阻止执行。已发布的旧本体会拒绝未知清单字段,无法追溯增加友好升级提示。
## 不同入口的行为
```json
"ui": {
"quickPage": "page.js",
"windows": {"main": {"entry": "page.js", "title": "Example"}},
"launch": {
"launcher": {"type": "window", "window": "main"},
"quickPanel": {"type": "quickPage"},
"manager": {"type": "window", "window": "main"}
}
}
```
未配置的入口默认进入对应插件快捷页面。管理面板独立管理按钮不受影响。长按菜单内置的“快捷面板”始终进入快捷页,不受入口配置影响。窗口目标必须引用已声明窗口,快捷页目标必须声明快捷页面。现有 `plugin.open` 仍仅启动后台,不执行入口导航。
## 长按操作
自定义按钮按声明顺序显示在三个内置操作上方;最多 16 个,ID 合法且不重复,名称非空且不超过 120 字节。名称由插件提供,发布时自行选择合适的语言。
```json
"launcherActions": [
{"id": "open", "label": "打开窗口", "target": {"type": "window", "window": "main"}},
{"id": "connect", "label": "连接", "target": {"type": "backend", "method": "connect", "params": {"profile": "default"}}},
{"id": "notify", "label": "发送通知", "target": {"type": "frontend", "entry": "actions.js"}}
]
```
后台目标必须有后台,接收 `{params, context}`,禁止使用保留的 `framely.*` 方法。前端目标必须指向包内带哈希的脚本;点击时在独立沙箱中加载,不渲染快捷页面,可使用现有 SDK 能力。前端操作最多等待 90 秒,失败保留菜单并支持重试。操作不会取得插件管理或任意系统命令权限。
新模板会自动把 `actions.ts` 构建为 `actions.js`:
```ts
import {registerLauncherActions, framely} from '@framely/sdk';
registerLauncherActions({
notify: async context => {
await framely.notifications.send({
id: 'hello', title: '你好', body: `来自 ${context.source}`, inbox: true,
});
},
});
```
## 启动上下文
```ts
const initial = await framely.ui.launchContext.get();
const unsubscribe = framely.ui.launchContext.onChanged(context => {
console.log(context.source, context.trigger, context.actionId);
});
```
`source` 为 `launcher`、`quickPanel` 或 `manager`;`trigger` 为 `shortPress` 或 `menuAction`;自定义操作还包含 `actionId`。首次加载即可查询,复用页面或窗口时接收 `ui.launch` 事件。前端操作直接接收上下文,后台操作随参数接收。入口导致后台首次启动时,`onStart` 生命周期参数也包含 `launchContext`。没有已知来源的旧调用默认标记为快捷面板。
+5 -1
View File
@@ -39,7 +39,7 @@
| `screenshots` | 默认空数组,最多 8 个 payload 内 PNG/JPEG 路径 |
| `authorUrl` / `documentationUrl` / `homepage` | 可选 HTTP/HTTPS 地址,禁止 URL 内用户名密码 |
| `downloadUrl` | 可选,固定版本 HTTPS 包下载地址;GitHub 数据库源码可省略,按仓库/版本/ID 拼接,社区登记的包内需补全;见发布指南 |
| `publish` | 可选外部商店图片配置,见下文 |
| `publish` | 可选外部插件库图片配置,见下文 |
| `backend` / `lifecycle` / `ui` | 可选,配置后端、钩子和页面 |
| `dependencies` / `optionalDependencies` / `conflicts` | 默认空对象,见关系参考 |
| `exclusiveResources` | 默认空数组,禁止同时启用占用同名资源的插件 |
@@ -96,3 +96,7 @@
清单 JSON 最大 256 KiB,载荷路径最多 512 字节。载荷路径仅允许 ASCII 字母、数字及 `/._-+`,禁止绝对路径、空段、`.`、`..`、软链接和未声明的附加包文件。清单通过校验后,运行权限仍需用户判断;文件哈希不代表插件安全审核。
`backend.uiVisibilityEvents`(boolean,默认 false):订阅宿主专用 `framely.ui.visibility` RPC。后端必须及时回复;通知无需页面挂载。该字段需要支持可见性事件的新版宿主。
## 启动台操作与本体兼容
[启动台操作与本体兼容](launcher.md)
+2 -2
View File
@@ -77,7 +77,7 @@ cargo run --manifest-path "$FRAMELY_REPO/Cargo.toml" --locked -- verify "dist/$P
### 图标与窗口兼容
社区数据库的图标通常只需顶层 `icon: "icon.png"`:同一路径 PNG 同时提交到源码仓库和 `payload/`。数据库从登记的仓库、固定提交及路径生成 GitHub Raw 地址,并下载比较包内图标;不需要额外填写 `publish.icon`。自行托管目录的图片由 `publish.icon` / `publish.screenshots` 提供固定 HTTPS 地址。[商店资料参考](../plugin-development.md#商店资料与图标)
社区数据库的图标通常只需顶层 `icon: "icon.png"`:同一路径 PNG 同时提交到源码仓库和 `payload/`。数据库从登记的仓库、固定提交及路径生成 GitHub Raw 地址,并下载比较包内图标;不需要额外填写 `publish.icon`。自行托管目录的图片由 `publish.icon` / `publish.screenshots` 提供固定 HTTPS 地址。[插件库资料参考](../plugin-development.md#插件库资料与图标)
社区数据库支持窗口 `entry`、`title`、`dockIcon`、`localWeb`、`width`、`height`、`widthMeters`。像素宽度为整数 640–2560,高度为整数 360–1440,物理宽度按本体 float32 规则校验 0.4–4.0 米。源码省略尺寸时,数据库会按 1600×900、3 米补全,与 pack 输出一致;自定义尺寸也会参与源码和包内清单比较,未声明的变化会被拒绝。模板包含的默认独立窗口已通过实际打包和数据库校验;登记仍需完成整包哈希、归属及其他字段检查。
@@ -92,7 +92,7 @@ mkdir -p target/plugin-catalog
cargo run --locked -- catalog --name '我的插件源' --base-url https://example.org/packages --packages ./packages --output ./target/plugin-catalog/catalog.json
```
将 `example.org` 换成实际 HTTPS 地址。CLI 计算完整包的 SHA256并生成 JSON,不上传包、不复制图片。没有外部 `publish.icon` 时,本地 CLI 不会把包内图标转换成商店 URL。
将 `example.org` 换成实际 HTTPS 地址。CLI 计算完整包的 SHA256并生成 JSON,不上传包、不复制图片。没有外部 `publish.icon` 时,本地 CLI 不会把包内图标转换成插件库 URL。
**上传整个生成目录,不能只上传 `catalog.json`:**
+41 -4
View File
@@ -25,7 +25,8 @@ registerPlugin({QuickPage, windows: {main: MainWindow}});
| `framely.windows.open(key)` | 窗口 key;异步结果 | 打开已在 Manifest 声明的窗口 |
| `framely.windows.close(key)` | 窗口 key;异步结果 | 关闭窗口,不停用后端 |
| `framely.notifications.send(notification)` | 通知对象;异步结果 | 发送或按 ID 更新通知 |
| `framely.notifications.remove(id)` | 通知 ID;异步结果 | 撤回通知 |
| `framely.notifications.remove(id)` | 通知 ID;异步结果 | 关闭弹窗并移除收件箱副本 |
| `framely.notifications.dismiss(id)` | 通知 ID;异步结果 | 仅关闭弹窗,保留收件箱副本 |
| `framely.dependencies()` | `Promise<DependencyStatus[]>` | 查询本插件声明的依赖状态 |
| `framely.language.get()` | `Promise<{preference, language}>` | 获取语言偏好和当前生效语言 |
| `framely.onEvent(callback)` | 返回取消订阅函数 | 接收宿主转发的事件 |
@@ -43,14 +44,38 @@ try {
```tsx
await framely.notifications.send({
id: 'task', title: '完成', body: '文件已处理', durationMs: 8000,
id: 'task', title: '完成', body: '文件已处理', inbox: true, durationMs: 8000,
actions: [{id: 'open', label: '打开', icon: '↗'}],
});
```
`id`、`title`、`body` 必填。`image` 可选,接受 PNG/JPEG data URL 或 HTTPS URL,字符串最大 1 MiB。`actions` 最多 3 项,每项有 `id`、`label`、`icon`。`durationMs` 范围 1000–60000,省略时默认 8000 ms。`id` 和按钮 `id` 遵循本体 ID 规则,按钮 ID 在同一通知内不得重复;`title` 最大 160 字节、`body` 4096 字节、按钮 `label` 非空且最多 80 字节、`icon` 最多 16 字节。每插件每 10 秒最多发送 10 次,全局最多 64 条同时存在的通知;同 ID 更新不额外占队列位置。
`id`、`title`、`body` 必填。`inbox` 默认 `false`:浮窗启用时,只有明确设置 `inbox: true` 的通知才进入快捷面板的“通知”收件箱。收件箱消息在弹窗到期、关闭弹窗、插件停用或重启、本体重启后都会保留;按钮策略、主动移除或卸载插件会移除消息。重启后恢复的收件箱消息不会重新弹出。
按钮动作调用后端 `notification.action`,参数 `{id, action}`;同时转发 `notification.action` 页面事件。无后端插件可只监听页面事件。同一 ID 用于更新,调用 remove 撤回。
| 字段 | 默认值 | 行为 |
| --- | --- | --- |
| `inbox` | `false` | 保存到收件箱;用户浮窗设置也可要求保存 |
| `durationMs` | `8000` | 弹窗自动关闭倒计时,单位毫秒;支持 `1000-60000`,`0` 表示持续显示直到主动关闭 |
| `actions[].closeOnClick` | `true` | 此按钮操作成功后关闭弹窗 |
| `actions[].removeFromInboxOnClick` | `true` | 此按钮操作成功后移除收件箱副本 |
用户的通知设置优先于发送方偏好。全局浮窗或此插件的浮窗开关关闭时,Framely 不展示弹窗,并将消息转存到收件箱,即使 `inbox` 是 `false`。查看收件箱会清除启动图标的未读角标,但保留消息及按钮。插件不能覆盖用户的浮窗和角标设置。
两个按钮策略独立配置。例如查看状态后仍要保留消息,可将两个字段均设为 `false`:
```tsx
await framely.notifications.send({
id: 'capture', title: '录制中', body: '当前录制正在进行。',
inbox: true, durationMs: 0,
actions: [{id: 'status', label: '查看状态',
closeOnClick: false, removeFromInboxOnClick: false}],
});
```
弹窗显示剩余关闭时间,收件箱显示接收时间。关闭弹窗保留其收件箱副本。`framely.notifications.remove(id)` 同时撤回弹窗和收件箱消息;`framely.notifications.dismiss(id)` 仅关闭弹窗。重复发送同 ID 会替换消息并重新开始倒计时。按钮操作失败时保留消息供重试。按钮使用 `label` 文字显示,旧版 `icon` 字符串保持可选兼容。
`image` 可选,接受 PNG/JPEG data URL 或 HTTPS URL,字符串最大 1 MiB。`actions` 最多 3 项,每项必填 `id`、`label`。通知和按钮 ID 遵循本体 ID 规则,按钮 ID 在同一通知内不得重复;`title` 最大 160 字节、`body` 4096 字节、按钮 `label` 非空且最多 80 字节、可选 `icon` 最多 16 字节。每插件每 10 秒最多发送 10 次,全局最多 64 个同时显示的弹窗及 256 条收件箱消息。达到上限后,新消息发送会报错,请先移除旧消息;替换同 ID 不额外占用位置。
按钮调用后端 `notification.action`,参数为 `{id, action}`;后端调用成功后转发同名页面事件。纯前端插件可以监听该事件。插件停用时,其收件箱消息仍可查看和移除,操作按钮需重新启用插件后使用。
## React hooks
@@ -124,3 +149,15 @@ const off = framely.ui.onVisibilityChanged(state => {
});
// 页面卸载时调用 off()
```
`framely.ui.close()` 关闭当前原生界面:快捷页关闭 Framely 面板,插件窗口关闭自身;不会停止插件后端或当前录制。需要包含此 bridge 方法的新版宿主。
独立插件窗口直接展示插件页面,宿主不添加标题栏。插件应自行提供标题、导航和关闭按钮,可使用 `framely.ui.close()` 或 `framely.windows.close(id)`。快捷面板仍保留宿主导航。
## 启动台操作与本体兼容
[启动台操作与本体兼容](launcher.md)
### 本体页面滚动
本体桥接脚本会为现有插件页面和新版 SDK 页面安装主扳机纵向拖动滚动。它选择指针下可滚动的祖先区域,并在边界继续滚动外层。输入框、文本域、可编辑内容和滑块保留自身交互。若插件需要自行处理纵向拖拽,请在对应元素或其祖先上设置 `data-framely-no-scroll-drag`;原生 `draggable="true"` 元素也会排除。无需新增清单字段或重新打包插件。
+35
View File
@@ -0,0 +1,35 @@
# 空间启动台
[English](../launcher.md)
激光指向 Framely Dock 图标:默认主扳机打开启动台,上扳机打开快捷面板。在 **管理面板 → 启动台 → 主扳机打开启动台** 开关默认开启,关闭后两个入口立即互换。已保存的扳机偏好会继续生效。其他手柄保留普通点击入口。
启动台是 Dock 内宽 3.6 米、高度保留为 2 米的原生面板,由 Steam 管理位置和曲面;打开时通过 Dock 的面板切换机制替代当前面板,Dock 隐藏时关闭。界面绘制上限固定为 60 FPS,不跟随头显刷新率升高,以限制像素读回与纹理上传负担。这是绘制上限,并不保证实测达到该帧率。启动台使用 4320×2400 的纹理(逻辑分辨率的 3 倍),保留多级纹理过滤以平滑图标边缘,只上传发生变化的区域;拖动动画期间缓存应用排序和搜索结果。图标区域始终保留六列三行的空间,不随应用数量变化;每页最多 13 个图标,按第一排 4 个、第二排 5 个、第三排 4 个的顺序填充。第二排向左错开半格,第三排与第一排对齐;未填满的第二、三排也从相同槽位开始,不按数量重新居中分配。9 个为 4/5,10 个为 4/5/1;1–4 个使用一排居中排列。图标区域独立于分类器居中,高度固定为 570 个界面像素;只有一排或两排图标时,每排的槽位位置仍保持不变。“完成”按钮使用与分类器相同的半透明背景、边框和高光,分类器与图标之间的可见留白大于图标之间的水平留白。按钮与摇杆翻页采用 300 毫秒横向滑动与淡入动效,分类切换采用 360 毫秒纵向滑动与淡入动效,分类器的激活底块同步滑动到选中项,系统启用减少动态效果时跳过这些过渡动画。切换到其他 Dock 面板会正常替代启动台,Framely 不修改其他窗口的透明度或输入设置。关闭启动台后返回 Steam 的 Dock 面板。Steam 优先使用本地封面,缺图时回退到 Logo 或首字母。透明界面沿用户面前弯曲,显示应用图标、名称、分类器和顶部搜索框。右侧胶囊按钮打开管理面板的插件库或设置页。左侧切换收藏、全部、插件、Steam、Lepton 和桌面程序,底部圆点翻页,悬停图标显示收藏按钮,已收藏时显示蓝色实心星标,再次点击可取消收藏。停用插件不显示,安全模式阻止第三方插件启动。Steam 仅展示 Frame 本机各库已安装应用,不包含仅在账号库或串流电脑中的应用。
按下后的激光落点允许在 36 个界面像素内抖动,点击区域大于图标本身;明显拖动会取消落点稳定处理。主扳机按住插件图标 400 毫秒展开操作菜单,普通抖动不会取消长按,包含开发者按钮和“快捷面板、管理插件、卸载插件”。按 **B** 或点击空白区域关闭启动台;处于图标整理状态时,第一次操作先退出整理,启动台继续显示。按住主扳机横向拖动时,当前页随手移动,相邻页同步滑入;松手后吸附到相邻页,拖动距离不足或到达边界则回到当前页。操作菜单默认在指针离开 10 秒后关闭,重新移入取消计时。在 **管理面板 → 启动台** 调整自动关闭开关与时长(1–300 秒)。卸载需要确认,并保留插件数据。
继续按住到 **800 毫秒**进入图标整理。拖动任意图标到其他位置,其他图标按顺序移动并自动补齐 4/5/4 槽位,不留空洞。Framely 本身也在启动台中,点击打开管理面板,并可一起排列。向左拖动的翻页边缘位于分类器右侧,分类器本身不会触发翻页。拖到图标区域左右边缘停留 650 毫秒自动翻页,继续停留可跨更多页面。放手保存排列,点击“完成”退出整理。关闭启动台或隐藏 Dock 会取消尚未完成的拖动。手动排列在收藏、搜索、刷新和重新打开后保持不变。收藏不会将图标移到前面,收藏分类按相同顺序筛选;尚无保存位置的应用按名称追加到末尾。
任一手柄的摇杆上下切换分类、左右翻页。每次拨动只取幅度较大的一个方向,回到中位范围后才允许再次导航,避免斜向或两个摇杆同时触发分类与翻页。
桌面程序从会话用户的 XDG 应用目录和 Flatpak 导出入口读取,隐藏入口及重复的 Steam 游戏快捷方式不会显示。Steam 安装的 APK 应用出现在 Lepton 分类,通过 Steam 启动;侧载应用从 Lepton 各容器的已安装 APK 与包数据库读取,排除空容器和 Android 系统组件,并在原有用户容器中启动。缺少 Android 名称、图片时使用包名和首字母。仅下载但未安装的 APK 不会加入启动台,读取应用列表也不会启动容器。
Steam 启动成功表示请求已交给 Steam,并不代表游戏加载完成。插件库版本可声明 `engines.framely`;当前本体不满足显示的范围时,请先升级本体。
## 分类与收藏
管理面板新增独立的 **启动台** 页面,集中设置扳机分配、菜单自动关闭及“全部包含的分类”。默认包含插件(含 Framely)、Steam、Lepton、桌面程序。关闭某个分类只影响“全部”,其单独分类和收藏仍可访问;全部关闭时,“全部”显示空状态。
所有类型的应用均可用悬停显示的星标收藏,已有插件与 Steam 收藏保持不变。停用插件不显示,暂时不可用的应用仍保留收藏记录。每次打开会清空搜索并重置分页,但保持上次所选分类;当前 Framely 运行期间的分类选择会一直保留。仅本次运行首次打开时,本地应用枚举完成后,有可显示的收藏则进入收藏,否则进入全部。在收藏页取消最后一个收藏时保持收藏分类,重新打开也显示空状态,不自动切换分类。
## 搜索
点击顶部胶囊搜索框打开 VR 键盘。仅搜索当前分类,并保留原有排序;清空后恢复分类内容。键盘打开期间暂停摇杆导航及背景关闭,B先关闭键盘。搜索不会改变图标区域的固定大小或位置。
支持原文、英文单词首字母、中文拼音、日语罗马音及音节首字母。不区分大小写、全半角,忽略声调、空格与分隔符,支持前缀及连续片段。例如 `toushi`、`tiaose`、`tsts` 匹配“透视调色”;`sakura`、`skr` 匹配“さくら”;`toukyou` 匹配“東京”;`hla` 匹配“Half-Life: Alyx”。中文索引在有限大小内包含单字的其他读音,生僻名称或词组读音可能与实际读法不同。
索引在设备后台异步生成并缓存。中文使用离线拼音数据,日语使用随包提供的 Vibrato/IPADic 读音词典。汉字名称同时生成中文和日语候选,不依赖界面语言;词典无法识别的部分保留原文。读音索引尚未完成时仍可使用原文搜索,不会将应用名称发送至外部搜索服务。
会话接口:`launcher.search.index` 接收 `{names: string[]}`,返回名称对应的 `{terms: string[]}`。`launcher.favorite` 接收 `{key, favorite}`,使用与图标排列相同的稳定标识。插件和 Steam 仍更新原有收藏,`framely`、`desktop:` 和本地 `lepton:` 标识保存于 `launcherFavorites`。`launcher.settings.save` 新增 `allCategories`(`plugin`、`steam`、`lepton`、`desktop`);旧配置省略字段时默认全选,显式空数组表示全部不选。词典和许可说明位于发布包 `share/search`,同时包含于本体更新包和完整离线包。
已收藏应用的右上角显示常驻的蓝色实心星标,再次点击即可取消收藏;收藏状态变化不会改变图标排序。
+6 -6
View File
@@ -87,11 +87,11 @@ Framely 主页面和插件 iframe 自动为可交互控件提供轻微的手柄
振动由 OpenVR 的当前激光控制器执行(12 ms、120 Hz、幅度 0.15),宿主只接受当前可见且被激光命中的视图,限制相邻脉冲间隔为 80 ms。插件发出的反馈消息由 iframe 宿主绑定到实际视图,不能指定另一个窗口或手柄。
## 商店资料与图标
## 插件库资料与图标
Manifest 可声明 `icon`(载荷内 PNG,最多 1 MiB、1024×1024)、`details`、`tags`、`screenshots`(最多 8 个载荷内 PNG/JPEG 路径)、`changelog`。这些文件会和页面一样加入文件哈希清单。图标用于插件列表和插件独立窗口的原生 Dock 缩略图,省略时使用默认拼图图标。
清单还支持三个可选网页地址,未填写的入口不显示:`authorUrl`(作者主页)、`documentationUrl`(插件文档)、`homepage`(插件主页)。地址必须是 HTTP 或 HTTPS,可包含文档锚点,不接受带用户名密码的链接。这些字段会保留在安装后的清单,并由 `framely catalog` 导出到商店目录;作者主页链接直接显示在作者名称上,未提供时作者名称为普通文本;商店列表、详情、管理弹窗和安装确认使用相同行为。文档和插件主页保留独立入口,点击后由系统默认浏览器打开。
清单还支持三个可选网页地址,未填写的入口不显示:`authorUrl`(作者主页)、`documentationUrl`(插件文档)、`homepage`(插件主页)。地址必须是 HTTP 或 HTTPS,可包含文档锚点,不接受带用户名密码的链接。这些字段会保留在安装后的清单,并由 `framely catalog` 导出到插件库目录;作者主页链接直接显示在作者名称上,未提供时作者名称为普通文本;插件库列表、详情、管理弹窗和安装确认使用相同行为。文档和插件主页保留独立入口,点击后由系统默认浏览器打开。
```json
{
@@ -113,11 +113,11 @@ Manifest 可声明 `icon`(载荷内 PNG,最多 1 MiB、1024×1024)、`deta
}
```
上述为显式地址示例,`downloadUrl` 并非源码必填字段:GitHub 社区登记可按仓库、`v<version>` 和 `<id>-<version>.framely` 自动生成。包内仍需最终地址,当前 pack 不会自动读取 Git remote;按[发布指南](developer-guide/publishing.md)生成临时清单即可。社区图标通常只需 `icon`,数据库从固定源码提交生成并校验 Raw 地址;无需额外 `publish.icon`。本地 catalog CLI 使用 `publish` 外部图片,省略时不会导出包内图标/截图,商店采用默认图标。包内资源仍用于已安装插件。
上述为显式地址示例,`downloadUrl` 并非源码必填字段:GitHub 社区登记可按仓库、`v<version>` 和 `<id>-<version>.framely` 自动生成。包内仍需最终地址,当前 pack 不会自动读取 Git remote;按[发布指南](developer-guide/publishing.md)生成临时清单即可。社区图标通常只需 `icon`,数据库从固定源码提交生成并校验 Raw 地址;无需额外 `publish.icon`。本地 catalog CLI 使用 `publish` 外部图片,省略时不会导出包内图标/截图,插件库采用默认图标。包内资源仍用于已安装插件。
插件数据库只登记 submodule 固定提交,读取该提交的清单,补全默认下载地址,先取得 Release digest 或源码 `downloadSha256` 中的预期整包哈希,再下载并比对 SHA256、清单声明和载荷。发布分支提供各渠道目录及历史版本 JSON,不保存插件包或图片,不需要 entries 登记。数据库 CI 自动展示版本和运行用户变化,发布时禁止同 ID、版本更换 SHA256;不执行作者代码,不要求逐插件人工源码审核,也不验证源码与二进制可复现关系。
`framely catalog` 也只生成 JSON:下载 URL 优先使用 `downloadUrl`,没有该字段时采用 `--base-url` 加包文件名。图标与截图来自 `publish` 的外部 URL,不复制资源。商店支持关键词、标签、来源、已安装和可更新筛选;源暂时不可用时显示会话缓存。安装使用已经校验的同一份包,不在确认后重新下载。
`framely catalog` 也只生成 JSON:下载 URL 优先使用 `downloadUrl`,没有该字段时采用 `--base-url` 加包文件名。图标与截图来自 `publish` 的外部 URL,不复制资源。插件库支持关键词、标签、来源、已安装和可更新筛选;源暂时不可用时显示会话缓存。安装使用已经校验的同一份包,不在确认后重新下载。
后端默认按失败自动恢复,包括已经启动的按需插件;可用 `backend.restart: "never"` 关闭。默认第三次连续失败后停用,运行稳定一分钟后重置计数,详见[生命周期](plugin-lifecycle.md)。
@@ -145,8 +145,8 @@ SDK 提供 `Section`、`Button`、`Toggle`、`Slider`、`TextField`、`Select`
插件只使用 `tags` 描述用途,不再使用分类;为兼容已发布的包,旧 `category` 字段读取时忽略,新的清单及目录不再导出它。插件数据库每次生成目录时同时生成 `tags.json`,格式为 `{ "schemaVersion": 1, "tags": ["工具", "温控"] }`,聚合当前目录全部插件的标签、精确去重并排序。stable/testing 分别生成自己的标签文件,不生成跨渠道并集;程序加载多个源后在本地聚合去重。标签随插件更新自动增删,无须手工维护。
上传的插件包、商店下载及其依赖包暂存于 `/tmp/framely-package-*`,确认安装后才写入插件目录。取消、安装完成或失败后删除暂存包;未完成上传和待确认安装包闲置 15 分钟后过期,每 30 秒清理一次。
上传的插件包、插件库下载及其依赖包暂存于 `/tmp/framely-package-*`,确认安装后才写入插件目录。取消、安装完成或失败后删除暂存包;未完成上传和待确认安装包闲置 15 分钟后过期,每 30 秒清理一次。
本机网页窗口在 `ui.windows.<key>` 设置 `localWeb: true`,宿主通过后端 `window.get` 获取窗口 URL;这是窗口加载方式,不是权限声明。URL 使用 `http://localhost:<端口>/framely-window/<key>`。普通窗口无需设置此项。
商店主目录 `catalog.json` 中每个插件 ID 仅出现一次,代表最新版。历史条目存放在相对目录 `plugins/<插件ID>/versions.json`,格式为 `{ "schemaVersion": 1, "id": "作者.插件", "versions": [...] }`;版本条目沿用目录条目的字段,列表不包含主目录的最新版。商店进入详情时按需读取历史,依赖解析仅在最新版不满足范围时读取历史。附加目录字段可扩展,读取端忽略未知字段,但校验已知字段;改变必需语义应升级 `schemaVersion`。插件安装包清单仍严格校验。
插件库主目录 `catalog.json` 中每个插件 ID 仅出现一次,代表最新版。历史条目存放在相对目录 `plugins/<插件ID>/versions.json`,格式为 `{ "schemaVersion": 1, "id": "作者.插件", "versions": [...] }`;版本条目沿用目录条目的字段,列表不包含主目录的最新版。插件库进入详情时按需读取历史,依赖解析仅在最新版不满足范围时读取历史。附加目录字段可扩展,读取端忽略未知字段,但校验已知字段;改变必需语义应升级 `schemaVersion`。插件安装包清单仍严格校验。
+4
View File
@@ -93,3 +93,7 @@ restart 默认为 on-failure;never 只记录失败,不自动重启。restart
状态包含 starting、running、stopping、stopped、recovering、failed 等阶段,并发管理操作沿核心服务的串行调度执行。管理器正常 SIGTERM/SIGINT 会尝试逐个停止插件;强制终止、断电不能保证任何钩子执行。清理应同时可在下次启动时安全恢复。
日志页面合并展示后端日志与 lifecycle 日志;每种日志保留当前 2 MiB 与上一份。安装失败且插件尚未登记时,可从 `/var/lib/framely/logs/<id>.lifecycle.log` 查看。运行身份的数据目录为 `/var/lib/framely/data/<id>/<身份>/`(自定义核心数据目录时相应变化)。
## 启动上下文
[启动上下文](developer-guide/launcher.md)
+13 -7
View File
@@ -38,12 +38,18 @@ framely call system.source.save '{"source":{"url":"https://example.org/framely-r
该机制不能恢复已被系统清除的 `/home` 数据,也不保证操作系统更新后无需人工操作。回滚到不支持修复模式的旧发行后,需要使用支持该模式的新发行包恢复安装。正式发布前应进行一次真实 SteamOS 更新,验证设置、插件数据、账号身份、服务恢复和 SteamVR 宿主兼容性。
## 本体包与独立 CEF
每次构建生成不含 CEF 的本体包 `framely-<版本及构建号>-linux-arm64.tar.gz`、完整离线包 `framely-<版本及构建号>-offline-linux-arm64.tar.gz`、独立运行库 `framely-cef-<运行库标识>-linux-arm64.tar.gz`。更新清单 `framely-release.json` 固定指向不含 CEF 的本体包;首次安装固定选择完整离线包。`framely-cef.json` 提供独立 CEF 的 HTTPS 下载地址、大小及 SHA256。
CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时复用;旧安装中相同的运行库自动迁移。所需运行库缺失或 CEF 版本更换时单独下载并校验,各发行的运行库保留用于回滚。完整包可离线提供 CEF。参见[发行包与运行库恢复](user-guide/installation.md#发行包与-cef)。
## GitHub Actions 发布
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.8`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.6`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.4.2-preview.12`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.8`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
@@ -53,19 +59,19 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
```bash
# 提交并推送代码后,选择需要发布的产品
git tag v0.4.2-preview.8
git push origin v0.4.2-preview.8
git tag v0.4.2-preview.12
git push origin v0.4.2-preview.12
# 独立发布安装器,不触发 Framely 构建
git tag installer-v0.4.1-preview.6
git push origin installer-v0.4.1-preview.6
git tag installer-v0.4.1-preview.8
git push origin installer-v0.4.1-preview.8
```
带 `-preview.N` 等预发布后缀的标签会发布为 GitHub Prerelease,不设置为 Latest。安装器 Release 始终设置为非 Latest;只有 Framely 正式 Release 设置为 Latest。因此设备安装入口和默认更新清单的 `releases/latest/download/...` 继续指向 Framely 本体。安装器读取发行列表时按本体包名筛选,不把 Linux ARM64 安装器误当成设备发行包。安装器下载应使用具体的 `installer-v<版本>` Release。
只有标签触发会发布。各工作流在自己的构建全部成功后创建草稿 Release,上传本产品产物及外部 `SHA256SUMS`,最后公开草稿。Framely Release 额外包含 `bootstrap.py`、入口脚本和更新清单;安装器 Release 只包含五个平台的安装器包和校验文件。失败的草稿不作为最新稳定版提供下载。手动触发用于验证,仅保留 Actions artifacts。
Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为设备运行包;`framely-release.json` 为内置更新功能的版本清单;`install.sh` 是命令行入口,调用 `bootstrap.py` 完成下载、校验和维护;`SHA256SUMS` 为附件校验清单。桌面安装器只需自动下载运行包和校验清单,用户无需手动下载脚本。安装器的五个平台包发布在独立的安装器 Release 中。
Framely 本体 Release 附件用途:`framely-*-linux-arm64.tar.gz` 为不含 CEF 的更新包,`framely-*-offline-linux-arm64.tar.gz` 为首次安装用的完整离线包,`framely-cef-*` 为独立运行库,`framely-cef.json` 提供运行库下载信息;`framely-release.json` 为内置更新功能的版本清单;`install.sh` 是命令行入口,调用 `bootstrap.py` 完成下载、校验和维护;`SHA256SUMS` 为附件校验清单。桌面安装器只需自动下载运行包和校验清单,用户无需手动下载脚本。安装器的五个平台包发布在独立的安装器 Release 中。
外部 `SHA256SUMS` 包含压缩包哈希,校验传输内容;包内同名文件校验解压后的载荷。哈希不能独立证明作者身份,在线安装依赖指定 GitHub 仓库与 HTTPS;本地安装依赖用户提供的可信包与校验文件。下载遵循 HTTPS 重定向,不允许降级。
+2 -2
View File
@@ -19,8 +19,8 @@
默认每天自动刷新,也可关闭自动刷新或立即刷新。自动检查在 Framely 会话运行期间进行,启动后检查到期项;支持 ETag/Last-Modified 和最多五次 HTTPS 重定向。离线/失败保留已持久化的上次成功源列表,记录错误,按一分钟起的指数退避重试,最多一天。
新增源自动纳入;名称自动更新。相同 id 的 URL 改变时保留旧地址,显示待确认变更,确认后才采用新地址。刷新订阅后同时刷新插件目录。目录缓存沿用会话内缓存,重启后的离线商店不保证有目录缓存。
新增源自动纳入;名称自动更新。相同 id 的 URL 改变时保留旧地址,显示待确认变更,确认后才采用新地址。刷新订阅后同时刷新插件目录。目录缓存沿用会话内缓存,重启后的离线插件库不保证有目录缓存。
目录 URL 规范化后去重,一个源可由多个订阅和手动添加共同引用。删除订阅或移除订阅中的源只移除对应引用;没有任何引用才从源列表移除。已安装插件、来源记录和数据保留,不静默切换来源。
可在插件源列表单独停用源,刷新不会重新开启;即使移除后又加入同一 URL,也保留停用偏好。订阅源的 URL 在待确认变更中管理。刷新只更新源列表、商店资料和更新提示,不自动安装、更新或启用插件。
可在插件源列表单独停用源,刷新不会重新开启;即使移除后又加入同一 URL,也保留停用偏好。订阅源的 URL 在待确认变更中管理。刷新只更新源列表、插件库资料和更新提示,不自动安装、更新或启用插件。
+42 -3
View File
@@ -9,10 +9,11 @@
在 SteamVR Dashboard 点击 Framely Dock 图标打开快捷菜单。首次访问需阅读并同意协议及隐私声明;未同意时插件不会自动启动。
- **常用**:打开收藏的插件快捷页。
- **已安装**:查看本机插件,进入插件页面。
- **设置**:语言、安全模式和“管理插件”入口。
- **已安装**:查看已启用的本机插件,进入插件页面。
- **通知**:查看收件箱消息和更新提醒,并控制悬浮通知。
- **设置**:选择已安装的语言、切换安全模式,或打开“管理插件”和“检查更新”。“检查更新”打开管理窗口的关于/更新页面;安装语言文件和下载语言模板在管理窗口的设置中进行。
“管理插件”打开独立大窗口,左侧导航进入插件、商店、插件源、设置和关于。安装、更新及来源管理在这里完成;电脑或手机也可使用[网络管理面板](network-panel.md)。
“管理插件”打开独立大窗口,左侧导航进入插件、插件库、插件源、设置、通知设置和关于。安装、更新及来源管理在这里完成;电脑或手机也可使用[网络管理面板](network-panel.md)。
## 插件启停与窗口
@@ -26,4 +27,42 @@
“安全模式”暂停第三方插件,适合出现故障时检查;排查后再退出安全模式。在“关于”查看本体版本、检查更新和复查协议。撤回协议会停用全部插件,再次同意后需要手动启用。
在“关于”选择正式版或测试版渠道并检查更新,点击“下载并安装”并确认一次:Framely 下载、校验发行包后自动开始安装。下载和校验分别显示阶段提示与实际字节进度。下载或校验失败时停止安装并显示原因。插件和数据保留,Framely 界面会暂时关闭并重新启动。
日志排查命令见[安装指南](installation.md#修复与日志)。界面能打开不代表所有插件都正常;先逐个启用,确认运行身份与来源可信。
## 通知收件箱与自动更新检查
快捷面板的“通知”标签展示明确设置为进入收件箱的消息,浮窗启用时,普通通知默认不保存;浮窗关闭时会转存到收件箱。浮动通知显示自动关闭倒计时,关闭浮动通知会保留已保存的收件箱副本;收件箱里的删除按钮会移除消息。通知按钮默认在操作成功后关闭弹窗并移除收件箱副本,发送方可以分别修改这两个行为。
“通知”顶部的“允许通知”开关控制全局浮窗。关闭后,新通知即使没有声明进入收件箱也会保存到这里,正在显示的浮窗也会转存。重新开启不会重新弹出旧消息。
悬浮通知跟随头显,显示在视野内右上方。激光命中区域与实际卡片对齐,短按按钮可容忍小幅追踪抖动。
管理窗口的“通知设置”提供全局“悬浮通知”和“启动图标角标”开关,并为 Framely 本体(包括更新提醒)及所有已安装插件(包括已停用插件)提供独立的“角标”和“悬浮”开关。全局开关优先,关闭后仍保留各应用偏好。Framely Dock 启动图标的角标统计允许显示角标来源的未读收件箱消息,超过 99 显示 `99+`;实际打开通知标签后消除角标,隐藏的头显面板不会自动标记已读。查看消息不会删除消息,也不会关闭其操作按钮。设置和已读状态均会保存并在重启后恢复。
本体默认在启动约 30 秒后检查更新(须已接受用户协议),之后每 6 小时检查一次;面板关闭时也会继续检查。可在管理窗口“关于”的更新页关闭自动检查或修改间隔。自动检查遵循所选正式版/测试版渠道及代理配置,只检查并提醒,不会自动下载或安装。
发现较新版本后会将提醒保存在收件箱,通知设置允许时同时显示浮窗。“忽略”关闭并移除此版本提醒,同一版本在重启后也不会重复提醒;“打开更新”直接打开管理窗口的更新页面。更改更新源或渠道会清除旧提醒。
## 导出日志
打开 **设置 → 恢复与诊断 → 导出日志**。在头显内,ZIP 保存到 Frame 的 `~/Downloads`,面板显示保存路径;通过网络面板访问时,ZIP 下载到浏览器。日志包包括本体与插件的近期日志、CEF 渲染日志、GPU 信息、服务状态和安装版本。缺失来源或命令失败会记录在 `report.json` 中,不影响导出其余信息;单个来源和总收集量均有大小限制。
管理面板无法打开时,使用安装器连接 Frame,点击底部的 **导出日志**,选择电脑上的保存位置。安装器直接通过 SSH 收集,也会加入当前安装器的操作日志;本体服务停止或状态文件损坏时仍可使用,无需选择发行版本或安装包。
日志包不包含插件配置、收件箱内容或密码文件,会过滤日志中的常见凭据格式;分享前仍应检查应用日志。不会自动上传。
无需运行本体服务,也可以通过终端导出:
```bash
sudo /var/lib/framely/current/bin/framely export-logs --output /tmp/framely-logs.zip
```
输出路径必须是新文件。本体程序无法运行时,解压发行包中的 `tools/export-diagnostics.py` 也可独立使用:`sudo python3 tools/export-diagnostics.py --output /tmp/framely-logs.zip`。
## VR 页面滚动
激光指向页面后,拨动任意手柄的摇杆即可滚动,无需先点击或聚焦页面。滚动作用于激光下的元素,也支持插件页面的内层滚动区域。键盘打开时暂停页面摇杆滚动;启动台仍使用原有的摇杆分类与翻页操作。
按住主扳机并上下移动激光,可拖动可滚动的页面内容。小幅抖动仍视为普通点击;纵向拖动开始后,松开不会触发原先按下的按钮。文本输入、滑块及明确声明的自定义拖拽区域保留自身交互。启动台保留图标排列和横向拖动翻页操作。
+15 -3
View File
@@ -6,7 +6,7 @@
## 安装前
目标设备为 Linux ARM64 Steam Frame。当前开发基线为 SteamOS VR 0.4.2、SteamVR build 20260928.6175029;其他系统版本需验证。运行包自带 CEF,设备使用时无需安装 Node.js 或 Rust。
目标设备为 Linux ARM64 Steam Frame。当前开发基线为 SteamOS VR 0.4.2、SteamVR build 20260928.6175029;其他系统版本需验证。完整离线安装包自带 CEF,设备使用时无需安装 Node.js 或 Rust。
电脑与 Frame 连接同一网络,在 Frame 开启开发者模式并启用 SSH,准备 Steam 用户 `steamos` 的登录密码。安装会使用 sudo 创建账号、systemd 服务和发行目录;请核对 SSH 指纹。安装器不会自动解除 SteamOS 只读保护;实际所需目录不可写时会停止。
@@ -40,14 +40,26 @@ curl -fsSL https://raw.githubusercontent.com/SteamFramelyHomebrew/framely/main/i
## 方式三:本地发行包
下载设备运行包 `framely-<版本及构建号>-linux-arm64.tar.gz`、外部 `SHA256SUMS` 和同一 Release 的 `bootstrap.py`,放入 Frame 的同一目录:
下载完整离线包 `framely-<版本及构建号>-offline-linux-arm64.tar.gz`、外部 `SHA256SUMS` 和同一 Release 的 `bootstrap.py`,放入 Frame 的同一目录:
```bash
python3 bootstrap.py install --archive "./framely-<版本及构建号>-linux-arm64.tar.gz" --checksums ./SHA256SUMS
python3 bootstrap.py install --archive "./framely-<版本及构建号>-offline-linux-arm64.tar.gz" --checksums ./SHA256SUMS
```
尖括号是占位符,执行前替换为真实文件名。引擎校验后在 `/home` 暂存、解压并检查包内哈希,避免耗尽设备的 `/tmp`。
## 发行包与 CEF
每个本体 Release 提供三种压缩包:
- `framely-<版本及构建号>-offline-linux-arm64.tar.gz`:包含 CEF 的完整离线包,首次安装固定使用此包。
- `framely-<版本及构建号>-linux-arm64.tar.gz`:不含 CEF 的本体包;桌面安装器、SSH 引擎及本体更新器在更新时自动选择此包。
- `framely-cef-<运行库标识>-linux-arm64.tar.gz`:单独的 CEF 运行库,提供在线下载;`framely-cef.json` 包含下载 URL、大小和 SHA256。
CEF 保存在 `/home/.framely/cef/<运行库标识>`。更新复用经过校验的对应运行库;旧完整安装中相同的 CEF 文件会自动迁移。所需运行库缺失或发行更换 CEF 时,本体安装流程通过 HTTPS 下载单独的匹配 CEF 包,并校验大小、SHA256 和包内哈希。完整离线包直接提供运行库,无需下载 CEF。
各发行引用自己的 CEF 版本,回滚时仍保留之前的运行库;修复会校验运行库,卸载则与程序一起移除 CEF 缓存。本地首次安装必须选择完整离线包;本地更新可使用不含 CEF 的包,前提是对应运行库已安装,否则恢复运行库需要联网。
## 验证安装
```bash
+1 -1
View File
@@ -24,7 +24,7 @@ http://设备IP:15915
## 可以做什么
登录后可以管理来源和订阅、浏览商店、导入本地插件包、确认安装、启停插件、修改设置及检查本体更新。插件后端始终运行在 Frame,上传本地包只是从手机/电脑把文件传到设备。
登录后可以管理来源和订阅、浏览插件库、导入本地插件包、确认安装、启停插件、修改设置及检查本体更新。插件后端始终运行在 Frame,上传本地包只是从手机/电脑把文件传到设备。
VR 窗口、手柄振动和 SteamVR 虚拟键盘需要头显端验收;浏览器无法替代这些设备交互。部分外部链接由设备默认浏览器打开。
+6 -6
View File
@@ -4,21 +4,21 @@
[文档首页](../README.md) · [网络面板](network-panel.md)
## 从商店安装
## 从插件库安装
1. 打开管理窗口“商店”。当前代码首次初始化加入社区 stable/testing 来源;来源内容取决于服务和设备配置。没有目录或插件时,进入“插件源”添加开发者提供的完整 HTTPS `catalog.json` 地址。
1. 打开管理窗口“插件库”。当前代码首次初始化加入社区 stable/testing 来源;来源内容取决于服务和设备配置。没有目录或插件时,进入“插件源”添加开发者提供的完整 HTTPS `catalog.json` 地址。
2. 可按关键词、标签、来源或安装状态筛选。
3. 打开详情,查看作者、介绍、更新记录、运行用户和依赖。默认显示推荐版本,历史版本会按需加载。
4. 点击安装或检查所选版本。下载、校验和依赖解析完成后,阅读确认页。
5. 检查包内实际运行用户以及依赖带来的 root 权限,确认后安装。安装后在插件列表启用或打开。
商店提供的运行用户只是目录资料,最终确认以下载包内声明为准。`steamos` 使用 Steam 会话用户,`root` 可修改系统和设备设置。包哈希用于校验内容,不等于对作者身份或安全性的担保。
插件库提供的运行用户只是目录资料,最终确认以下载包内声明为准。`steamos` 使用 Steam 会话用户,`root` 可修改系统和设备设置。包哈希用于校验内容,不等于对作者身份或安全性的担保。
## 添加源与订阅
单个目录地址:在“插件源”添加名称和 `https://.../catalog.json`,测试连接后保存。
多个源的订阅:在“源订阅”粘贴开发者提供的订阅 JSON 地址,预览所含来源后确认添加。它和单个目录不是同一种文件。订阅刷新仅更新来源和商店资料,不自动安装或更新插件。
多个源的订阅:在“源订阅”粘贴开发者提供的订阅 JSON 地址,预览所含来源后确认添加。它和单个目录不是同一种文件。订阅刷新仅更新来源和插件库资料,不自动安装或更新插件。
停用或删除来源不会自动卸载已安装插件;订阅管理的 URL 变更需另行确认。格式与刷新行为见[源订阅参考](../source-subscriptions.md)。
@@ -41,7 +41,7 @@ sudo /var/lib/framely/current/bin/framely install ./plugin.framely --approve
## 更新、选择旧版与卸载
商店提示“可更新”时进入详情,查看变化后按安装确认流程更新。需要旧版时,在详情选择历史版本再检查;换来源也会明确提示。设置和收藏通常保留,但运行用户改变会使用另一份数据目录,不自动迁移旧身份的数据。
插件库提示“可更新”时进入详情,查看变化后按安装确认流程更新。需要旧版时,在详情选择历史版本再检查;换来源也会明确提示。设置和收藏通常保留,但运行用户改变会使用另一份数据目录,不自动迁移旧身份的数据。
从插件管理弹窗卸载。卸载钩子负责清理该插件的外部资源,保存的数据保留;不要直接删除系统中的插件目录。停用只停止使用,不卸载程序和数据。
@@ -49,7 +49,7 @@ sudo /var/lib/framely/current/bin/framely install ./plugin.framely --approve
| 现象 | 处理 |
| --- | --- |
| 商店空白 | 检查是否添加并启用了来源、连接能否成功、筛选是否过窄 |
| 插件库空白 | 检查是否添加并启用了来源、连接能否成功、筛选是否过窄 |
| 下载或校验失败 | 检查网络和包地址,重新下载;不要跳过哈希校验 |
| 历史版本不可用 | 来源可能没有历史文件,可先安装最新版或导入可信旧包 |
| 插件无法启用 | 查看依赖、冲突和错误说明,确认其他必要插件已安装 |
+1 -1
View File
@@ -1,4 +1,4 @@
import React,{useState} from 'react';
import {registerPlugin,framely,Section,Button} from '@framely/sdk';
function Page(){const [text,setText]=useState('');const [result,setResult]=useState('');const [count,setCount]=useState(0);async function run(f:()=>Promise<unknown>){try{setResult(JSON.stringify(await f(),null,2));}catch(e){setResult(String(e));}}return <><Section title="Framely 功能测试"><small>每个按钮都调用正式 SDK。</small><p><Button onClick={()=>setCount(n=>n+1)}>点击计数:{count}</Button><Button onClick={()=>void run(()=>framely.call('identity'))}>读取后端身份</Button></p></Section><Section title="输入与持久化"><input placeholder="点击后打开 Steam 虚拟键盘" value={text} onChange={e=>setText(e.target.value)}/><Button onClick={()=>void run(()=>framely.call('save',{text}))}>保存</Button><Button onClick={()=>void run(()=>framely.call('read'))}>读取</Button></Section><Section title="窗口"><Button onClick={()=>void run(()=>framely.windows.open('main'))}>打开原生 Dock 窗口</Button><Button onClick={()=>void run(()=>framely.windows.open('temporary'))}>打开临时窗口</Button><Button onClick={()=>void run(()=>framely.windows.close('main'))}>关闭大窗口</Button><Button onClick={()=>void run(()=>framely.windows.close('temporary'))}>关闭临时窗口</Button></Section><Section title="通知"><Button onClick={()=>void run(()=>framely.notifications.send({id:'showcase',title:'Framely 通知测试',body:'一张图片和三个按钮。重复发送会更新这条通知。',image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII=',durationMs:15000,actions:[{id:'ok',label:'确认',icon:'✓'},{id:'save',label:'保存',icon:'↓'},{id:'more',label:'详情',icon:'⋯'}]}))}>发送通知</Button><Button onClick={()=>void run(()=>framely.notifications.remove('showcase'))}>撤回</Button></Section><pre style={{whiteSpace:'pre-wrap',fontSize:16}}>{result}</pre><Section title="滚动测试">{Array.from({length:12},(_,i)=><p key={i}>测试条目 {i+1}</p>)}</Section></>}
function Page(){const [text,setText]=useState('');const [result,setResult]=useState('');const [count,setCount]=useState(0);async function run(f:()=>Promise<unknown>){try{setResult(JSON.stringify(await f(),null,2));}catch(e){setResult(String(e));}}return <><Section title="Framely 功能测试"><small>每个按钮都调用正式 SDK。</small><p><Button onClick={()=>setCount(n=>n+1)}>点击计数:{count}</Button><Button onClick={()=>void run(()=>framely.call('identity'))}>读取后端身份</Button></p></Section><Section title="输入与持久化"><input placeholder="点击后打开 Steam 虚拟键盘" value={text} onChange={e=>setText(e.target.value)}/><Button onClick={()=>void run(()=>framely.call('save',{text}))}>保存</Button><Button onClick={()=>void run(()=>framely.call('read'))}>读取</Button></Section><Section title="窗口"><Button onClick={()=>void run(()=>framely.windows.open('main'))}>打开原生 Dock 窗口</Button><Button onClick={()=>void run(()=>framely.windows.open('temporary'))}>打开临时窗口</Button><Button onClick={()=>void run(()=>framely.windows.close('main'))}>关闭大窗口</Button><Button onClick={()=>void run(()=>framely.windows.close('temporary'))}>关闭临时窗口</Button></Section><Section title="通知"><Button onClick={()=>void run(()=>framely.notifications.send({id:'showcase',inbox:true,title:'Framely 通知测试',body:'一张图片和三个按钮。重复发送会更新这条通知。',image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII=',durationMs:15000,actions:[{id:'ok',label:'确认',icon:'✓'},{id:'save',label:'保存',icon:'↓'},{id:'more',label:'详情',icon:'⋯'}]}))}>发送通知</Button><Button onClick={()=>void run(()=>framely.notifications.remove('showcase'))}>撤回</Button></Section><pre style={{whiteSpace:'pre-wrap',fontSize:16}}>{result}</pre><Section title="滚动测试">{Array.from({length:12},(_,i)=><p key={i}>测试条目 {i+1}</p>)}</Section></>}
registerPlugin({QuickPage:Page,WindowPage:Page});
+14 -1
View File
@@ -1985,7 +1985,7 @@ dependencies = [
[[package]]
name = "framely-installer"
version = "0.4.1-preview.6"
version = "0.4.1-preview.9"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -2003,6 +2003,7 @@ dependencies = [
"tempfile",
"ureq",
"url",
"winreg",
"winresource",
"zeroize",
]
@@ -6549,6 +6550,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]]
name = "spin"
version = "0.9.9"
@@ -7450,6 +7462,7 @@ dependencies = [
"rustls-pki-types",
"serde",
"serde_json",
"socks",
"url",
"webpki-roots 0.26.11",
]
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "framely-installer"
version = "0.4.1-preview.6"
version = "0.4.1-preview.9"
edition = "2024"
license = "AGPL-3.0-only"
@@ -22,7 +22,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
hex = "0.4"
ureq = { version = "2.12", features = ["json"] }
ureq = { version = "2.12", features = ["json", "socks-proxy"] }
url = "2"
ssh2 = { version = "0.9", features = ["vendored-openssl"] }
if-addrs = "0.13"
@@ -33,6 +33,9 @@ tempfile = "3"
zeroize = "1"
directories = "6"
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "=0.55.0"
[build-dependencies]
winresource = "=0.1.31"
+13 -1
View File
@@ -32,6 +32,10 @@ After an operation, maintenance shows a separate success/failure card and a reco
Scanning, downloads, and SSH work run on background threads, with progress and logs in the UI. The network panel button assumes port 15915; use the actual address if the device port has changed.
Online first installation always downloads the complete `*-offline-linux-arm64.tar.gz` package with CEF. Online updates download the core package without CEF and reuse the device runtime. A missing or newly required CEF runtime is downloaded separately by the device. For local first installation, choose the complete offline package. See [package selection](../docs/user-guide/installation.md#release-packages-and-cef).
Release queries, checksum files and package downloads automatically use the computer's manual system proxy: Windows Internet Settings, macOS network settings, or Linux GNOME/KDE settings. `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` (or their lowercase forms) override desktop settings; `NO_PROXY` and desktop bypass rules are respected, including on redirects. HTTP CONNECT and SOCKS proxies are supported. PAC scripts and IPv6 proxy endpoints are currently unsupported; use a manual proxy with a hostname or IPv4 address. SSH/SFTP and downloads initiated by Frame use the device connection and network rather than the computer's HTTP proxy.
## Build
Linux requires C/C++ build tools, CMake, pkg-config, and development libraries for Fontconfig/Freetype, X11/XCB, XKBCommon, Wayland, and Vulkan. macOS requires Xcode command-line tools. Windows requires MSVC, CMake, and Perl for built-in OpenSSL. GPUI Kit is pinned to 0.7.0; matching dependencies are locked in `Cargo.lock`.
@@ -58,12 +62,14 @@ 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.6`) 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.1-preview.9`) 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.
The titlebar and application icons share the pixel logo. Windows builds embed the ICO in the EXE; macOS packaging uses the system `iconutil` to generate and configure `.icns`. On Linux, run `bash install-desktop-entry.sh` from the extracted archive to register an icon-bearing entry in the current user's application menu. The entry points to that directory; rerun the script after moving it.
Installer archives do not include a separate `assets` directory. The UI logo is embedded in the executable; the Linux registration script embeds its menu icon, while macOS keeps its application icon inside the `.app` bundle.
Automated tests and Actions builds validate software and artifacts. Discovery, login, permissions, update, rollback, and uninstall still need acceptance testing on actual computers and Frame hardware.
Discovery queries `frame.local` mDNS address records directly. It does not require an advertised SSH service or system DNS support for `.local`. Run the same discovery logic independently for diagnosis:
@@ -71,3 +77,9 @@ Discovery queries `frame.local` mDNS address records directly. It does not requi
```bash
cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --example scan -- 192.168.5.0/24
```
## Export diagnostic logs
After connecting to Frame, click **Export logs** in the footer and choose a ZIP destination on your computer. You do not need to select a release or installation package. Collection runs directly over SSH with administrator access, independently of Framely’s web panel and daemon. It also works with stopped services or damaged state, and includes the installer’s current operation log. Export does not install, restart or change Framely.
The archive includes bounded recent service/plugin/CEF logs, GPU evidence, installation versions and a plugin status summary. Missing sources are listed in `report.json`. Plugin settings, inbox contents and password files are excluded; common credential patterns are masked. Review logs before sharing. Cancelling the save dialog leaves the installation untouched.
+13 -1
View File
@@ -32,6 +32,10 @@ Rust + GPUI Kit 的桌面安装器,连接 Steam Frame,安装、更新、修
扫描、下载和 SSH 工作在后台线程运行,界面展示进度和日志。默认网络管理面板按钮使用端口 15915;设备修改端口后应使用实际地址。
在线首次安装固定下载包含 CEF 的完整 `*-offline-linux-arm64.tar.gz` 包;在线更新下载不含 CEF 的本体包并复用设备运行库。所需 CEF 缺失或版本更换时,由设备单独下载。本地首次安装也需选择完整离线包,参见[发行包说明](../docs/zh-CN/user-guide/installation.md#发行包与-cef)。
版本查询、校验文件和压缩包下载自动读取电脑的手动系统代理:Windows Internet 设置、macOS 网络设置及 Linux GNOME/KDE 设置。`HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY`(也支持小写)优先于桌面设置;遵循 `NO_PROXY` 及桌面绕过规则,重定向后也会重新判断。支持 HTTP CONNECT 和 SOCKS 代理。暂不支持 PAC 脚本及 IPv6 代理地址,可使用主机名或 IPv4 地址的手动代理。SSH/SFTP 使用设备连接,Frame 主动下载使用设备网络,不使用电脑的 HTTP 代理。
## 构建
Linux 需要 C/C++ 构建工具、CMake、pkg-config、Fontconfig/Freetype、X11/XCB、XKBCommon、Wayland 和 Vulkan 开发库。macOS 需要 Xcode 命令行工具;Windows 需要 MSVC、CMake 和 Perl(用于内置 OpenSSL)。GPUI Kit 固定为 0.7.0,配套依赖由 `Cargo.lock` 锁定。
@@ -58,12 +62,14 @@ 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.6`)触发 `.github/workflows/installer-release.yml`,只构建并发布安装器;本体使用 `v<版本>` 标签和独立工作流。也可在 Actions 手动运行安装器工作流,仅生成构建产物。安装器 Release 不占用仓库的 Latest,以免影响 Frame 的默认安装和更新地址。
安装器版本由本目录的 `Cargo.toml` 和 `Cargo.lock` 独立管理,不跟随 Framely 本体版本。推送 `installer-v<版本>` 标签(当前 `installer-v0.4.1-preview.9`)触发 `.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 代码签名,发布前应分别验证系统的首次启动体验。
安装器标题栏和应用图标使用统一的像素 Logo。Windows 构建将 ICO 嵌入 EXE,macOS 打包使用系统 `iconutil` 生成并配置 `.icns`。Linux 解压后可在解压目录执行 `bash install-desktop-entry.sh`,把带图标的入口注册到当前用户的应用菜单;入口指向该目录,移动后需重新执行脚本。
安装器压缩包不包含独立的 `assets` 目录。界面 Logo 内嵌在可执行文件中,Linux 注册脚本内嵌菜单图标,macOS 的应用图标保存在 `.app` 内部。
所有自动测试和 Actions 构建只能验证软件及产物;仍需在真实电脑和 Frame 上完成扫描、登录、权限、升级回滚及卸载验收。
扫描会直接查询 `frame.local` 的 mDNS 地址记录,不要求设备广播 SSH 服务,也不依赖电脑的系统 DNS 支持 `.local`。可单独运行与界面相同的发现逻辑进行诊断:
@@ -71,3 +77,9 @@ Actions 构建 Linux x64/ARM64、Windows x64、macOS Intel/Apple Silicon。macOS
```bash
cargo run --manifest-path installer/Cargo.toml --locked --no-default-features --example scan -- 192.168.5.0/24
```
## 导出诊断日志
连接 Frame 后,点击底部的 **导出日志**,选择电脑上的 ZIP 保存位置,无需选择发行版本或安装包。安装器通过 SSH 和管理员权限独立收集,不依赖网络面板或本体服务;服务停止、状态文件损坏时仍能导出,并包含安装器当前的操作日志。导出不会安装、重启或修改 Framely。
日志包包含有大小限制的近期服务、插件和 CEF 日志、GPU 信息、安装版本及插件状态摘要。缺失来源记录在 `report.json` 中。不包含插件设置、收件箱内容和密码文件,并过滤常见凭据格式;分享前仍应检查日志。取消保存弹窗不会改变安装状态。
+5 -2
View File
@@ -3,7 +3,7 @@
set -euo pipefail
base=$(cd -- "$(dirname -- "$0")" && pwd)
python3 - "$base" <<'PY'
import os, pathlib, shutil
import os, pathlib
base = pathlib.Path(__import__('sys').argv[1])
binary = base / 'framely-installer'
if not binary.is_file():
@@ -13,7 +13,10 @@ apps = data / 'applications'
icons = data / 'icons/hicolor/scalable/apps'
apps.mkdir(parents=True, exist_ok=True)
icons.mkdir(parents=True, exist_ok=True)
shutil.copy2(base / 'assets/branding/framely-app-icon.svg', icons / 'org.framely.installer.svg')
icon = None # Embedded by tools/package-installer.py.
if icon is None:
icon = (base.parent / 'assets/branding/framely-app-icon.svg').read_bytes()
(icons / 'org.framely.installer.svg').write_bytes(icon)
# Desktop entries parse backslash escapes before parsing the quoted Exec value.
quoted = str(binary).replace('%', '%%').replace('\\', '\\\\')
for character in ('"', '`', '$'):
+1
View File
@@ -2,6 +2,7 @@ pub mod discovery;
pub mod instance;
pub mod maintenance;
pub mod progress;
pub mod proxy;
pub mod release;
pub mod remote;
pub const DEFAULT_REPO: &str = "SteamFramelyHomebrew/framely";
+52 -5
View File
@@ -42,6 +42,7 @@ enum Event {
Connected(Result<Connection, String>),
File(FileKind, Option<PathBuf>),
Log(String),
LogsExported(Result<Option<PathBuf>, String>),
Progress(Progress),
Done(Result<(), String>),
}
@@ -320,6 +321,20 @@ impl Installer {
self.status = progress.detail.clone();
self.progress = Some(progress);
}
Event::LogsExported(result) => {
self.busy = false;
match result {
Ok(Some(path)) => {
self.status = "日志已导出。".into();
self.notice("日志导出完成", format!("已保存到:{}", path.display()));
}
Ok(None) => self.status = "已取消导出日志。".into(),
Err(error) => {
self.status = "日志导出失败。".into();
self.notice("日志导出失败", error);
}
}
}
Event::Done(result) => {
self.busy = false;
self.error = None;
@@ -347,6 +362,39 @@ impl Installer {
}
self.present_prompt(window, cx);
}
fn export_logs(&mut self, cx: &mut Context<Self>) {
if self.busy {
return;
}
let Some(connection) = self.connection.clone() else {
return;
};
let password = Zeroizing::new(self.password.read(cx).value().to_string());
let sender = self.sender.clone();
self.busy = true;
self.status = "正在导出日志…".into();
let logs = self.logs.clone();
std::thread::spawn(move || {
let result = (|| -> anyhow::Result<Option<PathBuf>> {
let Some(path) = rfd::FileDialog::new()
.set_title("保存 Framely 日志包")
.set_file_name("framely-logs.zip")
.add_filter("ZIP 日志包", &["zip"])
.save_file()
else {
return Ok(None);
};
let guard = connection
.lock()
.map_err(|_| anyhow::anyhow!("设备连接不可用"))?;
remote::export_logs(&guard, &password, &path, &logs)?;
Ok(Some(path))
})()
.map_err(|e| format!("{e:#}"));
let _ = sender.send(Event::LogsExported(result));
});
cx.notify();
}
fn scan(&mut self, cx: &mut Context<Self>) {
self.cancelled.store(true, Ordering::Relaxed);
self.cancelled = Arc::new(AtomicBool::new(false));
@@ -463,7 +511,7 @@ impl Installer {
} else {
self.selected
.and_then(|i| self.releases.get(i))
.is_some_and(|r| release::package(r).is_ok())
.is_some_and(|r| release::package_for(r, &self.chosen_action).is_ok())
}
}
fn action_available(&self, action: &str) -> bool {
@@ -480,7 +528,7 @@ impl Installer {
} else {
self.selected
.and_then(|i| self.releases.get(i))
.and_then(|r| release::package(r).ok())
.and_then(|r| release::package_for(r, "update").ok())
.map(|(package, _)| package.name.as_str())
};
self.chosen_action == "update"
@@ -592,7 +640,7 @@ impl Installer {
} else {
let selected =
selected.ok_or_else(|| anyhow::anyhow!("请选择 Release 版本"))?;
let (package, checksum) = release::package(&selected)?;
let (package, checksum) = release::package_for(&selected, &action)?;
let archive = folder.path().join(&package.name);
let sums = folder.path().join("SHA256SUMS");
let total = checksum
@@ -615,8 +663,7 @@ impl Installer {
(None, None)
};
if let (Some(archive), Some(sums)) = (&archive, &sums) {
progress(Progress::new(Stage::Verify, "校验本地安装包 SHA256"));
release::verify(archive, sums)?;
release::verify_with_progress(archive, sums, &mut progress)?;
log("电脑端 SHA256 校验通过,开始传输;设备端将再次校验。".into());
}
remote::operate(
+4 -3
View File
@@ -27,9 +27,10 @@ impl Installation {
}
}
pub fn same_package(&self, name: &str) -> bool {
self.current_version
.as_ref()
.is_some_and(|version| name == format!("framely-{version}-linux-arm64.tar.gz"))
self.current_version.as_ref().is_some_and(|version| {
name == format!("framely-{version}-linux-arm64.tar.gz")
|| name == format!("framely-{version}-offline-linux-arm64.tar.gz")
})
}
}
pub fn requires_package(action: &str) -> bool {
+19 -2
View File
@@ -102,6 +102,7 @@ impl DeviceProgress {
"stage" => (2, "准备设备安装目录"),
"extract" => (3, "解压并校验发行文件"),
"configure" => (4, "配置账号、安装文件与系统服务"),
"cef-download" => (4, "设备下载 CEF 运行库"),
"activate" => (5, "启动服务并检查安装状态"),
_ => continue,
};
@@ -111,8 +112,17 @@ impl DeviceProgress {
{
continue;
}
let mut value = Progress::new(Stage::Install, detail);
value.step = Some((index, 5));
let mut value = Progress::new(
if step.step == "cef-download" {
Stage::Download
} else {
Stage::Install
},
detail,
);
if step.step != "cef-download" {
value.step = Some((index, 5));
}
value.completed = step.completed.unwrap_or(0);
value.total = step.total;
report(value);
@@ -188,5 +198,12 @@ mod tests {
assert_eq!(reports[0].step, Some((3, 5)));
assert_eq!(reports[1].step, Some((4, 5)));
assert_eq!(reports[1].fraction(), None);
decoder.push(
"FRAMELY_PROGRESS {\"step\":\"cef-download\",\"completed\":50,\"total\":100}\n",
&mut |p| reports.push(p),
);
assert_eq!(reports[2].stage, Stage::Download);
assert_eq!(reports[2].fraction(), Some(0.5));
assert_eq!(reports[2].step, None);
}
}
+560
View File
@@ -0,0 +1,560 @@
//! Read proxy settings without changing the computer or the SSH device.
use anyhow::{Result, bail, ensure};
use std::{collections::HashMap, net::IpAddr};
use url::Url;
#[derive(Default)]
struct Settings {
proxies: HashMap<String, String>,
bypass: Vec<String>,
exclude_simple: bool,
reverse_bypass: bool,
}
impl Settings {
fn select(&self, url: &Url) -> Option<&str> {
let host = url.host_str()?;
let excluded = self.exclude_simple && !host.contains('.')
|| self
.bypass
.iter()
.any(|pattern| bypass_matches(pattern, host, url.port_or_known_default()));
if excluded != self.reverse_bypass {
return None;
}
self.proxies
.get(url.scheme())
.or_else(|| self.proxies.get("*"))
.map(String::as_str)
}
}
fn env_settings(mut get: impl FnMut(&str) -> Option<String>) -> Option<Settings> {
let mut settings = Settings::default();
for (scheme, lower, upper) in [
("https", "https_proxy", "HTTPS_PROXY"),
("http", "http_proxy", "HTTP_PROXY"),
("*", "all_proxy", "ALL_PROXY"),
] {
if let Some(value) = get(lower).or_else(|| get(upper)) {
settings.proxies.insert(scheme.into(), value);
}
}
(!settings.proxies.is_empty()).then_some(settings)
}
pub fn for_url(url: &Url) -> Result<Option<ureq::Proxy>> {
if let Ok(bypass) = std::env::var("no_proxy").or_else(|_| std::env::var("NO_PROXY")) {
if split_bypass(&bypass).iter().any(|pattern| {
bypass_matches(
pattern,
url.host_str().unwrap_or(""),
url.port_or_known_default(),
)
}) {
return Ok(None);
}
}
let settings = match env_settings(|key| std::env::var(key).ok()) {
Some(settings) => settings,
None => system_settings()?.unwrap_or_default(),
};
settings
.select(url)
.filter(|value| !value.trim().is_empty())
.map(parse_proxy)
.transpose()
}
fn parse_proxy(value: &str) -> Result<ureq::Proxy> {
let value = value.trim();
let value = if value.contains("://") {
value.to_owned()
} else {
format!("http://{value}")
};
let value = value
.strip_prefix("socks5h://")
.map(|rest| format!("socks5://{rest}"))
.unwrap_or(value);
let parsed = Url::parse(&value)?;
ensure!(
matches!(
parsed.scheme(),
"http" | "socks" | "socks4" | "socks4a" | "socks5"
) && parsed.host_str().is_some(),
"系统代理地址无效或协议不受支持"
);
ensure!(
!matches!(parsed.host(), Some(url::Host::Ipv6(_))),
"当前下载客户端暂不支持 IPv6 代理地址,请使用主机名或 IPv4 地址"
);
ureq::Proxy::new(value).map_err(|_| anyhow::anyhow!("系统代理地址无效"))
}
fn split_bypass(value: &str) -> Vec<String> {
value
.split([',', ';'])
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
}
fn bypass_matches(pattern: &str, host: &str, port: Option<u16>) -> bool {
let pattern = pattern.trim().to_ascii_lowercase();
let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
if pattern == "<local>" {
return !host.contains('.');
}
if let Some((network, bits)) = pattern.split_once('/') {
if let (Ok(network), Ok(address), Ok(bits)) = (
network.parse::<IpAddr>(),
host.parse::<IpAddr>(),
bits.parse::<u32>(),
) {
return match (network, address) {
(IpAddr::V4(network), IpAddr::V4(address)) if bits <= 32 => {
bits == 0
|| u32::from(network) >> (32 - bits) == u32::from(address) >> (32 - bits)
}
(IpAddr::V6(network), IpAddr::V6(address)) if bits <= 128 => {
bits == 0
|| u128::from(network) >> (128 - bits)
== u128::from(address) >> (128 - bits)
}
_ => false,
};
}
}
let (pattern, required_port) = match pattern.rsplit_once(':') {
Some((name, number)) if !name.contains(':') => (name, number.parse::<u16>().ok()),
_ => (pattern.as_str(), None),
};
if required_port.is_some() && required_port != port {
return false;
}
let domain = pattern.trim_start_matches('.');
if !pattern.contains('*') {
return host == domain || host.ends_with(&format!(".{domain}"));
}
// Match '*' throughout the hostname, including Windows IP wildcards.
let (pattern, host) = (pattern.as_bytes(), host.as_bytes());
let (mut p, mut h, mut star, mut resume) = (0, 0, None, 0);
while h < host.len() {
if p < pattern.len() && pattern[p] == host[h] {
p += 1;
h += 1;
} else if p < pattern.len() && pattern[p] == b'*' {
star = Some(p);
p += 1;
resume = h;
} else if let Some(index) = star {
p = index + 1;
resume += 1;
h = resume;
} else {
return false;
}
}
while p < pattern.len() && pattern[p] == b'*' {
p += 1;
}
p == pattern.len()
}
const PAC_ERROR: &str = "暂不支持 PAC 自动代理脚本,请配置手动系统代理或 HTTPS_PROXY";
#[cfg(target_os = "windows")]
fn system_settings() -> Result<Option<Settings>> {
use winreg::{
RegKey,
enums::{HKEY_CURRENT_USER, KEY_READ},
};
let Ok(key) = RegKey::predef(HKEY_CURRENT_USER).open_subkey_with_flags(
r"Software\Microsoft\Windows\CurrentVersion\Internet Settings",
KEY_READ,
) else {
return Ok(None);
};
if key.get_value::<u32, _>("ProxyEnable").unwrap_or(0) != 1 {
if key
.get_value::<String, _>("AutoConfigURL")
.is_ok_and(|value| !value.trim().is_empty())
{
bail!(PAC_ERROR);
}
return Ok(None);
}
let Ok(server) = key.get_value::<String, _>("ProxyServer") else {
return Ok(None);
};
let mut settings = parse_windows_servers(&server);
settings.bypass = split_bypass(
&key.get_value::<String, _>("ProxyOverride")
.unwrap_or_default(),
);
settings.exclude_simple = settings.bypass.iter().any(|value| value == "<local>");
Ok(Some(settings))
}
#[cfg(target_os = "macos")]
fn system_settings() -> Result<Option<Settings>> {
let output = std::process::Command::new("/usr/sbin/scutil")
.arg("--proxy")
.output()?;
if !output.status.success() {
return Ok(None);
}
parse_macos(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(any(target_os = "macos", test))]
fn parse_macos(text: &str) -> Result<Option<Settings>> {
let mut values = HashMap::new();
let mut settings = Settings::default();
let (mut depth, mut exceptions) = (0usize, false);
for line in text.lines().map(str::trim) {
if line == "}" {
depth = depth.saturating_sub(1);
if depth == 1 {
exceptions = false;
}
continue;
}
if depth == 1 {
if let Some((key, value)) = line.split_once(" : ") {
if key == "ExceptionsList" {
exceptions = true;
} else {
values.insert(key, value);
}
}
} else if depth == 2 && exceptions {
if let Some((_, value)) = line.split_once(" : ") {
settings.bypass.push(value.into());
}
}
if line.ends_with('{') {
depth += 1;
}
}
for (scheme, name, protocol) in [
("http", "HTTP", "http"),
("https", "HTTPS", "http"),
("*", "SOCKS", "socks5"),
] {
if values.get(format!("{name}Enable").as_str()) == Some(&"1") {
if let (Some(host), Some(port)) = (
values.get(format!("{name}Proxy").as_str()),
values.get(format!("{name}Port").as_str()),
) {
settings
.proxies
.insert(scheme.into(), format!("{protocol}://{host}:{port}"));
}
}
}
settings.exclude_simple = values.get("ExcludeSimpleHostnames") == Some(&"1");
if settings.proxies.is_empty() && values.get("ProxyAutoConfigEnable") == Some(&"1") {
bail!(PAC_ERROR);
}
Ok(Some(settings))
}
#[cfg(any(target_os = "windows", test))]
fn parse_windows_servers(server: &str) -> Settings {
let mut settings = Settings::default();
for entry in server.split(';').map(str::trim).filter(|s| !s.is_empty()) {
let (scheme, value) = entry.split_once('=').unwrap_or(("*", entry));
let scheme = scheme.to_ascii_lowercase();
if scheme == "socks" {
let value = if value.contains("://") {
value.into()
} else {
format!("socks5://{value}")
};
settings.proxies.insert("*".into(), value);
} else {
settings.proxies.insert(scheme, value.into());
}
}
settings
}
#[cfg(target_os = "linux")]
fn system_settings() -> Result<Option<Settings>> {
let kde = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_lowercase()
.contains("kde");
if kde {
if let Some(settings) = kde_settings()? {
return Ok(Some(settings));
}
}
if let Ok(output) = std::process::Command::new("gsettings")
.args(["list-recursively", "org.gnome.system.proxy"])
.output()
{
if output.status.success() {
if let Some(settings) = parse_gnome(&String::from_utf8_lossy(&output.stdout))? {
return Ok(Some(settings));
}
}
}
if !kde {
return kde_settings();
}
Ok(None)
}
#[cfg(target_os = "linux")]
fn kde_settings() -> Result<Option<Settings>> {
let mut paths = Vec::new();
if let Some(config) = std::env::var_os("XDG_CONFIG_HOME") {
paths.push(std::path::PathBuf::from(config));
} else if let Some(home) = std::env::var_os("HOME") {
paths.push(std::path::PathBuf::from(home).join(".config"));
}
paths.extend(std::env::split_paths(
&std::env::var_os("XDG_CONFIG_DIRS").unwrap_or_else(|| "/etc/xdg".into()),
));
for path in paths {
if let Ok(text) = std::fs::read_to_string(path.join("kioslaverc")) {
if let Some(settings) = parse_kde(&text)? {
return Ok(Some(settings));
}
}
}
Ok(None)
}
#[cfg(any(target_os = "linux", test))]
fn unquote(value: &str) -> String {
let value = value.trim();
let quoted = value
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
.or_else(|| value.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
.unwrap_or(value);
quoted
.replace("\\'", "'")
.replace("\\\"", "\"")
.replace("\\\\", "\\")
}
#[cfg(any(target_os = "linux", test))]
fn parse_gnome(text: &str) -> Result<Option<Settings>> {
let mut values = HashMap::new();
for line in text.lines() {
let mut fields = line.splitn(3, ' ');
if let (Some(schema), Some(key), Some(value)) =
(fields.next(), fields.next(), fields.next())
{
values.insert(format!("{schema}.{key}"), value.trim());
}
}
let get = |key: &str| {
values
.get(&format!("org.gnome.system.proxy.{key}"))
.copied()
.unwrap_or("")
};
match unquote(get("mode")).as_str() {
"auto" => bail!(PAC_ERROR),
"manual" => {}
"none" => return Ok(Some(Settings::default())),
_ => return Ok(None),
}
let mut settings = Settings::default();
for (scheme, service) in [("http", "http"), ("https", "https"), ("*", "socks")] {
let host = unquote(get(&format!("{service}.host")));
let port = get(&format!("{service}.port")).parse::<u16>().unwrap_or(0);
if !host.is_empty() && port > 0 {
let protocol = if service == "socks" { "socks5" } else { "http" };
settings
.proxies
.insert(scheme.into(), format!("{protocol}://{host}:{port}"));
}
}
if get("use-same-proxy") == "true" {
if let Some(http) = settings.proxies.get("http").cloned() {
settings.proxies.insert("https".into(), http);
}
}
settings.bypass = get("ignore-hosts")
.trim_matches(['[', ']'])
.split(',')
.map(unquote)
.filter(|s| !s.is_empty())
.collect();
Ok(Some(settings))
}
#[cfg(any(target_os = "linux", test))]
fn parse_kde(text: &str) -> Result<Option<Settings>> {
let mut values = HashMap::new();
let mut proxy_group = false;
for line in text.lines().map(str::trim) {
if line.starts_with('[') {
proxy_group = line == "[Proxy Settings]";
} else if proxy_group {
if let Some((key, value)) = line.split_once('=') {
values.insert(key.trim(), value.trim());
}
}
}
match values.get("ProxyType").copied() {
Some("1") => {}
Some("2" | "3") => bail!(PAC_ERROR),
Some("0") => return Ok(Some(Settings::default())),
_ => return Ok(None),
}
let mut settings = Settings::default();
for (scheme, key) in [
("http", "httpProxy"),
("https", "httpsProxy"),
("*", "socksProxy"),
] {
if let Some(value) = values.get(key).filter(|value| !value.is_empty()) {
// KDE also stores endpoints as "http://host port".
let value = value
.rsplit_once(' ')
.map(|(host, port)| format!("{host}:{port}"))
.unwrap_or_else(|| (*value).into());
let value = if key == "socksProxy" {
value.replace("http://", "socks5://")
} else {
value
};
settings.proxies.insert(scheme.into(), value);
}
}
settings.bypass = split_bypass(values.get("NoProxyFor").copied().unwrap_or(""));
settings.reverse_bypass = values
.get("ReversedException")
.is_some_and(|value| *value == "true");
Ok(Some(settings))
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn system_settings() -> Result<Option<Settings>> {
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn environment_preserves_credentials_and_prefers_scheme_over_all() {
let vars = HashMap::from([
("HTTPS_PROXY", "http://User:PaSS@localhost:8080"),
("ALL_PROXY", "socks5h://localhost:1080"),
]);
let settings = env_settings(|key| vars.get(key).map(|s| s.to_string())).unwrap();
assert_eq!(
settings.select(&Url::parse("https://example.org").unwrap()),
Some("http://User:PaSS@localhost:8080")
);
parse_proxy(
settings
.select(&Url::parse("https://example.org").unwrap())
.unwrap(),
)
.unwrap();
parse_proxy(
settings
.select(&Url::parse("http://example.org").unwrap())
.unwrap(),
)
.unwrap();
assert!(env_settings(|_| None).is_none());
let direct = env_settings(|key| (key == "https_proxy").then(String::new)).unwrap();
assert_eq!(
direct.select(&Url::parse("https://example.org").unwrap()),
Some("")
);
}
#[test]
fn bypass_matches_domains_ports_wildcards_and_subnets() {
assert!(bypass_matches("*foo", "foofoo", Some(443)));
for (rule, host, port) in [
("*", "example.org", 443),
(".example.org", "SUB.EXAMPLE.ORG", 443),
("*.example.org:443", "sub.example.org", 443),
("192.168.*", "192.168.5.67", 443),
("127.0.0.0/8", "127.1.2.3", 443),
("::1", "[::1]", 443),
("<local>", "frame", 443),
] {
assert!(bypass_matches(rule, host, Some(port)), "{rule} / {host}");
}
assert!(!bypass_matches("example.org", "badexample.org", Some(443)));
assert!(!bypass_matches("example.org:80", "example.org", Some(443)));
assert!(!bypass_matches("192.168.0.0/16", "10.1.2.3", Some(443)));
assert!(parse_proxy("file:///tmp/proxy").is_err());
}
#[test]
fn desktop_settings_select_https_or_socks_and_honor_disabled_mode() {
let gnome = "org.gnome.system.proxy mode 'manual'\norg.gnome.system.proxy.https host '127.0.0.1'\norg.gnome.system.proxy.https port 7890\norg.gnome.system.proxy.socks host '127.0.0.1'\norg.gnome.system.proxy.socks port 7891\norg.gnome.system.proxy ignore-hosts ['localhost', '*.example.org']\n";
let settings = parse_gnome(gnome).unwrap().unwrap();
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("http://127.0.0.1:7890")
);
assert!(
settings
.select(&Url::parse("https://sub.example.org").unwrap())
.is_none()
);
assert_eq!(
settings.select(&Url::parse("http://github.com").unwrap()),
Some("socks5://127.0.0.1:7891")
);
assert!(
parse_gnome(&gnome.replace("'manual'", "'none'"))
.unwrap()
.unwrap()
.proxies
.is_empty()
);
assert!(parse_gnome(&gnome.replace("'manual'", "'auto'")).is_err());
let kde = "[Proxy Settings]\nProxyType=1\nhttpsProxy=http://127.0.0.1 7890\nNoProxyFor=localhost,*.example.org\n";
let settings = parse_kde(kde).unwrap().unwrap();
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("http://127.0.0.1:7890")
);
assert!(
settings
.select(&Url::parse("https://sub.example.org").unwrap())
.is_none()
);
assert!(
parse_kde(&kde.replace("ProxyType=1", "ProxyType=0"))
.unwrap()
.unwrap()
.proxies
.is_empty()
);
}
#[test]
fn macos_and_windows_settings_preserve_protocol_and_bypass_rules() {
let text = "<dictionary> {\n HTTPSEnable : 1\n HTTPSProxy : proxy.example.org\n HTTPSPort : 8080\n SOCKSEnable : 1\n SOCKSProxy : localhost\n SOCKSPort : 1080\n ExceptionsList : <array> {\n 0 : *.local\n }\n __SCOPED__ : <dictionary> {\n en0 : <dictionary> {\n HTTPSEnable : 0\n HTTPSProxy : wrong.example.org\n }\n }\n}";
let settings = parse_macos(text).unwrap().unwrap();
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("http://proxy.example.org:8080")
);
assert_eq!(
settings.select(&Url::parse("http://github.com").unwrap()),
Some("socks5://localhost:1080")
);
assert!(
settings
.select(&Url::parse("https://frame.local").unwrap())
.is_none()
);
assert!(parse_macos("<dictionary> {\n ProxyAutoConfigEnable : 1\n}").is_err());
let settings =
parse_windows_servers("http=proxy1:8080;https=proxy2:8081;socks=localhost:1080");
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("proxy2:8081")
);
assert_eq!(
settings.select(&Url::parse("ftp://example.org").unwrap()),
Some("socks5://localhost:1080")
);
let settings = parse_windows_servers("proxy.example.org:8080");
assert_eq!(
settings.select(&Url::parse("https://github.com").unwrap()),
Some("proxy.example.org:8080")
);
}
}
+200 -10
View File
@@ -30,18 +30,22 @@ pub fn validate_repo(repo: &str) -> Result<()> {
);
Ok(())
}
fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
fn agent(url: &url::Url) -> Result<ureq::Agent> {
let mut builder = ureq::AgentBuilder::new()
.try_proxy_from_env(false)
.timeout(Duration::from_secs(120))
.redirects(0)
.build()
.redirects(0);
if let Some(proxy) = crate::proxy::for_url(url)? {
builder = builder.proxy(proxy);
}
Ok(builder.build())
}
fn get(url: &str) -> Result<ureq::Response> {
let agent = agent();
let mut url = url::Url::parse(url)?;
for _ in 0..=5 {
ensure!(url.scheme() == "https", "下载地址必须使用 HTTPS");
let response = agent
// Re-evaluate bypass rules for every redirect target.
let response = agent(&url)?
.get(url.as_str())
.set("User-Agent", "Framely-Installer")
.call()?;
@@ -68,7 +72,11 @@ pub fn list(repo: &str) -> Result<Vec<Release>> {
ensure!(bytes.len() <= 4 * 1024 * 1024, "Release 列表过大");
let items: Vec<Release> = serde_json::from_slice(&bytes)?;
let finished = items.len() < 100;
all.extend(items.into_iter().filter(|r| !r.draft && package(r).is_ok()));
all.extend(
items
.into_iter()
.filter(|r| !r.draft && (package(r).is_ok() || package_for(r, "update").is_ok())),
);
if finished {
return Ok(all);
}
@@ -76,10 +84,26 @@ pub fn list(repo: &str) -> Result<Vec<Release>> {
Ok(all)
}
pub fn package(release: &Release) -> Result<(&Asset, &Asset)> {
package_for(release, "install")
}
pub fn package_for<'a>(release: &'a Release, action: &str) -> Result<(&'a Asset, &'a Asset)> {
let offline = action == "install"
&& release
.assets
.iter()
.any(|a| safe_archive_name(&a.name) && a.name.ends_with("-offline-linux-arm64.tar.gz"));
ensure!(
action != "install"
|| offline
|| !release.assets.iter().any(|a| a.name == "framely-cef.json"),
"首次安装需要包含 CEF 的完整离线包"
);
let packages: Vec<_> = release
.assets
.iter()
.filter(|a| safe_archive_name(&a.name))
.filter(|a| {
safe_archive_name(&a.name) && a.name.ends_with("-offline-linux-arm64.tar.gz") == offline
})
.collect();
ensure!(packages.len() == 1, "该版本缺少唯一的 Framely ARM64 发行包");
let checksum = release
@@ -116,6 +140,13 @@ pub fn expected_hash(checksums: &str, name: &str) -> Result<String> {
matched.context("校验文件中没有该压缩包的条目")
}
pub fn verify(archive: &Path, checksums: &Path) -> Result<()> {
verify_with_progress(archive, checksums, &mut |_| {})
}
pub fn verify_with_progress(
archive: &Path,
checksums: &Path,
report: &mut dyn FnMut(crate::progress::Progress),
) -> Result<()> {
ensure!(
archive.is_file() && checksums.is_file(),
"请选择压缩包及外部 SHA256SUMS"
@@ -137,17 +168,30 @@ pub fn verify(archive: &Path, checksums: &Path) -> Result<()> {
let mut file = File::open(archive)?;
let mut digest = Sha256::new();
let mut block = [0; 256 * 1024];
let mut progress =
crate::progress::Progress::new(crate::progress::Stage::Verify, "校验本地安装包 SHA256");
let total = file.metadata()?.len();
progress.total = Some(total);
report(progress.clone());
let mut last_update = std::time::Instant::now();
loop {
let n = file.read(&mut block)?;
if n == 0 {
break;
}
digest.update(&block[..n]);
progress.completed += n as u64;
if progress.completed < total && last_update.elapsed() >= Duration::from_millis(200) {
report(progress.clone());
last_update = std::time::Instant::now();
}
}
ensure!(
hex::encode(digest.finalize()) == expected,
"SHA256 不匹配,已停止安装"
);
ensure!(progress.completed == total, "校验期间安装包长度发生变化");
report(progress);
Ok(())
}
pub fn download(
@@ -179,6 +223,100 @@ pub fn download(
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn https_requests_use_discovered_desktop_proxy() {
use std::{
io::Write,
net::TcpListener,
os::unix::fs::PermissionsExt,
process::{Command, Stdio},
time::Instant,
};
if std::env::var_os("FRAMELY_PROXY_TEST_CHILD").is_some() {
assert!(get("https://example.invalid/installer-proxy-check").is_err());
return;
}
let work = tempfile::tempdir().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
let mock = work.path().join("gsettings");
std::fs::write(&mock, format!("#!/bin/sh\nprintf '%s\\n' \"org.gnome.system.proxy mode 'manual'\" \"org.gnome.system.proxy.https host '127.0.0.1'\" \"org.gnome.system.proxy.https port {port}\"\n")).unwrap();
std::fs::set_permissions(&mock, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut command = Command::new(std::env::current_exe().unwrap());
command
.args([
"--exact",
"release::tests::https_requests_use_discovered_desktop_proxy",
"--nocapture",
])
.env("FRAMELY_PROXY_TEST_CHILD", "1")
.env("XDG_CURRENT_DESKTOP", "GNOME")
.env("XDG_CONFIG_HOME", work.path())
.env("XDG_CONFIG_DIRS", work.path())
.env(
"PATH",
std::env::join_paths(std::iter::once(work.path().to_path_buf()).chain(
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()),
))
.unwrap(),
)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for key in [
"http_proxy",
"HTTP_PROXY",
"https_proxy",
"HTTPS_PROXY",
"all_proxy",
"ALL_PROXY",
"no_proxy",
"NO_PROXY",
] {
command.env_remove(key);
}
let mut child = command.spawn().unwrap();
let deadline = Instant::now() + Duration::from_secs(8);
let mut connection = loop {
if let Ok((connection, _)) = listener.accept() {
break connection;
}
if Instant::now() >= deadline || child.try_wait().unwrap().is_some() {
let _ = child.kill();
let output = child.wait_with_output().unwrap();
panic!(
"Installer did not connect through detected proxy: {}",
String::from_utf8_lossy(&output.stderr)
);
}
std::thread::sleep(Duration::from_millis(10));
};
connection
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
let mut request = Vec::new();
let mut buffer = [0u8; 1024];
while !request.ends_with(b"\r\n\r\n") {
match connection.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(n) => request.extend_from_slice(&buffer[..n]),
}
}
connection
.write_all(
b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.unwrap();
drop(connection);
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(request.starts_with(b"CONNECT example.invalid:443 HTTP/1.1\r\n"));
}
#[test]
fn checksum_rejects_ambiguity_and_tampering() {
let dir = tempfile::tempdir().unwrap();
@@ -191,9 +329,19 @@ mod tests {
archive.file_name().unwrap().to_str().unwrap()
);
std::fs::write(&sums, &line).unwrap();
verify(&archive, &sums).unwrap();
let mut progress = Vec::new();
verify_with_progress(&archive, &sums, &mut |value| progress.push(value)).unwrap();
assert_eq!(progress.first().unwrap().completed, 0);
assert_eq!(progress.last().unwrap().fraction(), Some(1.0));
assert!(
progress
.iter()
.all(|value| value.stage == crate::progress::Stage::Verify)
);
std::fs::write(&archive, b"changed").unwrap();
assert!(verify(&archive, &sums).is_err());
progress.clear();
assert!(verify_with_progress(&archive, &sums, &mut |value| progress.push(value)).is_err());
assert!(!progress.iter().any(|value| value.fraction() == Some(1.0)));
assert!(
expected_hash(
&(line.clone() + &line),
@@ -234,4 +382,46 @@ mod tests {
release.assets[0].name = "framely-0.4.2-build-linux-arm64.tar.gz".into();
assert!(package(&release).is_ok());
}
#[test]
fn first_install_uses_offline_and_updates_use_core_with_three_archives() {
let mut release = Release {
tag_name: "v0.4.2".into(),
name: None,
published_at: None,
body: None,
prerelease: false,
draft: false,
assets: [
"framely-0.4.2-build-linux-arm64.tar.gz",
"framely-0.4.2-build-offline-linux-arm64.tar.gz",
"framely-cef-154-build-linux-arm64.tar.gz",
"framely-cef.json",
"SHA256SUMS",
]
.into_iter()
.map(|name| Asset {
name: name.into(),
browser_download_url: format!("https://example.org/{name}"),
size: 1,
})
.collect(),
};
assert!(
package_for(&release, "install")
.unwrap()
.0
.name
.ends_with("-offline-linux-arm64.tar.gz")
);
assert_eq!(
package_for(&release, "update").unwrap().0.name,
"framely-0.4.2-build-linux-arm64.tar.gz"
);
release
.assets
.retain(|a| !a.name.ends_with("-offline-linux-arm64.tar.gz"));
assert!(package_for(&release, "install").is_err());
assert!(package_for(&release, "update").is_ok());
}
}
+65
View File
@@ -507,3 +507,68 @@ mod tests {
eprintln!("stages={stages:?}; error={error}");
}
}
/// Collect directly over SSH; neither the web panel nor the daemon is required.
pub fn export_logs(
connection: &Connection,
password: &str,
destination: &Path,
logs: &[String],
) -> Result<()> {
use base64::engine::general_purpose::STANDARD;
ensure!(
!password.is_empty() && !password.contains(['\n', '\r']),
"请输入有效的设备登录密码"
);
connection.session.set_timeout(60_000);
let script = include_str!("../../tools/export-diagnostics.py");
// Keep archive output private and binary-safe. The ordinary text logger
// masks password substrings, which could corrupt a base64 archive.
let mut channel = connection.session.channel_session()?;
let local_log = logs.join("\n").replace(password, "[密码已隐藏]");
let recent: String = local_log
.chars()
.rev()
.take(64 * 1024)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
channel.exec(&format!(
"sudo -S -p '' -- python3 -c {} --installer-log-base64 {}",
quote(script),
quote(&STANDARD.encode(recent))
))?;
channel.write_all(password.as_bytes())?;
channel.write_all(b"\n")?;
channel.flush()?;
channel.send_eof()?;
let mut output = String::new();
Read::by_ref(&mut channel)
.take(12 * 1024 * 1024)
.read_to_string(&mut output)?;
let mut error = String::new();
channel.stderr().take(4096).read_to_string(&mut error)?;
channel.wait_close()?;
ensure!(
channel.exit_status()? == 0,
"日志收集失败:{}",
error.replace(password, "[密码已隐藏]")
);
let archive: serde_json::Value =
serde_json::from_str(output.trim()).context("日志包响应无效")?;
let bytes = STANDARD.decode(archive["data"].as_str().context("日志包缺少数据")?)?;
ensure!(
bytes.len() <= 9 * 1024 * 1024 && bytes.starts_with(b"PK\x03\x04"),
"日志包格式无效"
);
let parent = destination
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let mut file = tempfile::NamedTempFile::new_in(parent).context("无法创建日志包")?;
file.write_all(&bytes).context("无法保存日志包")?;
file.as_file().sync_all()?;
file.persist(destination).context("无法保存日志包")?;
Ok(())
}
+6
View File
@@ -1111,6 +1111,12 @@ impl Render for Installer {
},
)),
);
footer = footer.child(
Button::new("export-diagnostic-logs")
.label("导出日志")
.disabled(self.busy || self.connection.is_none())
.on_click(cx.listener(|view, _, _, cx| view.export_logs(cx))),
);
if self.page < 2 {
let label = if self.page == 0 && self.connection.is_none() {
"先连接设备"
+28 -28
View File
@@ -12,34 +12,34 @@ inline constexpr unsigned char mask[48 * 48] = {
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,255,255,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,255,255,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,255,255,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,255,255,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,0,0,0,0,0,0,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,0,0,0,0,0,0,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,255,255,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,255,255,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,255,255,0,0,0,255,255,255,255,0,0,0,0,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,255,255,0,0,0,255,255,255,255,0,0,0,0,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,0,0,0,0,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,0,0,0,0,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,255,255,255,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,0,0,0,0,0,0,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,255,0,0,0,0,0,0,255,255,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255,0,0,0,0,0,0,0,255,255,255,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
+258 -34
View File
@@ -8,13 +8,21 @@
#include "dock_uv_geometry.h"
#include "menu_geometry.h"
#include "menu_input.h"
#include "launcher_input.h"
#include "launcher_pointer.h"
#include "page_scroll.h"
#include <filesystem>
#include "overlay_placement.h"
#include "render_resolution.h"
#include "render_timing.h"
#include "paint_buffer.h"
#include "notification_geometry.h"
#include "notification_badge.h"
#include "keyboard_state.h"
#include "keyboard_input.h"
#include "brand_mask.h"
#include "ui_visibility.h"
#include "plugin_navigation.h"
#include <GL/gl.h>
#include <GL/glx.h>
#include <arpa/inet.h>
@@ -40,7 +48,27 @@ static vr::IVRSystem* vr_system=nullptr;
static Display* xdisplay=nullptr;
static GLXContext glcontext=nullptr;
static GLXPbuffer glsurface=0;
static std::atomic<unsigned> notification_badge_count{0};
static bool launcher_open=false;
static LauncherDismissGuard launcher_dismiss;
static LauncherDockGuard launcher_dock;
static EntryTrigger launcher_close_button;
static LauncherStick launcher_stick;
static PageScrollStick page_scroll_stick;
static vr::VROverlayHandle_t page_scroll_target=0;
static std::chrono::steady_clock::time_point launcher_outside_at{};
static bool launcher_selected=false;
static std::chrono::steady_clock::time_point launcher_keyboard_back_until{};
static std::atomic<bool> launcher_primary{true};
static bool entry_actions_ready=false;
static vr::VRActionSetHandle_t entry_set=0;
static vr::VRActionHandle_t primary_action=0,upper_action=0,aim_action=0,close_action=0,navigate_action=0;
static vr::VRInputValueHandle_t hands[2]{};
static EntryTrigger entry_buttons[2][2];
static EntryAnalogTrigger entry_analog[2];
static EntryPointer entry_pointer;
static bool menu_open=false,keyboard_open=false,keyboard_close_pending=false;
static float notification_curve_radius=1.3f;
static bool keyboard_toolbar=false,keyboard_chars=false,keyboard_done=false;
static MenuButtonInput menu_button;
static cef_frame_t* keyboard_frame=nullptr;
@@ -59,9 +87,10 @@ template<class T> struct Handler {
};
struct View {
std::string key,plugin,title;int width=600,height=840,render_scale=1;bool dashboard=false,shown=false,dirty=false,closing=false,closed=false;
OverlayPlacement placement;std::chrono::steady_clock::time_point haptic_at{};
bool active_seen=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
vr::VROverlayHandle_t overlay=0,thumbnail=0;GLuint textures[2]{};int texture_slot=0;bool texture_ready[2]{};std::vector<uint8_t> pixels;
OverlayPlacement placement;NotificationClick notification_click;LauncherPointer launcher_pointer;std::chrono::steady_clock::time_point haptic_at{};
cef_mouse_event_t pointer{};bool pointer_valid=false,stick_scrolling=false;vr::TrackedDeviceIndex_t pointer_device=vr::k_unTrackedDeviceIndexInvalid;std::chrono::steady_clock::time_point pointer_at{};
bool active_seen=false,notification_content=false;int browser_id=0;cef_browser_t* browser=nullptr;cef_browser_host_t* host=nullptr;
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 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;}
@@ -82,8 +111,8 @@ static std::vector<uint8_t> icon(bool hover,bool active){
int gx=std::min(47,int((x+.5f)*48/128)),gy=std::min(47,int((y+.5f)*48/128));
float mark=framely_brand::mask[gy*48+gx]/255.f;
int i=(y*128+x)*4;
const int normal[3]={23,25,28},highlight[3]={61,68,80},muted[3]={147,197,237},bright[3]={240,241,243};
for(int c=0;c<3;c++){float bg=(hover||active)?highlight[c]:normal[c];float fg=(hover||active)?bright[c]:muted[c];p[i+c]=uint8_t(bg+(fg-bg)*mark);}
const int normal[3]={23,25,28},highlight[3]={32,35,40},blue[3]={147,197,237};
for(int c=0;c<3;c++){float bg=(hover||active)?highlight[c]:normal[c];p[i+c]=uint8_t(bg+(blue[c]-bg)*mark);}
// 32px-wide, 6px-high tab indicator is clipped to its upper 3px by the BarSurface.
if(active){float qx=std::abs(x-63.5f)-20.8f,qy=std::abs(y-129.6f);float line=std::clamp(5.3f-std::hypot(std::max(qx,0.f),qy),0.f,1.f);const int blue[3]={26,159,255};for(int c=0;c<3;c++)p[i+c]=uint8_t(p[i+c]+(blue[c]-p[i+c])*line);}
p[i+3]=uint8_t(255*coverage);
@@ -99,7 +128,7 @@ static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)thr
// Keep a wider curved mesh around the narrow button; transparent margins never hit input.
static constexpr int dock_canvas_width=640,dock_canvas_height=128,dock_icon_left=256;
static std::vector<uint8_t> dock_icon(bool hover,bool active){
auto source=icon(hover,active);std::vector<uint8_t> canvas(dock_canvas_width*dock_canvas_height*4,0);
auto source=icon(hover,active);notification_badge(source,notification_badge_count.load());std::vector<uint8_t> canvas(dock_canvas_width*dock_canvas_height*4,0);
for(int row=0;row<dock_canvas_height;row++)std::copy_n(source.data()+row*128*4,128*4,canvas.data()+(row*dock_canvas_width+dock_icon_left)*4);
return canvas;
}
@@ -118,8 +147,31 @@ static void laser_cursor(vr::VROverlayHandle_t overlay,const vr::VREvent_t& even
if(event.eventType==vr::VREvent_MouseMove){vr::HmdVector2_t position{{event.data.mouse.x,event.data.mouse.y}};overlays->SetOverlayCursorPositionOverride(overlay,&position);}
else if(event.eventType==vr::VREvent_FocusLeave)overlays->ClearOverlayCursorPositionOverride(overlay);
}
static void show(View& v,bool shown){if(v.closing)return;if(v.key=="notifications")overlays->SetOverlayInputMethod(v.overlay,shown?vr::VROverlayInputMethod_Mouse:vr::VROverlayInputMethod_None);if(v.key=="menu"&&shown!=v.shown)overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,shown&&!(keyboard_open&&keyboard_view=="menu"));bool actual=overlays->IsOverlayVisible(v.overlay);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.host->was_hidden(v.host,!shown);v.shown=shown;if(shown)v.host->invalidate(v.host,PET_VIEW);}}
static void close_keyboard(bool hide=true){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"&&views.count("menu")&&!views.at("menu")->closing)overlays->SetOverlayFlag(views.at("menu")->overlay,vr::VROverlayFlags_WantsModalBehavior,views.at("menu")->shown);if(keyboard_frame){keyboard_frame->base.release(&keyboard_frame->base);keyboard_frame=nullptr;}keyboard_view.clear();}
static void update_menu_modal(){
if(!views.count("menu"))return;
auto& menu=*views.at("menu");
bool toast=views.count("notifications")&&views.at("notifications")->shown&&!views.at("notifications")->closing;
// A modal quick menu must not monopolize the laser while a toast needs input.
overlays->SetOverlayFlag(menu.overlay,vr::VROverlayFlags_WantsModalBehavior,menu.shown&&!toast&&!(keyboard_open&&keyboard_view=="menu"));
}
static void show(View& v,bool shown){
if(v.closing)return;
bool actual=overlays->IsOverlayVisible(v.overlay);
if(v.key=="notifications"&&(shown!=v.shown||shown!=actual)){
auto e=overlays->SetOverlayInputMethod(v.overlay,shown?vr::VROverlayInputMethod_Mouse:vr::VROverlayInputMethod_None);
if(!e)e=overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,shown);
if(e){std::cerr<<"Notification input state failed "<<e<<"\n";shown=false;overlays->SetOverlayInputMethod(v.overlay,vr::VROverlayInputMethod_None);}
}
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);
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 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){
@@ -130,7 +182,7 @@ static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_o
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");return 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;
if(!keyboard_open)return true;
@@ -144,24 +196,39 @@ static bool keyboard_event(const vr::VREvent_t& e,vr::VROverlayHandle_t source_o
}
static void close_view(View& v){if(v.closing)return;if(keyboard_view==v.key)close_keyboard();v.closing=true;if(v.key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);overlays->ClearOverlayCursorPositionOverride(v.overlay);overlays->HideOverlay(v.overlay);v.host->close_browser(v.host,1);}
static void 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_notification(View& v){
// Let the compositor follow the HMD's render pose; a captured standing-space
// transform leaves the toast behind when the user turns or moves their head.
vr::HmdMatrix34_t head{{{1,0,0,0},{0,1,0,0},{0,0,1,0}}};
NotificationPlacement toast{};
if(!notification_placement(head,notification_curve_radius,toast))return;
auto error=overlays->SetOverlayWidthInMeters(v.overlay,toast.width);
if(!error)error=overlays->SetOverlayTransformTrackedDeviceRelative(v.overlay,vr::k_unTrackedDeviceIndex_Hmd,&toast.transform);
if(error){std::cerr<<"Notification head attachment failed "<<error<<"\n";return;}
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){
if(views.count(key)){auto& v=*views.at(key);if(dock)overlays->ShowDashboard(key.c_str());else if(key!="notifications")show(v,true);return v;}
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=dock;v->width=width;v->height=height;v->render_scale=key=="framely.manager"?4:(key=="menu"||key=="notifications")?2:1;
if(views.count(key)){auto& v=*views.at(key);if(dock&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="notifications"&&key!="launcher")show(v,true);return v;}
// Use 3x 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=dock;v->width=width;v->height=height;v->render_scale=key=="launcher"?3:(key=="framely.manager"||key=="menu"||key=="notifications")?2:1;
auto error=dock?overlays->CreateDashboardOverlay(key.c_str(),title.c_str(),&v->overlay,&v->thumbnail):overlays->CreateOverlay(key.c_str(),title.c_str(),&v->overlay);if(error)throw std::runtime_error("Overlay creation failed: "+std::to_string(error));
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(v->overlay,key=="notifications"?100:0);
overlays->SetOverlayInputMethod(v->overlay,key=="notifications"?vr::VROverlayInputMethod_None:vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{float(width),float(height)}};overlays->SetOverlayMouseScale(v->overlay,&scale);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_HideLaserIntersection,false);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SendVRSmoothScrollEvents,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(v->overlay,0);
// Keep the runtime's full dashboard controls, including its keyboard and close buttons.
if(dock){
auto result=overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_MinimalControlBar,false);
if(key=="launcher")overlays->SetOverlayFlag(v->overlay,vr::VROverlayFlags_NoDashboardTab,true);
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);
if(result)std::cerr<<"Dashboard control unavailable "<<key<<" "<<result<<"\n";
}
}
if(dock){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:1.2f));}else if(key!="menu"&&key!="notifications"){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;cef_browser_settings_t bs{};bs.size=sizeof(bs);bs.windowless_frame_rate=30;bs.background_color=0;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(dock)overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications")show(*ptr,true);return *ptr;
if(dock){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);}
cef_window_info_t info{};info.size=sizeof(info);info.windowless_rendering_enabled=1;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(dock&&key!="launcher")overlays->ShowDashboard(key.c_str());else if(key!="menu"&&key!="notifications"&&key!="launcher")show(*ptr,true);return *ptr;
}
static void plugin_thumbnail(View& view,const std::string& encoded){
if(!view.thumbnail||encoded.empty()||encoded.size()>1400000)return;
@@ -177,9 +244,133 @@ static void plugin_thumbnail(View& view,const std::string& encoded){
for(int y=0;y<h;y++)for(int x=0;x<w;x++){int sx=std::min(width-1,int(x/scale)),sy=std::min(height-1,int(y/scale));std::copy_n(source.data()+(sy*width+sx)*4,4,pixels.data()+((y+(128-h)/2)*128+x+(128-w)/2)*4);}
overlays->SetOverlayRaw(view.thumbnail,pixels.data(),128,128,4);
}
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=="haptic"){auto key=c.value("view","");if(views.count(key)&&views.at(key)->shown&&!views.at(key)->closing)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){if(views.count("framely.manager")&&views.at("framely.manager")->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}create_view("framely.manager","Framely 插件管理",origin+"/manager",true,1440,810);menu_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);anchor_to_head(v,.42f,.42f,.32f,-.8f);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications"))show(*views.at("notifications"),false);}}catch(const std::exception& e){std::cerr<<"Native command error: "<<e.what()<<"\n";}}
static void navigate(View& view,const std::string& hash){auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"location.hash="+json(hash).dump()+";window.dispatchEvent(new Event('hashchange'))");frame->base.release(&frame->base);}
static bool launcher_dock_interaction(){
// These are Steam's Dock bar and native window controls on Frame, not app
// content. The runtime keeps ownership of their positioning and interaction.
bool hovered=false;
for(const char* key:{"valve.steam.gamepadui.bar","valve.steam.gamepadui.floatingfooter","system.systemui"}){
vr::VROverlayHandle_t overlay=0;
if(!overlays->FindOverlay(key,&overlay)&&overlays->IsOverlayVisible(overlay)&&overlays->IsHoverTargetOverlay(overlay))hovered=true;
}
bool held=false;for(auto& hand:entry_buttons)for(auto& trigger:hand)held|=trigger.held;
auto now=std::chrono::steady_clock::now();launcher_dock.observe(hovered,held,now);
if(launcher_dock.holds(now)){launcher_outside_at={};return true;}
return false;
}
static void overlay_input(View& v,const vr::VREvent_t& e){if(v.closing||(v.key=="notifications"&&!v.shown))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);switch(e.eventType){case vr::VREvent_Modal_Cancel: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!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){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:v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove: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;mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:mouse.x=v.width/2;mouse.y=v.height/2;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications")close_view(v);break;default:break;}}
static void hide_launcher(){
if(keyboard_view=="launcher")close_keyboard();
launcher_open=false;launcher_selected=false;launcher_dock.clear();
if(views.count("launcher")&&overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay))
overlays->ShowDashboard("valve.steam.gamepadui.main");
}
static void launcher_back(){
auto now=std::chrono::steady_clock::now();if(now<launcher_keyboard_back_until)return;
if(keyboard_open){launcher_keyboard_back_until=now+std::chrono::milliseconds(180);close_keyboard();return;}
if(!launcher_open||!views.count("launcher"))return;
auto& view=*views.at("launcher");auto* frame=view.browser->get_main_frame(view.browser);
execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);
}
static bool launcher_accepts(const vr::VREvent_t& e){
bool held=false;for(auto& hand:entry_buttons)for(auto& button:hand)held|=button.held;
return launcher_dismiss.allows(std::chrono::steady_clock::now(),held,e.eventAgeSeconds);
}
static void toggle_entry(bool launcher){
if(launcher){
menu_open=false;if(views.count("menu"))show(*views.at("menu"),false);
if(launcher_open){hide_launcher();return;}
auto& view=create_view("launcher","Framely launcher",origin+"/launcher",true,1440,800);
vr::VREvent_t stale{};while(overlays->PollNextOverlayEvent(view.overlay,&stale,sizeof(stale))){}
launcher_dock.clear();launcher_dismiss.opened(std::chrono::steady_clock::now());launcher_open=true;launcher_selected=false;launcher_stick={};launcher_outside_at={};
overlays->ShowDashboard("launcher");
auto* frame=view.browser->get_main_frame(view.browser);execute(frame,"window.dispatchEvent(new Event('framely.launcher.open'))");frame->base.release(&frame->base);
}else{hide_launcher();menu_open=!menu_open;if(menu_open)menu_button.opened(std::chrono::steady_clock::now());}
}
static void setup_entry_actions(){
char exe[4096]{};auto n=readlink("/proc/self/exe",exe,sizeof(exe)-1);if(n<=0)return;
auto base=std::filesystem::path(exe).parent_path();auto manifest=base/"../../share/input/actions.json";
if(!std::filesystem::exists(manifest))manifest=base/"../../native/input/actions.json";
auto* input=vr::VRInput();if(!input||!std::filesystem::exists(manifest))return;
auto error=input->SetActionManifestPath(std::filesystem::absolute(manifest).lexically_normal().c_str());if(error!=vr::VRInputError_None){std::cerr<<"Action manifest failed: "<<error<<std::endl;return;}
entry_actions_ready=input->GetActionSetHandle("/actions/entry",&entry_set)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/primary",&primary_action)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/upper",&upper_action)==vr::VRInputError_None&&input->GetActionHandle("/actions/entry/in/aim",&aim_action)==vr::VRInputError_None;
input->GetActionHandle("/actions/entry/in/close",&close_action);input->GetActionHandle("/actions/entry/in/navigate",&navigate_action);
input->GetInputSourceHandle("/user/hand/left",&hands[0]);input->GetInputSourceHandle("/user/hand/right",&hands[1]);
std::cout<<"Framely independent trigger bindings "<<(entry_actions_ready?"ready":"unavailable")<<std::endl;
}
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(!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;}
if(page_scroll_target!=target->overlay){page_scroll_stick.clear();page_scroll_target=target->overlay;}
target->stick_scrolling=active&&std::isfinite(x)&&std::isfinite(y)&&std::max(std::abs(x),std::abs(y))>.18f;
auto delta=page_scroll_stick.update(x,y,target->stick_scrolling,now);
if(delta.x||delta.y){auto mouse=target->pointer;mouse.modifiers=0;target->host->send_mouse_wheel_event(target->host,&mouse,delta.x,delta.y);}
}
static void entry_actions(vr::VROverlayHandle_t button,bool available){
if(!entry_actions_ready)return;auto* input=vr::VRInput();vr::VRActiveActionSet_t set{};set.ulActionSet=entry_set;
if(input->UpdateActionState(&set,sizeof(set),1)!=vr::VRInputError_None)return;
auto now=std::chrono::steady_clock::now();
float stick_x=0,stick_y=0;bool stick_active=false;vr::TrackedDeviceIndex_t stick_device=vr::k_unTrackedDeviceIndexInvalid;
for(int hand=0;hand<2;hand++){
vr::InputPoseActionData_t pose{};auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);bool hit=available&&entry_pointer.hit(device);
bool pose_ok=available&&input->GetPoseActionDataRelativeToNow(aim_action,vr::TrackingUniverseStanding,0,&pose,sizeof(pose),hands[hand])==vr::VRInputError_None&&pose.bActive&&pose.pose.bPoseIsValid;
if(available&&!pose_ok&&device!=vr::k_unTrackedDeviceIndexInvalid){
vr::TrackedDevicePose_t tracked[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,tracked,vr::k_unMaxTrackedDeviceCount);
char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));
vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t tip{};
if(tracked[device].bDeviceIsConnected&&tracked[device].bPoseIsValid&&vr::VRRenderModels()->GetComponentStateForDevicePath(model,"tip",hands[hand],&mode,&tip)){
pose.pose.mDeviceToAbsoluteTracking=entry_tip_pose(tracked[device].mDeviceToAbsoluteTracking,tip.mTrackingToComponentLocal);pose_ok=true;
}
}
if(pose_ok){auto& m=pose.pose.mDeviceToAbsoluteTracking;vr::VROverlayIntersectionParams_t params{};params.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){params.vSource.v[r]=m.m[r][3];params.vDirection.v[r]=-m.m[r][2];}vr::VROverlayIntersectionResults_t result{};hit=hit||(overlays->IsHoverTargetOverlay(button)&&overlays->ComputeOverlayIntersection(button,&params,&result)&&result.vUVs.v[0]>=float(dock_icon_left)/dock_canvas_width&&result.vUVs.v[0]<=float(dock_icon_left+128)/dock_canvas_width);}
for(int kind=0;kind<2;kind++){vr::InputDigitalActionData_t data{};bool active=input->GetDigitalActionData(kind?upper_action:primary_action,&data,sizeof(data),hands[hand])==vr::VRInputError_None&&data.bActive;
bool down=data.bState;if(kind==0&&active)entry_analog[hand].held=down;
// Dashboard focus suppresses action data. Frame's render-model components
// read the separate physical input paths, without changing Steam bindings
// or the user's global input-override setting.
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){
char type[256]{},model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_ControllerType_String,type,sizeof(type));
if(std::string(type)=="frame_controller"){
vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));
vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};
active=vr::VRRenderModels()->GetComponentStateForDevicePath(model,kind?"bumper":"trigger",hands[hand],&mode,&state);
down=active&&(state.uProperties&vr::VRComponentProperty_IsPressed);
if(active&&kind==0){
// Frame's primary trigger is analog: its animated component has no
// IsPressed flag. Calibrate its travel from the model's legacy axis 1,
// then match Steam's 50% press / 45% release thresholds.
vr::VRControllerState_t rest_input{},full_input{};full_input.rAxis[1].x=1;
vr::RenderModel_ComponentState_t rest{},full{};float pull=0;
if(vr::VRRenderModels()->GetComponentState(model,"trigger",&rest_input,&mode,&rest)&&vr::VRRenderModels()->GetComponentState(model,"trigger",&full_input,&mode,&full)&&entry_trigger_pull(rest,full,state,pull))down=entry_analog[hand].update(pull)||down;
}
}
}
if(kind==0&&!active)entry_analog[hand].held=false;
bool was_held=entry_buttons[hand][kind].held;
if(entry_buttons[hand][kind].update(active,down,hit,now))toggle_entry(kind==0?launcher_primary.load():!launcher_primary.load());
else if(kind==0&&active&&down&&!was_held&&launcher_open&&!hit&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
}
if(launcher_open&&hand==1){vr::InputDigitalActionData_t close{};bool active=input->GetDigitalActionData(close_action,&close,sizeof(close),hands[hand])==vr::VRInputError_None&&close.bActive;bool down=close.bState;
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};active=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_b",hands[hand],&mode,&state);down=active&&(state.uProperties&vr::VRComponentProperty_IsPressed);}
if(launcher_close_button.update(active,down,true,now))launcher_back();
}
vr::InputAnalogActionData_t axis{};bool active=input->GetAnalogActionData(navigate_action,&axis,sizeof(axis),hands[hand])==vr::VRInputError_None&&axis.bActive;float x=axis.x,y=axis.y;
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t 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();active=models->GetComponentStateForDevicePath(model,"thumbstick",hands[hand],&mode,&current)&&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);}
// Pick a single controller and a single axis; never emit two directions.
if(active&&(!stick_active||std::max(std::abs(x),std::abs(y))>std::max(std::abs(stick_x),std::abs(stick_y)))){stick_active=true;stick_x=x;stick_y=y;stick_device=device;}
}
if(launcher_open){int direction=launcher_stick.update(keyboard_open?0:stick_x,keyboard_open?0:stick_y,!keyboard_open&&stick_active);if(direction&&views.count("launcher")){auto* frame=views.at("launcher")->browser->get_main_frame(views.at("launcher")->browser);execute(frame,"window.dispatchEvent(new CustomEvent('framely.launcher.navigate',{detail:"+std::to_string(direction)+"}))");frame->base.release(&frame->base);}}
else{launcher_stick={};launcher_close_button={};}
page_scroll(stick_x,stick_y,stick_active,stick_device,now);
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=="launcher.close"){hide_launcher();}else if(kind=="menu.open"){hide_launcher();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)hover_haptic(views.at(key)->overlay,views.at(key)->haptic_at);}else if(kind=="manager.open"){hide_launcher();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"+((page=="catalog"||page=="settings"||page=="updates"||page=="notification-settings"||page=="launcher-settings"||page.rfind("plugin:",0)==0)?"#"+page:""),true,1440,810);if(page.rfind("plugin:",0)==0)navigate(manager,"#"+page);if(page=="catalog"||page=="settings"||page=="updates"||page=="notification-settings"||page=="launcher-settings"){auto* frame=manager.browser->get_main_frame(manager.browser);execute(frame,"location.hash="+json(page).dump()+";window.dispatchEvent(new Event(\"hashchange\"))");frame->base.release(&frame->base);}menu_open=false;launcher_open=false;}else if(kind=="agreement.decline"){auto key=c.value("view","");if(key=="menu"){menu_open=false;if(views.count("menu")){show(*views.at("menu"),false);views.at("menu")->browser->reload(views.at("menu")->browser);}}else if(views.count(key))close_view(*views.at(key));}else if(kind=="manager.close"){if(views.count("framely.manager"))close_view(*views.at("framely.manager"));}else if(kind=="menu.close"){menu_open=false;}else if(kind=="window.open"){hide_launcher();std::string plugin=c.at("plugin"),win=c.at("window");auto key="framely.window."+plugin+"."+win;if(views.count(key)&&views.at(key)->closing){std::lock_guard<std::mutex> lock(queue_mutex);queue.push_back(c);continue;}auto spec=c.at("spec");auto& opened=create_view("framely.window."+plugin+"."+win,spec.at("title"),origin+"/window/"+plugin+"/"+win,spec.value("dockIcon",false),spec.value("width",1600),spec.value("height",900),plugin,spec.value("widthMeters",3.f));plugin_thumbnail(opened,spec.value("iconData",std::string{}));menu_open=false;launcher_open=false;}else if(kind=="window.close"){auto key="framely.window."+c.at("plugin").get<std::string>()+"."+c.at("window").get<std::string>();if(views.count(key))close_view(*views.at(key));}else if(kind=="plugin.disabled"){for(auto&[key,v]:views)if(v->plugin==c.at("plugin"))close_view(*v);}else if(kind=="notification.changed"){auto& v=create_view("notifications","Framely notifications",origin+"/notifications",false,600,360);anchor_notification(v);v.host->was_hidden(v.host,0);v.host->invalidate(v.host,PET_VIEW);}else if(kind=="keyboard"){auto key=c.value("view","menu");if(!views.count(key))continue;auto& v=*views.at(key);if(v.closing)continue;close_keyboard();v.host->set_focus(v.host,1);keyboard_frame=v.browser->get_focused_frame(v.browser);keyboard_view=key;keyboard_target=v.overlay;++keyboard_token;auto existing=c.value("existing","");if(key=="menu")overlays->SetOverlayFlag(v.overlay,vr::VROverlayFlags_WantsModalBehavior,false);auto e=overlays->ShowKeyboardForOverlay(v.overlay,c.value("password",false)?vr::k_EGamepadTextInputModePassword:vr::k_EGamepadTextInputModeNormal,c.value("multiline",false)?vr::k_EGamepadTextInputLineModeMultipleLines:vr::k_EGamepadTextInputLineModeSingleLine,vr::KeyboardFlag_Modal|vr::KeyboardFlag_Minimal|vr::KeyboardFlag_ShowArrowKeys,"Framely",4096,existing.c_str(),keyboard_token);keyboard_open=e==vr::VROverlayError_None;keyboard_menu.begin(keyboard_open&&key=="menu");keyboard_state(keyboard_open);if(!keyboard_open)close_keyboard();}else if(kind=="notifications.empty"){if(views.count("notifications")){views.at("notifications")->notification_content=false;show(*views.at("notifications"),false);}}}catch(const std::exception& e){std::cerr<<"Native command error: "<<e.what()<<"\n";}}
}
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_accepts(e)&&(e.eventType==vr::VREvent_Modal_Cancel||e.eventType==vr::VREvent_ButtonPress||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp))return;if(keyboard_event(e,v.overlay))return;laser_cursor(v.overlay,e);cef_mouse_event_t mouse{};mouse.x=int(e.data.mouse.x);mouse.y=v.height-int(e.data.mouse.y);if(e.eventType==vr::VREvent_MouseMove||e.eventType==vr::VREvent_MouseButtonDown||e.eventType==vr::VREvent_MouseButtonUp){v.pointer=mouse;v.pointer_valid=true;v.pointer_device=e.trackedDeviceIndex;v.pointer_at=std::chrono::steady_clock::now();}switch(e.eventType){case vr::VREvent_Modal_Cancel:if(v.key=="launcher"&&!keyboard_open&&!launcher_dock_interaction())launcher_back();else if(v.key=="menu"&&!keyboard_open&&!keyboard_menu.holds(std::chrono::steady_clock::now())&&menu_button.can_cancel(std::chrono::steady_clock::now())){menu_open=false;std::cout<<"Menu closed: outside click"<<std::endl;}break;case vr::VREvent_OverlayClosed:if(v.key=="launcher")hide_launcher();else if(v.key!="menu"&&v.key!="notifications")close_view(v);break;case vr::VREvent_ButtonPress:if(e.data.controller.button==vr::k_EButton_ApplicationMenu){if(v.key=="launcher"){launcher_back();break;}auto* frame=v.browser->get_main_frame(v.browser);execute(frame,"window.dispatchEvent(new Event('framely.back'))");frame->base.release(&frame->base);}break;case vr::VREvent_FocusLeave:if(v.key=="launcher"&&v.launcher_pointer.holding(std::chrono::steady_clock::now()))break;if(v.key!="notifications"||!v.notification_click.active)v.host->send_mouse_move_event(v.host,&mouse,1);break;case vr::VREvent_MouseMove:if(v.key=="launcher")v.launcher_pointer.constrain(mouse.x,mouse.y,std::chrono::steady_clock::now());if(v.key!="notifications"||!v.notification_click.hold(mouse.x,mouse.y,std::chrono::steady_clock::now()))v.host->send_mouse_move_event(v.host,&mouse,0);break;case vr::VREvent_MouseButtonDown:case vr::VREvent_MouseButtonUp:{v.host->set_focus(v.host,1);auto button=e.data.mouse.button==vr::VRMouseButton_Right?MBT_RIGHT:MBT_LEFT;if(v.key=="launcher"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.launcher_pointer.begin(mouse.x,mouse.y,now);else{v.launcher_pointer.constrain(mouse.x,mouse.y,now);v.launcher_pointer.clear();}}if(v.key=="notifications"&&button==MBT_LEFT){auto now=std::chrono::steady_clock::now();if(e.eventType==vr::VREvent_MouseButtonDown)v.notification_click.begin(mouse.x,mouse.y,now);else v.notification_click.release(mouse.x,mouse.y,now);}mouse.modifiers=e.eventType==vr::VREvent_MouseButtonDown?EVENTFLAG_LEFT_MOUSE_BUTTON:0;v.host->send_mouse_click_event(v.host,&mouse,button,e.eventType==vr::VREvent_MouseButtonUp,1);break;}case vr::VREvent_ScrollSmooth:case vr::VREvent_ScrollDiscrete:if(v.stick_scrolling||keyboard_open||!v.pointer_valid)break;mouse=v.pointer;mouse.modifiers=0;v.host->send_mouse_wheel_event(v.host,&mouse,int(e.data.scroll.xdelta*120),int(e.data.scroll.ydelta*120));break;case vr::VREvent_OverlayGamepadFocusLost:if(!keyboard_open&&!v.dashboard&&v.key!="menu"&&v.key!="notifications"&&v.key!="launcher")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];}
@@ -193,22 +384,22 @@ 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();
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();
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};};
render_handler.api.get_screen_info=[](cef_render_handler_t*,cef_browser_t* b,cef_screen_info_t* info){auto* v=find_browser(b);return render_screen_info(info,v?v->width:600,v?v->height:840,v?v->render_scale:1);};
render_handler.api.on_paint=[](cef_render_handler_t*,cef_browser_t* b,cef_paint_element_type_t type,size_t,const cef_rect_t*,const void* data,int w,int h){auto* v=find_browser(b);if(!v||v->closing||type!=PET_VIEW||w!=v->width*v->render_scale||h!=v->height*v->render_scale)return;v->pixels.resize(w*h*4);auto* source=static_cast<const uint8_t*>(data);for(int y=0;y<h;y++)std::copy_n(source+y*w*4,w*4,v->pixels.data()+(h-1-y)*w*4);v->dirty=true;};
render_handler.api.on_paint=[](cef_render_handler_t*,cef_browser_t* b,cef_paint_element_type_t type,size_t dirty_count,const cef_rect_t* dirty,const void* data,int w,int h){auto* v=find_browser(b);if(!v||v->closing||type!=PET_VIEW||w!=v->width*v->render_scale||h!=v->height*v->render_scale)return;merge_paint(v->pixels,data,w,h,dirty_count,dirty,true);for(auto& damage:v->texture_damage)damage.add(w,h,dirty_count,dirty);v->dirty=true;};
life_handler.api.on_before_popup=[](cef_life_span_handler_t*,cef_browser_t*,cef_frame_t*,int,const cef_string_t*,const cef_string_t*,cef_window_open_disposition_t,int,const cef_popup_features_t*,cef_window_info_t*,cef_client_t**,cef_browser_settings_t*,cef_dictionary_value_t**,int*){return 1;};
life_handler.api.on_before_close=[](cef_life_span_handler_t*,cef_browser_t* b){auto* v=find_browser(b);if(v){v->closed=true;v->host->base.release(&v->host->base);v->host=nullptr;v->browser->base.release(&v->browser->base);v->browser=nullptr;}};
display_handler.api.on_console_message=[](cef_display_handler_t*,cef_browser_t* b,cef_log_severity_t,const cef_string_t* message,const cef_string_t*,int){auto* v=find_browser(b);std::cout<<"UI ["<<(v?v->key:"loading")<<"] "<<text(message)<<std::endl;return 1;};
request_handler.api.on_before_browse=[](cef_request_handler_t*,cef_browser_t*,cef_frame_t*,cef_request_t* r,int,int){auto* uri=r->get_url(r);auto s=text(uri);cef_string_userfree_utf16_free(uri);return int(s.rfind(origin+"/",0)!=0&&s!="about:blank");};
request_handler.api.on_render_process_terminated=[](cef_request_handler_t*,cef_browser_t* b,cef_termination_status_t,int,const cef_string_t*){auto* v=find_browser(b);if(v){std::cerr<<"Renderer crashed: "<<v->key<<"\n";close_view(*v);if(v->key=="menu")running=0;}};
request_handler.api.on_before_browse=[](cef_request_handler_t*,cef_browser_t* b,cef_frame_t* f,cef_request_t* r,int,int){auto* uri=r->get_url(r);auto s=text(uri);cef_string_userfree_utf16_free(uri);auto* v=find_browser(b);return int(!allow_plugin_navigation(s,origin,!f||f->is_main(f),v&&!v->plugin.empty()));};
request_handler.api.on_render_process_terminated=[](cef_request_handler_t*,cef_browser_t* b,cef_termination_status_t,int,const cef_string_t*){auto* v=find_browser(b);if(v){std::cerr<<"Renderer crashed: "<<v->key<<"\n";close_view(*v);if(v->key=="menu")running=0;if(v->key=="launcher")launcher_open=false;}};
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);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);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);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;}
std::thread poller([port]{int failures=0;while(running){try{auto data=poll_http(port);std::lock_guard<std::mutex> lock(queue_mutex);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));}});
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;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{};
std::thread poller([port]{int failures=0;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_primary.store(data.value("launcher",json::object()).value("primaryTrigger",true));std::lock_guard<std::mutex> lock(queue_mutex);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));}});
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}keyboard_event(global);}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.
@@ -218,40 +409,63 @@ int main(int argc,char** argv){
vr::HmdMatrix34_t frame{};bool frame_ok=visible&&fresh&&!overlays->GetTransformForOverlayCoordinates(dock,vr::TrackingUniverseStanding,{{mouse_scale.v[0]*.5f,mouse_scale.v[1]*.5f}},&center)&&dock_rigid_frame(center,frame);
auto sample_now=std::chrono::steady_clock::now();
if(frame_ok&&sample_now-sampled_at>=std::chrono::milliseconds(100)){sampled_at=sample_now;anchor_ok=dock_probe_origin(center,head)&&dock_uv_anchor(overlays,dock,center,head,width,height,anchor,evidence,size,uv);}
auto now=std::chrono::steady_clock::now();if(anchor_ok){saved_anchor=anchor;saved_frame=frame;saved_size=size;saved_evidence=evidence;anchored_at=now;}else if(frame_ok&&now-anchored_at<std::chrono::milliseconds(350)){anchor=dock_follow_pose(saved_anchor,saved_frame,frame);size=saved_size;evidence=saved_evidence;anchor_ok=true;}
auto now=std::chrono::steady_clock::now();if(anchor_ok){if(evidence.radius>0)notification_curve_radius=evidence.radius;saved_anchor=anchor;saved_frame=frame;saved_size=size;saved_evidence=evidence;anchored_at=now;}else if(frame_ok&&now-anchored_at<std::chrono::milliseconds(350)){anchor=dock_follow_pose(saved_anchor,saved_frame,frame);size=saved_size;evidence=saved_evidence;anchor_ok=true;}
if(keyboard_menu.holds(now)){if(views.count("menu"))show(*views.at("menu"),menu_open);}else if(anchor_ok){// Apply the button tilt in its pose: nonzero API pre-curve pitch hid it on Frame.
auto icon_pose=anchor;float cp=std::cos(evidence.pre_curve_pitch),sp=std::sin(evidence.pre_curve_pitch);for(int r=0;r<3;r++){icon_pose.m[r][1]=anchor.m[r][1]*cp+anchor.m[r][2]*sp;icon_pose.m[r][2]=anchor.m[r][2]*cp-anchor.m[r][1]*sp;}button_placement.apply(overlays,button,icon_pose,size*5);button_placement.curve(overlays,button,size*5,evidence.radius,0);if(!overlays->IsOverlayVisible(button))overlays->ShowOverlay(button);if(views.count("menu")){MenuPlacement popup{};auto& v=*views.at("menu");vr::HmdMatrix34_t main_pose{};vr::VROverlayHandle_t main_overlay=0;vr::HmdVector2_t main_scale{};const vr::HmdMatrix34_t* main=nullptr;
for(auto&[key,view]:views)if(view->dashboard&&view->shown&&!view->closing)main_overlay=view->overlay;
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;for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications")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())launcher_open=false;for(auto&[key,v]:views)if(!v->dashboard&&key!="menu"&&key!="notifications"&&key!="launcher")close_view(*v);}was_visible=visible;
// The runtime owns dashboard placement, curvature, visibility and switching.
// Never move the launcher into standing space or hide another app's overlay.
if(launcher_open)launcher_dock_interaction();
if(views.count("launcher")){
bool selected=overlays->IsActiveDashboardOverlay(views.at("launcher")->overlay);
if(launcher_open&&selected)launcher_selected=true;
if(launcher_open&&!selected&&overlays->IsDashboardVisible()&&!launcher_dock_interaction()&&(launcher_selected||now-launcher_dismiss.opened_at>std::chrono::seconds(1)))launcher_open=false;
}
// Toggle once for a matched left click; never activate on a stale release.
vr::VREvent_t event{};while(overlays->PollNextOverlayEvent(button,&event,sizeof(event))){
laser_cursor(button,event);
if(event.eventType==vr::VREvent_MouseMove||event.eventType==vr::VREvent_MouseButtonDown||event.eventType==vr::VREvent_MouseButtonUp)entry_pointer.move(event.trackedDeviceIndex,event.data.mouse.x,event.data.mouse.y);
if(event.eventType==vr::VREvent_FocusLeave)entry_pointer.leave();
if(event.eventType==vr::VREvent_MouseButtonDown||event.eventType==vr::VREvent_MouseButtonUp){
if(menu_button.event(event.eventType==vr::VREvent_MouseButtonDown,event.data.mouse.button==vr::VRMouseButton_Left,anchor_ok,now)){
menu_open=!menu_open;if(menu_open){menu_button.opened(now);if(views.count("menu"))views.at("menu")->host->set_focus(views.at("menu")->host,1);}
if(!entry_actions_ready&&menu_button.event(event.eventType==vr::VREvent_MouseButtonDown,event.data.mouse.button==vr::VRMouseButton_Left,anchor_ok,now)){
toggle_entry(launcher_primary.load());if(menu_open){menu_button.opened(now);if(views.count("menu"))views.at("menu")->host->set_focus(views.at("menu")->host,1);}
std::cout<<"Menu "<<(menu_open?"opened":"closed")<<std::endl;
}
}
}
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){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;for(auto&[other,t]:views)if(!t->dashboard&&other!="menu"&&other!="notifications"&&!keyboard_open)close_view(*t);}}
if(!anchor_ok)entry_pointer.leave();
for(auto&[key,v]:views)v->stick_scrolling=false;
entry_actions(button,anchor_ok);
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"&&!keyboard_open)close_view(*t);}}
while(overlays->PollNextOverlayEvent(v->overlay,&event,sizeof(event)))overlay_input(*v,event);
if(v->dirty&&!v->closing){
glXMakeContextCurrent(xdisplay,glsurface,glsurface,glcontext);
// Upload to the other texture; never redefine storage submitted this frame.
const int slot=1-v->texture_slot;const GLuint id=v->textures[slot];
auto& damage=v->texture_damage[slot];const int pixel_width=v->width*v->render_scale,pixel_height=v->height*v->render_scale;
static auto generate_mipmap=reinterpret_cast<PFNGLGENERATEMIPMAPPROC>(glXGetProcAddressARB(reinterpret_cast<const GLubyte*>("glGenerateMipmap")));
bool mipmapped=v->key=="launcher"&&generate_mipmap;
glBindTexture(GL_TEXTURE_2D,id);
glPixelStorei(GL_UNPACK_ALIGNMENT,4);glPixelStorei(GL_UNPACK_ROW_LENGTH,0);
glPixelStorei(GL_UNPACK_SKIP_PIXELS,0);glPixelStorei(GL_UNPACK_SKIP_ROWS,0);
if(!v->texture_ready[slot]){
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,mipmapped?GL_LINEAR_MIPMAP_LINEAR:GL_LINEAR);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,GL_CLAMP_TO_EDGE);glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_T,GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D,0,GL_RGBA8,v->width*v->render_scale,v->height*v->render_scale,0,GL_BGRA,GL_UNSIGNED_BYTE,nullptr);
v->texture_ready[slot]=true;
damage.include(0,0,pixel_width,pixel_height);
}
if(!damage.empty()){
glPixelStorei(GL_UNPACK_ROW_LENGTH,pixel_width);
glTexSubImage2D(GL_TEXTURE_2D,0,damage.x,damage.y,damage.width,damage.height,GL_BGRA,GL_UNSIGNED_BYTE,v->pixels.data()+(size_t(damage.y)*pixel_width+damage.x)*4);
glPixelStorei(GL_UNPACK_ROW_LENGTH,0);damage.clear();
if(mipmapped)generate_mipmap(GL_TEXTURE_2D);
}
glTexSubImage2D(GL_TEXTURE_2D,0,0,0,v->width*v->render_scale,v->height*v->render_scale,GL_BGRA,GL_UNSIGNED_BYTE,v->pixels.data());
// glFlush queues the upload; glFinish makes the full image ready for sharing.
glFinish();vr::Texture_t texture{reinterpret_cast<void*>(uintptr_t(id)),vr::TextureType_OpenGL,vr::ColorSpace_Auto};
auto e=overlays->SetOverlayTexture(v->overlay,&texture);glFlush();
@@ -269,12 +483,22 @@ int main(int argc,char** argv){
auto error=overlays->SetOverlayIntersectionMask(v->overlay,&mask,1);
if(error){std::cerr<<"Notification input mask failed "<<error<<"\n";visible=false;}
}
show(*v,visible);
v->notification_content=visible;
}
}
}
if(key=="notifications"){
if(v->notification_click.active&&std::chrono::steady_clock::now()-v->notification_click.pressed>std::chrono::milliseconds(800)){
// A release outside the overlay may not arrive. Never leave CEF pressed.
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->notification_content&&!v->placement.valid)anchor_notification(*v);
if(v->placement.valid)v->placement.curve(overlays,v->overlay,v->placement.width,notification_curve_radius);
show(*v,v->notification_content&&v->placement.valid);
}
}
{json snapshot=json::object();for(auto&[key,v]:views){if(key=="menu"||key=="framely.manager"||key.rfind("framely.window.",0)==0)snapshot[key]=capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay));}std::lock_guard<std::mutex> lock(visibility_mutex);visible_views=std::move(snapshot);}
{json snapshot=json::object();for(auto&[key,v]:views){if(key=="menu"||key=="launcher"||key=="framely.manager"||key.rfind("framely.window.",0)==0)snapshot[key]=capture_view_visible(v->closing,overlays->IsOverlayVisible(v->overlay),v->dashboard,overlays->IsDashboardVisible(),overlays->IsActiveDashboardOverlay(v->overlay));}std::lock_guard<std::mutex> lock(visibility_mutex);visible_views=std::move(snapshot);}
if(keyboard_close_pending){
// A non-minimal runtime keyboard may buffer text until Done. Drain overlay
// character events first so the global Done event cannot duplicate input.
@@ -282,7 +506,7 @@ int main(int argc,char** argv){
close_keyboard(false);
}
for(auto i=views.begin();i!=views.end();){auto& v=*i->second;if(v.closed){overlays->ClearOverlayTexture(v.overlay);overlays->DestroyOverlay(v.overlay);if(v.thumbnail)overlays->DestroyOverlay(v.thumbnail);glDeleteTextures(2,v.textures);i=views.erase(i);}else ++i;}
bool hovered=anchor_ok&&overlays->IsHoverTargetOverlay(button);if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);if(icon_active!=menu_open||icon_hover!=hovered){icon_active=menu_open;pixels=dock_icon(hovered,menu_open);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);}icon_hover=hovered;
bool hovered=anchor_ok&&overlays->IsHoverTargetOverlay(button);if(hovered&&!icon_hover)hover_haptic(button,button_haptic_at);const auto badge=notification_badge_count.load();if(icon_active!=(menu_open||launcher_open)||icon_hover!=hovered||icon_badge!=badge){icon_badge=badge;icon_active=menu_open||launcher_open;pixels=dock_icon(hovered,icon_active);overlays->SetOverlayRaw(button,pixels.data(),dock_canvas_width,dock_canvas_height,4);}icon_hover=hovered;
auto sync=overlays->WaitFrameSync(8);
// A timeout already waited: never append another sleep to it. Bound retries
// only for immediate API failures, so a broken sync call cannot busy-spin.
+47
View File
@@ -0,0 +1,47 @@
{
"action_sets": [
{
"name": "/actions/entry",
"usage": "leftright"
}
],
"actions": [
{
"name": "/actions/entry/in/primary",
"type": "boolean"
},
{
"name": "/actions/entry/in/upper",
"type": "boolean"
},
{
"name": "/actions/entry/in/aim",
"type": "pose"
},
{
"name": "/actions/entry/in/close",
"type": "boolean"
},
{
"name": "/actions/entry/in/navigate",
"type": "vector2"
}
],
"default_bindings": [
{
"controller_type": "frame_controller",
"binding_url": "frame-controller.json"
}
],
"localization": [
{
"language_tag": "en_US",
"/actions/entry": "Framely launcher",
"/actions/entry/in/primary": "Primary trigger",
"/actions/entry/in/upper": "Upper trigger",
"/actions/entry/in/aim": "Pointer",
"/actions/entry/in/close": "Close launcher (B)",
"/actions/entry/in/navigate": "Launcher navigation"
}
]
}
+99
View File
@@ -0,0 +1,99 @@
{
"controller_type": "frame_controller",
"name": "Framely entry controls",
"bindings": {
"/actions/entry": {
"poses": [
{
"path": "/user/hand/left/pose/tip",
"output": "/actions/entry/in/aim"
},
{
"path": "/user/hand/right/pose/tip",
"output": "/actions/entry/in/aim"
}
],
"sources": [
{
"path": "/user/hand/left/input/trigger",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/entry/in/primary"
}
},
"parameters": {
"haptic_amplitude": "0",
"click_activate_threshold": "0.5",
"click_deactivate_threshold": "0.45"
}
},
{
"path": "/user/hand/left/input/bumper",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/entry/in/upper"
}
},
"parameters": {
"haptic_amplitude": "0"
}
},
{
"path": "/user/hand/right/input/trigger",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/entry/in/primary"
}
},
"parameters": {
"haptic_amplitude": "0",
"click_activate_threshold": "0.5",
"click_deactivate_threshold": "0.45"
}
},
{
"path": "/user/hand/right/input/bumper",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/entry/in/upper"
}
},
"parameters": {
"haptic_amplitude": "0"
}
},
{
"path": "/user/hand/right/input/b",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/entry/in/close"
}
}
},
{
"path": "/user/hand/left/input/thumbstick",
"mode": "joystick",
"inputs": {
"position": {
"output": "/actions/entry/in/navigate"
}
}
},
{
"path": "/user/hand/right/input/thumbstick",
"mode": "joystick",
"inputs": {
"position": {
"output": "/actions/entry/in/navigate"
}
}
}
]
}
}
}
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include <chrono>
#include <algorithm>
#include <cmath>
#include "openvr.h"
struct EntryTrigger {
bool held=false,ready=false;
std::chrono::steady_clock::time_point last{};
bool update(bool active,bool down,bool hit,std::chrono::steady_clock::time_point now){
if(!active){held=false;ready=false;return false;}
if(!ready){held=down;ready=true;return false;}
bool rising=down&&!held;held=down;
if(!rising||!hit||now-last<std::chrono::milliseconds(180))return false;
last=now;return true;
}
};
// Steam's laser supplies the actual tip hit and originating controller. Keep
// its point while the trigger locks the pointer, until an explicit focus leave.
struct EntryPointer {
unsigned device=~0u;bool inside=false;
void move(unsigned source,float x,float y){device=source;inside=x>=256&&x<=384&&y>=0&&y<=128;}
void leave(){device=~0u;inside=false;}
bool hit(unsigned source)const{return inside&&source!=~0u&&source==device;}
};
inline vr::HmdMatrix34_t entry_tip_pose(const vr::HmdMatrix34_t& tracked,const vr::HmdMatrix34_t& tip){
vr::HmdMatrix34_t out{};
for(int r=0;r<3;r++)for(int c=0;c<4;c++){
for(int k=0;k<3;k++)out.m[r][c]+=tracked.m[r][k]*tip.m[k][c];
if(c==3)out.m[r][c]+=tracked.m[r][3];
}
return out;
}
inline float entry_rotation_angle(const vr::HmdMatrix34_t& from,const vr::HmdMatrix34_t& to){
float trace=0;for(int r=0;r<3;r++)for(int c=0;c<3;c++)trace+=from.m[r][c]*to.m[r][c];
return std::acos(std::clamp((trace-1.f)*.5f,-1.f,1.f));
}
inline bool entry_trigger_pull(const vr::RenderModel_ComponentState_t& rest,const vr::RenderModel_ComponentState_t& full,const vr::RenderModel_ComponentState_t& current,float& pull){
float travel=entry_rotation_angle(rest.mTrackingToComponentRenderModel,full.mTrackingToComponentRenderModel);
if(!std::isfinite(travel)||travel<.001f)return false;
pull=std::clamp(entry_rotation_angle(rest.mTrackingToComponentRenderModel,current.mTrackingToComponentRenderModel)/travel,0.f,1.f);return std::isfinite(pull);
}
struct EntryAnalogTrigger {
bool held=false;
bool update(float pull){held=std::isfinite(pull)&&pull>=(held?.45f:.5f);return held;}
};
// The press that opens a modal surface must not dismiss it as an outside click.
struct LauncherDismissGuard {
std::chrono::steady_clock::time_point opened_at{};bool released=false;
void opened(std::chrono::steady_clock::time_point now){opened_at=now;released=false;}
void observe(bool held){if(!held)released=true;}
bool allows(std::chrono::steady_clock::time_point now,bool held,float event_age=0){
observe(held);
return released&&now-opened_at>=std::chrono::milliseconds(300)&&
event_age>=0&&std::chrono::duration<float>(now-opened_at).count()>=event_age;
}
};
// One axis per gesture, with a dead zone and neutral re-arm. A diagonal never
// flips a category and a page together, even if its dominant axis changes.
struct LauncherStick {
bool ready=false,latched=false;
int update(float x,float y,bool active){
if(!active||!std::isfinite(x)||!std::isfinite(y)){ready=false;latched=false;return 0;}
float strength=std::max(std::abs(x),std::abs(y));
if(strength<.3f){ready=true;latched=false;return 0;}
if(!ready||latched||strength<.65f)return 0;
latched=true;
return std::abs(x)>=std::abs(y)?(x>0?1:-1):(y>0?2:-2);
}
};
inline void launcher_rotation_vector(const vr::HmdMatrix34_t& rest,const vr::HmdMatrix34_t& value,float out[3]){
float m[3][3]{};for(int r=0;r<3;r++)for(int c=0;c<3;c++)for(int k=0;k<3;k++)m[r][c]+=rest.m[k][r]*value.m[k][c];
out[0]=m[2][1]-m[1][2];out[1]=m[0][2]-m[2][0];out[2]=m[1][0]-m[0][1];
}
inline bool launcher_stick_axes(const vr::RenderModel_ComponentState_t& rest,const vr::RenderModel_ComponentState_t& full_x,const vr::RenderModel_ComponentState_t& full_y,const vr::RenderModel_ComponentState_t& current,float& x,float& y){
float a[3],b[3],v[3];launcher_rotation_vector(rest.mTrackingToComponentRenderModel,full_x.mTrackingToComponentRenderModel,a);launcher_rotation_vector(rest.mTrackingToComponentRenderModel,full_y.mTrackingToComponentRenderModel,b);launcher_rotation_vector(rest.mTrackingToComponentRenderModel,current.mTrackingToComponentRenderModel,v);
float aa=0,ab=0,bb=0,av=0,bv=0;for(int i=0;i<3;i++){aa+=a[i]*a[i];ab+=a[i]*b[i];bb+=b[i]*b[i];av+=a[i]*v[i];bv+=b[i]*v[i];}
float det=aa*bb-ab*ab;if(det<.00001f)return false;
x=std::clamp((av*bb-bv*ab)/det,-1.f,1.f);y=std::clamp((bv*aa-av*ab)/det,-1.f,1.f);return std::isfinite(x)&&std::isfinite(y);
}
// Runtime dashboard controls are outside the app surface. Preserve their drag
// gesture and delayed modal-cancel events without swallowing later empty clicks.
struct LauncherDockGuard {
std::chrono::steady_clock::time_point until{};bool dragging=false;
void observe(bool hovered,bool held,std::chrono::steady_clock::time_point now){
if(hovered&&held)dragging=true;
if(hovered||dragging)until=now+std::chrono::milliseconds(250);
if(!held)dragging=false;
}
bool holds(std::chrono::steady_clock::time_point now)const{return dragging||now<until;}
void clear(){until={};dragging=false;}
};
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <chrono>
#include <cmath>
// Keep the initial hit during ordinary laser jitter, including a long press.
// A deliberate move or an abandoned hold releases the lock.
struct LauncherPointer {
using Time=std::chrono::steady_clock::time_point;
bool active=false,cancelled=false;int x=0,y=0;Time pressed{};
void begin(int px,int py,Time now){active=true;cancelled=false;x=px;y=py;pressed=now;}
bool holding(Time now)const{return active&&!cancelled&&now-pressed<=std::chrono::milliseconds(2500);}
bool constrain(int& px,int& py,Time now){
if(!active)return false;
if(!holding(now)||std::hypot(float(px-x),float(py-y))>36.f)cancelled=true;
if(cancelled)return false;
px=x;py=y;return true;
}
void clear(){active=false;cancelled=false;}
};
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
// Rasterize the unread count directly onto the Dock's 128px icon texture.
// The zero state leaves the original icon byte-for-byte unchanged.
inline void notification_badge(std::vector<uint8_t>& rgba, unsigned count) {
if (!count || rgba.size()!=128*128*4) return;
const std::string label=count>99?"99+":std::to_string(count);
const int text_width=int(label.size())*12-3,width=text_width+14,left=124-width,top=4,height=29;
for(int y=top;y<top+height;y++)for(int x=left;x<124;x++){
const int dx=std::max({left+6-x,0,x-117}),dy=std::max({top+6-y,0,y-(top+height-7)});
if(dx*dx+dy*dy>36)continue;
auto i=(y*128+x)*4;rgba[i]=147;rgba[i+1]=197;rgba[i+2]=237;rgba[i+3]=255;
}
// Compact 3x5 numerals retain a readable count at the Frame Dock's display scale.
constexpr uint16_t digits[]={0b111101101101111,0b010110010010111,0b111001111100111,0b111001111001111,0b101101111001001,0b111100111001111,0b111100111101111,0b111001001001001,0b111101111101111,0b111101111001111};
for(size_t n=0;n<label.size();n++){
const uint16_t bits=label[n]=='+'?0b000010111010000:digits[label[n]-'0'];
for(int y=0;y<5;y++)for(int x=0;x<3;x++)if(bits&(1u<<(14-y*3-x)))
for(int sy=0;sy<3;sy++)for(int sx=0;sx<3;sx++){
auto i=((top+7+y*3+sy)*128+left+7+int(n)*12+x*3+sx)*4;
rgba[i]=23;rgba[i+1]=25;rgba[i+2]=28;rgba[i+3]=255;
}
}
}
+47 -2
View File
@@ -2,13 +2,41 @@
#include <cstdint>
#include <cstddef>
#include <vector>
#include <chrono>
#include "geometry_math.h"
struct NotificationPlacement {
vr::HmdMatrix34_t transform{};
float width=.32f,radius=0;
};
// Keep the compact toast at the upper right, but turn its front toward the eye.
// Copying the headset rotation leaves a side-offset overlay facing past the user.
inline bool notification_placement(const vr::HmdMatrix34_t& headset,float radius,NotificationPlacement& out){
vr::HmdMatrix34_t head{};
if(!dock_rigid_frame(headset,head)||!std::isfinite(radius)||radius<=0)return false;
vr::HmdVector3_t normal{},head_up{{head.m[0][1],head.m[1][1],head.m[2][1]}};
for(int r=0;r<3;r++){
out.transform.m[r][3]=head.m[r][3]+head.m[r][0]*.24f+head.m[r][1]*.12f-head.m[r][2]*1.f;
normal.v[r]=head.m[r][3]-out.transform.m[r][3];
}
if(!normalize_dock_vector(normal))return false;
auto right=cross_dock_vector(head_up,normal);
if(!normalize_dock_vector(right))return false;
auto up=cross_dock_vector(normal,right);
for(int r=0;r<3;r++){
out.transform.m[r][0]=right.v[r];out.transform.m[r][1]=up.v[r];out.transform.m[r][2]=normal.v[r];
}
out.radius=radius;return true;
}
struct NotificationBounds {
float x=0,y=0,width=0,height=0;
bool visible() const {return width>0&&height>0;}
};
// The submitted BGRA buffer is flipped in on_paint to OpenVR's bottom-left origin.
// BGRA rows are bottom-up after on_paint. Intersection rectangles use top-left
// coordinates, so the alpha bounds must be flipped back before submitting the mask.
inline NotificationBounds notification_bounds(const std::vector<uint8_t>& pixels,int width,int height,int scale){
if(width<=0||height<=0||scale<=0||pixels.size()!=size_t(width)*height*4)return {};
int left=width,low=height,right=-1,high=-1;
@@ -17,5 +45,22 @@ inline NotificationBounds notification_bounds(const std::vector<uint8_t>& pixels
if(x<left)left=x;if(x>right)right=x;if(y<low)low=y;if(y>high)high=y;
}
if(right<left)return {};
return {float(left)/scale,float(low)/scale,float(right-left+1)/scale,float(high-low+1)/scale};
return {float(left)/scale,float(height-1-high)/scale,float(right-left+1)/scale,float(high-low+1)/scale};
}
// Preserve the intended button during a brief click with normal tracking jitter.
// Deliberate movement and long holds cancel stabilization.
struct NotificationClick {
using Time=std::chrono::steady_clock::time_point;
bool active=false,cancelled=false;int x=0,y=0;Time pressed{};
void begin(int px,int py,Time now){active=true;cancelled=false;x=px;y=py;pressed=now;}
bool hold(int px,int py,Time now){
if(!active)return false;
if(now-pressed>std::chrono::milliseconds(800)||std::hypot(float(px-x),float(py-y))>18.f)cancelled=true;
return !cancelled;
}
bool release(int& px,int& py,Time now){
bool stable=hold(px,py,now);if(stable){px=x;py=y;}active=false;return stable;
}
void clear(){active=false;cancelled=false;}
};
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <algorithm>
#include <chrono>
#include <cmath>
struct PageScrollDelta {int x=0,y=0;};
// Integrate speed in logical UI pixels, independently of render scale/FPS.
struct PageScrollStick {
std::chrono::steady_clock::time_point last{};float remainder=0;int axis=0;
PageScrollDelta update(float x,float y,bool active,std::chrono::steady_clock::time_point now){
float dt=last.time_since_epoch().count()?std::clamp(std::chrono::duration<float>(now-last).count(),0.f,.05f):1.f/60;
last=now;PageScrollDelta out;
if(!active||!std::isfinite(x)||!std::isfinite(y)||std::max(std::abs(x),std::abs(y))<=.18f){remainder=0;axis=0;return out;}
int next=std::abs(y)>=std::abs(x)?2:1;if(next!=axis){remainder=0;axis=next;}
float value=axis==2?y:-x;
remainder+=std::copysign((std::min(std::abs(value),1.f)-.18f)/.82f,value)*1100.f*dt;
int pixels=int(remainder);remainder-=pixels;if(axis==2)out.y=pixels;else out.x=pixels;return out;
}
void clear(){last={};remainder=0;axis=0;}
};
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <vector>
// Each alternating GPU texture retains its own pending damage until uploaded.
// Coordinates match the vertically flipped CPU buffer, not CEF's top-left view.
struct PaintDamage {
int x=0,y=0,width=0,height=0;
bool empty() const{return width<=0||height<=0;}
void clear(){*this={};}
void include(int left,int top,int right,int bottom){
if(right<=left||bottom<=top)return;
if(!empty()){right=std::max(right,x+width);bottom=std::max(bottom,y+height);left=std::min(left,x);top=std::min(top,y);}
x=left;y=top;width=right-left;height=bottom-top;
}
template<class Rect> void add(int w,int h,size_t count,const Rect* rects){
if(!count||!rects){include(0,0,w,h);return;}
for(size_t i=0;i<count;i++){
const auto& r=rects[i];const int left=std::clamp(r.x,0,w),top=std::clamp(r.y,0,h);
const int right=int(std::clamp<long long>(static_cast<long long>(r.x)+r.width,0,w)),bottom=int(std::clamp<long long>(static_cast<long long>(r.y)+r.height,0,h));
if(right>left&&bottom>top)include(left,h-bottom,right,h-top);
}
}
};
// CEF dirty rectangles use physical pixels. Preserve unchanged pixels between
// paints, and flip only the copied rows for OpenVR's bottom-left texture origin.
template<class Rect> inline void merge_paint(std::vector<uint8_t>& pixels,const void* buffer,int width,int height,size_t count,const Rect* dirty,bool flip=false){
const size_t bytes=size_t(width)*height*4;const auto* source=static_cast<const uint8_t*>(buffer);
auto copy=[&](int left,int top,int right,int bottom){for(int y=top;y<bottom;y++)std::copy_n(source+(size_t(y)*width+left)*4,size_t(right-left)*4,pixels.data()+(size_t(flip?height-1-y:y)*width+left)*4);};
if(pixels.size()!=bytes||!count||!dirty){pixels.resize(bytes);copy(0,0,width,height);return;}
for(size_t i=0;i<count;i++){const auto& r=dirty[i];int left=std::clamp(r.x,0,width),top=std::clamp(r.y,0,height),right=int(std::clamp<long long>(static_cast<long long>(r.x)+r.width,0,width)),bottom=int(std::clamp<long long>(static_cast<long long>(r.y)+r.height,0,height));if(right>left&&bottom>top)copy(left,top,right,bottom);}
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <string>
// Other localhost ports are only for embedded pages in plugin windows.
// The main frame remains on the Framely origin.
inline bool allow_plugin_navigation(const std::string& url, const std::string& origin,
bool main_frame, bool plugin_window) {
if (url == "about:blank" || url.rfind(origin + "/", 0) == 0) return true;
if (main_frame || !plugin_window) return false;
const std::string prefix = "http://localhost:";
if (url.rfind(prefix, 0) != 0) return false;
auto end = url.find('/', prefix.size());
if (end == std::string::npos || end == prefix.size()) return false;
unsigned port = 0;
for (auto i = prefix.size(); i < end; ++i) {
if (url[i] < '0' || url[i] > '9') return false;
port = port * 10 + unsigned(url[i] - '0');
if (port > 65535) return false;
}
return port > 0;
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
// Cap CEF painting independently of headset refresh to limit pixel readback.
inline constexpr int browser_frame_rate(float = 0){return 60;}
+27
View File
@@ -12,6 +12,7 @@
"examples/showcase"
],
"dependencies": {
"@tabler/icons-react": "3.48.0",
"react": "19.2.0",
"react-dom": "19.2.0"
},
@@ -713,6 +714,32 @@
"node": ">= 10"
}
},
"node_modules/@tabler/icons": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.48.0.tgz",
"integrity": "sha512-lQk06gVHNVBnJp0UOgtth54mcZBJM7rdYKsAxKOProj1bbSAx+/xiuOWfHlyvgdL3M+Y+I0HINrjHa3ygp8eXQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.48.0.tgz",
"integrity": "sha512-OCeAlpaWHEMV+EsgdVeGQ3YuKfmfnThZhc++CaZ/ujYzi5+/DONDQlX4OGvH1ikYhIfmc0grI4otJgHy7QAvng==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.48.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@types/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
+1
View File
@@ -15,6 +15,7 @@
"plugin:init": "node tools/plugin-dev.mjs init"
},
"dependencies": {
"@tabler/icons-react": "3.48.0",
"react": "19.2.0",
"react-dom": "19.2.0"
},
+37 -2
View File
@@ -15,7 +15,7 @@ steam_home=$(getent passwd "$steam_user" | cut -d: -f6)
[[ -w /etc/systemd/system && -w /etc && -w /var/lib ]] || { echo 'System configuration is not writable. Framely did not change the read-only setting; installation stopped.' >&2; exit 1; }
cd "$base"
sha256sum --quiet -c SHA256SUMS
for binary in bin/framely lib/cef/framely-vr; do
for binary in bin/framely; do
dependencies=$(ldd "$binary" 2>&1)
[[ $dependencies != *'not found'* ]] || { echo "$dependencies" >&2; exit 1; }
done
@@ -74,6 +74,9 @@ mkdir -p "$root/logs"
chmod 755 "$root" "$root/releases" "$root/plugins" "$root/data"
chmod 700 "$root/logs"
old=$(readlink "$root/current" || true)
if ! $repair && [[ -z $old && -f CEF_RUNTIME.json && ! -f lib/cef/libcef.so ]]; then
echo 'First installation requires the complete offline package with CEF.' >&2; exit 1
fi
if $repair; then
[[ $old == "releases/$version" && -d $release && ! -L $release ]] || { echo 'Repair requires the currently installed release.' >&2; exit 1; }
(cd "$release" && sha256sum --quiet -c SHA256SUMS)
@@ -95,9 +98,23 @@ cleanup_install() {
}
trap cleanup_install EXIT
if ! $repair; then
cp -a bin lib share tools VERSION SHA256SUMS ./*.sh "$stage/"
# Preserve every checksummed file, including documentation and runtime metadata.
cp -a "$base/." "$stage/"
chown -R root:root "$stage"
find "$stage" -type d -exec chmod 755 {} +
fi
if [[ -f CEF_RUNTIME.json ]]; then
# Cache exact runtimes outside releases so upgrades and rollback share files.
cef_runtime=$(python3 "$base/tools/cef-runtime.py" prepare "$base" "$store")
if $repair; then cef_release="$release"; else cef_release="$stage"; fi
python3 "$base/tools/cef-runtime.py" attach "$cef_release" "$cef_runtime"
(cd "$cef_release" && sha256sum --quiet -c SHA256SUMS)
dependencies=$(ldd "$cef_release/lib/cef/framely-vr" 2>&1)
else
dependencies=$(ldd "$base/lib/cef/framely-vr" 2>&1)
fi
[[ $dependencies != *'not found'* ]] || { echo "$dependencies" >&2; exit 1; }
if ! $repair; then
mv "$stage" "$release"
release_created=true
fi
@@ -129,6 +146,24 @@ rollback_on_failure() {
}
trap rollback_on_failure ERR
systemctl stop framely-session.service framely.service 2>/dev/null || true
# Clear the uninstall guard retained by older releases only for a clean reinstall.
# Existing installations, repairs and incomplete plugin cleanup retain safe mode.
if ! $repair && [[ -z $old && -f $root/state.json ]]; then
python3 - "$root/state.json" <<'PY_REINSTALL'
import json, os, pathlib, sys
path = pathlib.Path(sys.argv[1])
state = json.loads(path.read_text())
if state.get('safeMode') is True and state.get('plugins') == {}:
state['safeMode'] = False
temporary = path.with_name('state.json.reinstall')
with temporary.open('w') as output:
os.chmod(temporary, 0o600)
json.dump(state, output, ensure_ascii=False)
output.flush()
os.fsync(output.fileno())
os.replace(temporary, path)
PY_REINSTALL
fi
ln -s "releases/$version" "$root/current.new"
mv -Tf "$root/current.new" "$root/current"
cat > /etc/systemd/system/framely.service <<EOF
+1 -1
View File
@@ -29,7 +29,7 @@ rm -f /etc/systemd/system/framely.service /etc/systemd/system/framely-session.se
systemctl daemon-reload
rm -f "$root/current" "$root/previous-release"
rm -f /home/.framely/state/current /home/.framely/state/previous-release /home/.framely/repair.sh
rm -rf /home/.framely/releases /run/framely
rm -rf /home/.framely/releases /home/.framely/cef /run/framely
rm -f "$root/releases" /home/.framely/state/releases
# External plugin effects cannot be reliably erased; retain data for recovery.
echo 'All plugins and Framely were uninstalled. Saved settings and plugin data were retained.'
+6 -4
View File
@@ -1,20 +1,22 @@
export * from './launcher';
import {launchContext} from './launcher';
export * from './visibility';
import {createVisibilityApi, type UiVisibility} from './visibility';
export * from './lifecycle';
import {installScrollbars} from './scrollbars';
import React from 'react';
import {createRoot} from 'react-dom/client';
export interface NotificationAction {id:string;label:string;icon:string}
export interface Notification {id:string;title:string;body:string;image?:string;actions?:NotificationAction[];durationMs?:number}
export interface NotificationAction {id:string;label:string;icon?:string;closeOnClick?:boolean;removeFromInboxOnClick?:boolean}
export interface Notification {id:string;title:string;body:string;image?:string;actions?:NotificationAction[];durationMs?:number;inbox?:boolean}
type Bridge={request:(op:string,p?:unknown)=>Promise<any>;subscribe:(f:(e:unknown)=>void)=>()=>void};
function bridge():Bridge{const b=(window as any).__framelyBridge;if(!b)throw new Error('This page requires the Framely plugin host');return b;}
export const framely={
ui:createVisibilityApi(bridge),
ui:{launchContext,...createVisibilityApi(bridge),close:():Promise<unknown>=>bridge().request('ui.close')},
language:{get:():Promise<{preference:string;language:string}>=>bridge().request('language.get')},
call:<T=unknown>(method:string,params:unknown={}):Promise<T>=>bridge().request('call',{method,params}),
windows:{open:(window:string)=>bridge().request('window.open',{window}),close:(window:string)=>bridge().request('window.close',{window})},
dependencies:():Promise<DependencyStatus[]>=>bridge().request('dependencies'),
notifications:{send:(notification:Notification)=>bridge().request('notification.send',{notification}),remove:(id:string)=>bridge().request('notification.remove',{id})},
notifications:{send:(notification:Notification)=>bridge().request('notification.send',{notification}),remove:(id:string)=>bridge().request('notification.remove',{id}),dismiss:(id:string)=>bridge().request('notification.dismiss',{id})},
onEvent:(callback:(event:unknown)=>void)=>bridge().subscribe(callback),
};
export function registerPlugin(pages:{QuickPage:React.ComponentType;WindowPage?:React.ComponentType;windows?:Record<string,React.ComponentType>}){installScrollbars();const key=location.pathname.split('/').pop();const Page=key==='quick'?pages.QuickPage:pages.windows?.[key??'']??pages.WindowPage??pages.QuickPage;const style=document.createElement('style');style.textContent='*{box-sizing:border-box}body{margin:0;padding:20px;background:#101820;color:#edf2fa;font:20px system-ui}button,input,textarea{font:inherit;color:inherit;border:1px solid #52627a;border-radius:6px;padding:12px;background:#24333e;margin:6px 6px 6px 0;min-height:48px}button{cursor:pointer}button:disabled{opacity:.5}input,textarea{width:100%}p{line-height:1.6}small{color:#acb9cf}';document.head.append(style);createRoot(document.getElementById('root')!).render(<Boundary><Page/></Boundary>);console.log('FRAMELY_PLUGIN_READY');}
+9
View File
@@ -0,0 +1,9 @@
export interface LaunchContext {source:'launcher'|'quickPanel'|'manager';trigger:'shortPress'|'menuAction';actionId?:string}
type Bridge={request:(op:string,p?:unknown)=>Promise<any>;subscribe:(f:(e:any)=>void)=>()=>void};
function bridge():Bridge{const b=(window as any).__framelyBridge;if(!b)throw new Error('This script requires the Framely plugin host');return b;}
export const launchContext={get:():Promise<LaunchContext>=>bridge().request('ui.launchContext'),onChanged:(callback:(context:LaunchContext)=>void)=>bridge().subscribe(e=>{if(e.type==='ui.launch')callback(e.data);})};
/** Register callbacks in a separate bundle named by a frontend launcher action. */
export function registerLauncherActions(actions:Record<string,(context:LaunchContext)=>unknown|Promise<unknown>>){
let busy=false;const off=bridge().subscribe(async e=>{if(e.type!=='launcher.action'||busy)return;busy=true;try{const context=e.data as LaunchContext;const action=actions[context.actionId??''];if(!action)throw new Error('Launcher action handler not registered');await action(context);await bridge().request('launcher.done');}catch(error){await bridge().request('launcher.done',{error:String(error)});}finally{busy=false;}});
void bridge().request('launcher.ready').catch(console.error);window.addEventListener('pagehide',off,{once:true});return off;
}
+37
View File
@@ -0,0 +1,37 @@
// Installed by the host bootstrap as well as the manager; old plugins benefit
// without rebuilding their own SDK. The launcher owns its separate gestures.
export function installScrollGestures(){
if((document as any).__framelyScrollGestures)return;
(document as any).__framelyScrollGestures=true;
type Drag={id:number;x:number;y:number;last:number;nodes:HTMLElement[];active:boolean;styles:string[]};
let drag:Drag|null=null,suppressClick=false,timer:ReturnType<typeof setTimeout>|null=null;
const clear=()=>{if(drag?.active){drag.nodes.forEach((n,i)=>n.style.scrollBehavior=drag!.styles[i]);try{drag.nodes[0].releasePointerCapture(drag.id);}catch{}}drag=null;};
const cancel=()=>{clear();suppressClick=false;if(timer)clearTimeout(timer);timer=null;};
const scroll=(dy:number)=>{if(!drag)return;for(const node of drag.nodes){const before=node.scrollTop;node.scrollTop+=dy;dy-=node.scrollTop-before;if(Math.abs(dy)<.5)break;}};
document.addEventListener('pointerdown',e=>{
cancel();if(e.button!==0||document.documentElement.classList.contains('launcher-view'))return;
const target=e.target instanceof Element?e.target:null;
if(!target||target.closest('input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role=slider],[draggable=true],[data-framely-no-scroll-drag]'))return;
const nodes:HTMLElement[]=[];
for(let n:Element|null=target;n;n=n.parentElement){if(n instanceof HTMLElement&&n.scrollHeight>n.clientHeight+1&&/auto|scroll/.test(getComputedStyle(n).overflowY))nodes.push(n);}
const root=document.scrollingElement;if(root instanceof HTMLElement&&root.scrollHeight>root.clientHeight+1&&!nodes.includes(root))nodes.push(root);
if(nodes.length)drag={id:e.pointerId,x:e.clientX,y:e.clientY,last:e.clientY,nodes,active:false,styles:nodes.map(n=>n.style.scrollBehavior)};
},true);
document.addEventListener('pointermove',e=>{
if(!drag||e.pointerId!==drag.id)return;
if(!e.buttons){clear();return;}
const dy=e.clientY-drag.y,dx=e.clientX-drag.x;
if(!drag.active){if(Math.abs(dy)<20||Math.abs(dy)<Math.abs(dx)*1.25)return;drag.active=true;suppressClick=true;drag.nodes.forEach(n=>n.style.scrollBehavior='auto');try{drag.nodes[0].setPointerCapture(e.pointerId);}catch{}window.getSelection()?.removeAllRanges();}
e.preventDefault();e.stopImmediatePropagation();scroll(drag.last-e.clientY);drag.last=e.clientY;
},{capture:true,passive:false});
document.addEventListener('pointerup',e=>{
if(!drag||e.pointerId!==drag.id)return;
if(drag.active){e.preventDefault();e.stopImmediatePropagation();scroll(drag.last-e.clientY);timer=setTimeout(()=>{suppressClick=false;timer=null;},200);}
clear();
},true);
document.addEventListener('click',e=>{if(suppressClick){e.preventDefault();e.stopImmediatePropagation();suppressClick=false;if(timer)clearTimeout(timer);timer=null;}},true);
document.addEventListener('pointercancel',cancel,true);
document.addEventListener('lostpointercapture',()=>{if(drag?.active)clear();},true);
window.addEventListener('blur',cancel);window.addEventListener('pagehide',cancel);
document.addEventListener('visibilitychange',()=>{if(document.hidden)cancel();});
}
+2
View File
@@ -1,5 +1,7 @@
import {installScrollGestures} from './scroll-gestures';
// Shared by the manager and host bootstrap, including already-installed plugins.
export function installScrollbars(){
installScrollGestures();
if(document.getElementById('framely-scrollbars'))return;
const style=document.createElement('style');style.id='framely-scrollbars';
style.textContent=`
+495
View File
@@ -0,0 +1,495 @@
//! User-session applications: XDG desktop entries and installed Lepton packages.
use anyhow::{ensure, Context, Result};
use serde::Serialize;
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
process::Stdio,
};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct App {
pub id: String,
pub name: String,
pub kind: &'static str,
pub icon: Option<String>,
#[serde(skip)]
target: Target,
}
#[derive(Clone, Debug)]
enum Target {
Desktop(PathBuf),
Lepton {
runner: PathBuf,
context: String,
package: String,
},
}
fn entries(input: &str) -> BTreeMap<String, String> {
let mut active = false;
let mut out = BTreeMap::new();
for line in input.lines() {
let line = line.trim();
if line.starts_with('[') {
active = line == "[Desktop Entry]";
continue;
}
if active && !line.starts_with('#') {
if let Some((k, v)) = line.split_once('=') {
out.insert(k.trim().into(), v.trim().into());
}
}
}
out
}
fn data_home(home: &Path) -> PathBuf {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.unwrap_or_else(|| home.join(".local/share"))
}
fn data_dirs(home: &Path) -> Vec<PathBuf> {
let mut dirs = vec![data_home(home)];
dirs.extend(
std::env::var("XDG_DATA_DIRS")
.unwrap_or_else(|_| "/usr/local/share:/usr/share".into())
.split(':')
.map(PathBuf::from)
.filter(|p| p.is_absolute()),
);
for p in [
home.join(".local/share/flatpak/exports/share"),
PathBuf::from("/var/lib/flatpak/exports/share"),
] {
if !dirs.contains(&p) {
dirs.push(p);
}
}
dirs
}
fn icon_file(path: &Path) -> Option<String> {
let bytes = fs::read(path).ok()?;
if bytes.len() > 1024 * 1024 {
return None;
}
let mime = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
"image/jpeg"
} else if path.extension().is_some_and(|e| e == "svg")
&& std::str::from_utf8(&bytes).ok()?.contains("<svg")
{
"image/svg+xml"
} else {
return None;
};
use base64::Engine;
Some(format!(
"data:{mime};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
))
}
fn icon(home: &Path, name: &str, dirs: &[PathBuf]) -> Option<String> {
let path = Path::new(name);
if path.is_absolute() {
return icon_file(path);
}
if name.contains('/') || name.is_empty() {
return None;
}
for base in std::iter::once(home.join(".icons")).chain(dirs.iter().map(|d| d.join("icons"))) {
for theme in ["hicolor", "breeze", "breeze-dark", "Adwaita"] {
for size in ["256x256", "128x128", "64x64", "48x48", "scalable", "32x32"] {
for ext in ["png", "svg"] {
if let Some(icon) = icon_file(
&base
.join(theme)
.join(size)
.join("apps")
.join(format!("{name}.{ext}")),
) {
return Some(icon);
}
}
}
}
}
dirs.iter().find_map(|d| {
["png", "svg", "xpm"]
.iter()
.find_map(|ext| icon_file(&d.join("pixmaps").join(format!("{name}.{ext}"))))
})
}
fn files(base: &Path, dir: &Path, depth: usize, out: &mut Vec<(String, PathBuf)>) {
if depth > 8 {
return;
}
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let p = entry.path();
if entry.file_type().is_ok_and(|t| t.is_dir()) {
files(base, &p, depth + 1, out);
} else if p.extension().is_some_and(|e| e == "desktop") {
if let Ok(relative) = p.strip_prefix(base) {
out.push((relative.to_string_lossy().replace('/', "-"), p));
}
}
}
}
}
fn desktop_apps(home: &Path, dirs: &[PathBuf], steam: &BTreeSet<u32>) -> Vec<App> {
let mut seen = BTreeSet::new();
let mut apps = Vec::new();
let desktops = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
for dir in dirs {
let base = dir.join("applications");
let mut candidates = Vec::new();
files(&base, &base, 0, &mut candidates);
candidates.sort();
for (id, path) in candidates {
if !seen.insert(id.clone()) {
continue;
}
let Ok(content) = fs::read_to_string(&path) else {
continue;
};
if content.len() > 128 * 1024 {
continue;
}
let v = entries(&content);
let get = |k: &str| v.get(k).map(String::as_str).unwrap_or("");
if get("Type") != "Application"
|| get("Hidden") == "true"
|| get("NoDisplay") == "true"
|| get("Exec").is_empty()
{
continue;
}
let matches = |list: &str| {
list.split(';')
.any(|item| !item.is_empty() && desktops.split(':').any(|d| d == item))
};
if !get("OnlyShowIn").is_empty() && !matches(get("OnlyShowIn"))
|| matches(get("NotShowIn"))
{
continue;
}
if !get("TryExec").is_empty() {
let t = Path::new(get("TryExec"));
let exists = if t.is_absolute() {
t.is_file()
} else {
std::env::var_os("PATH").is_some_and(|paths| {
std::env::split_paths(&paths).any(|d| d.join(t).is_file())
})
};
if !exists {
continue;
}
}
// Steam already owns its game shortcuts and helper runtimes.
if get("Exec").split_whitespace().any(|word| {
word.trim_matches('"')
.strip_prefix("steam://rungameid/")
.and_then(|s| s.parse::<u32>().ok())
.is_some_and(|id| steam.contains(&id))
}) || id == "valve-steamvr.desktop"
|| id == "Lepton Development.desktop"
{
continue;
}
let locale = std::env::var("LC_MESSAGES")
.or_else(|_| std::env::var("LANG"))
.unwrap_or_default();
let locale = locale.split('.').next().unwrap_or("");
let language = locale.split('_').next().unwrap_or("");
let name = v
.get(&format!("Name[{locale}]"))
.or_else(|| v.get(&format!("Name[{language}]")))
.or_else(|| v.get("Name"))
.filter(|n| !n.is_empty());
let Some(name) = name else {
continue;
};
apps.push(App {
id,
name: name.clone(),
kind: "desktop",
icon: icon(home, get("Icon"), dirs),
target: Target::Desktop(path),
});
}
}
apps
}
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
let rest = tag.split_once(&format!("{name}=\""))?.1;
Some(rest.split_once('"')?.0)
}
fn safe_component(s: &str) -> bool {
!s.is_empty()
&& !s.starts_with('-')
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
}
fn lepton_apps(home: &Path) -> Vec<App> {
let runner = [
home.join(".local/share/Steam/steamapps/common/Lepton/lepton"),
home.join(".steam/steam/steamapps/common/Lepton/lepton"),
]
.into_iter()
.find(|p| p.is_file());
let Some(runner) = runner else {
return Vec::new();
};
let mut apps = Vec::new();
let root = data_home(home).join("lepton/contexts");
let Ok(contexts) = fs::read_dir(root) else {
return apps;
};
for context in contexts.flatten() {
let name = context.file_name().to_string_lossy().into_owned();
if !safe_component(&name) || name.starts_with("steamlaunch-") {
continue;
}
let data = context.path().join("baked/data_overlay");
let Ok(xml) = fs::read_to_string(data.join("system/packages.xml")) else {
continue;
};
if xml.len() > 8 * 1024 * 1024 {
continue;
}
for tag in xml.split('<').filter(|t| t.starts_with("package ")) {
let Some(package) = attr(tag, "name").filter(|s| safe_component(s)) else {
continue;
};
let Some(code) = attr(tag, "codePath").and_then(|s| s.strip_prefix("/data/")) else {
continue;
};
// Android system packages are not launcher apps. A removed package's stale
// metadata is ignored; validate that its installed APK is still present.
let path = data.join(code);
if code.split('/').any(|s| s == "..") || !path.join("base.apk").is_file() {
continue;
}
let icon = icon_file(
&data
.join("system_ce/0/shortcut_service/bitmaps")
.join(format!("{package}.png")),
);
apps.push(App {
id: format!("{name}/{package}"),
name: package.rsplit('.').next().unwrap_or(package).into(),
kind: "lepton",
icon,
target: Target::Lepton {
runner: runner.clone(),
context: name.clone(),
package: package.into(),
},
});
}
}
apps
}
pub fn discover(home: &Path) -> Vec<App> {
let ids = crate::steam::discover(home)
.into_iter()
.map(|a| a.id)
.collect();
let mut apps = desktop_apps(home, &data_dirs(home), &ids);
apps.extend(lepton_apps(home));
apps.sort_by(|a, b| a.name.cmp(&b.name));
apps
}
pub fn launch(home: &Path, id: &str) -> Result<()> {
let app = discover(home)
.into_iter()
.find(|a| format!("{}:{}", a.kind, a.id) == id)
.context("Application is no longer installed")?;
launch_target(app.target)
}
fn launch_target(target: Target) -> Result<()> {
match target {
Target::Desktop(path) => {
crate::process::launch_handoff(
crate::process::tool("gio").arg("launch").arg(path),
std::time::Duration::from_secs(10),
)
.map_err(|error| anyhow::anyhow!("Desktop launch failed: {error:#}"))?;
}
Target::Lepton {
runner,
context,
package,
} => {
// Use the same session-owned context and Android launcher intent; never
// reinstall the APK or accept an arbitrary shell command from the UI.
let running = crate::process::command_output_timeout(
crate::process::tool("podman").args([
"inspect",
"--format",
"{{.State.Running}}",
&format!("lepton-{context}"),
]),
std::time::Duration::from_secs(5),
true,
)
.context("Cannot inspect Lepton context")?;
let running = running.status.success()
&& String::from_utf8_lossy(&running.stdout).trim() == "true";
let child = if running {
None
} else {
Some(
std::process::Command::new(&runner)
.args(["start", &context])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("Cannot start Lepton context")?,
)
};
if let Some(mut child) = child {
std::thread::spawn(move || {
let _ = child.wait();
});
}
let start = std::time::Instant::now();
let mut ready = false;
while start.elapsed() < std::time::Duration::from_secs(35) {
let output = crate::process::command_output_timeout(
crate::process::tool("podman").args([
"exec",
&format!("lepton-{context}"),
"monkey",
"-p",
&package,
"-c",
"android.intent.category.LAUNCHER",
"1",
]),
std::time::Duration::from_secs(35)
.saturating_sub(start.elapsed())
.min(std::time::Duration::from_secs(5)),
false,
)
.context("Lepton launch handoff failed")?;
if output.status.success() {
ready = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
ensure!(
ready,
"Lepton application did not become ready; retry after the context starts"
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xdg_priority_hidden_overrides_and_steam_duplicates() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("first");
let b = dir.path().join("second");
for p in [&a, &b] {
fs::create_dir_all(p.join("applications")).unwrap();
}
fs::write(
a.join("applications/hidden.desktop"),
"[Desktop Entry]\nHidden=true",
)
.unwrap();
fs::write(
b.join("applications/hidden.desktop"),
"[Desktop Entry]\nType=Application\nName=Hidden\nExec=test",
)
.unwrap();
fs::write(
a.join("applications/game.desktop"),
"[Desktop Entry]\nType=Application\nName=Game\nExec=steam steam://rungameid/42",
)
.unwrap();
fs::write(a.join("applications/terminal.desktop"),"[Other]\nName=Wrong\n[Desktop Entry]\nType=Application\nName=Terminal\nExec=terminal %U\nTerminal=true").unwrap();
let apps = desktop_apps(dir.path(), &[a, b], &BTreeSet::from([42]));
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].id, "terminal.desktop");
}
#[test]
fn lepton_only_installed_third_party_packages() {
let dir = tempfile::tempdir().unwrap();
let runner = dir
.path()
.join(".local/share/Steam/steamapps/common/Lepton");
fs::create_dir_all(&runner).unwrap();
fs::write(runner.join("lepton"), "").unwrap();
let data = dir
.path()
.join(".local/share/lepton/contexts/test/baked/data_overlay");
fs::create_dir_all(data.join("system")).unwrap();
fs::create_dir_all(data.join("app/example")).unwrap();
fs::write(data.join("app/example/base.apk"), "apk").unwrap();
fs::write(data.join("system/packages.xml"),"<packages><package name=\"com.example.app\" codePath=\"/data/app/example\"/><package name=\"com.android.system\" codePath=\"/system/app/system\"/><package name=\"com.removed.app\" codePath=\"/data/app/missing\"/></packages>").unwrap();
let apps = lepton_apps(dir.path());
assert_eq!(apps.len(), 1);
assert_eq!(apps[0].id, "test/com.example.app");
}
#[test]
fn launch_handoffs_use_literal_arguments_and_reuse_live_lepton_context() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let tools = dir.path().join("tools");
fs::create_dir(&tools).unwrap();
for name in ["gio", "podman"] {
fs::write(tools.join(name),format!("#!/bin/sh\nif [ \"$1\" = inspect ]; then echo true; exit 0; fi\nprintf '%s\\n' \"$@\" > '{}'/{}-args\n",dir.path().display(),name)).unwrap();
fs::set_permissions(tools.join(name), fs::Permissions::from_mode(0o755)).unwrap();
}
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = Some(tools));
let path = dir.path().join("app; literal.desktop");
launch_target(Target::Desktop(path.clone())).unwrap();
assert_eq!(
fs::read_to_string(dir.path().join("gio-args")).unwrap(),
format!("launch\n{}\n", path.display())
);
launch_target(Target::Lepton {
runner: dir.path().join("must-not-start"),
context: "existing".into(),
package: "com.example.app".into(),
})
.unwrap();
assert_eq!(fs::read_to_string(dir.path().join("podman-args")).unwrap(),"exec\nlepton-existing\nmonkey\n-p\ncom.example.app\n-c\nandroid.intent.category.LAUNCHER\n1\n");
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = None);
}
#[test]
fn stalled_lepton_inspect_and_launch_are_bounded() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let podman = dir.path().join("podman");
for inspect in [true, false] {
fs::write(&podman, if inspect { "#!/bin/sh\nexec sleep 30\n" } else { "#!/bin/sh\nif [ \"$1\" = inspect ]; then echo true; exit 0; fi\nexec sleep 30\n" }).unwrap();
fs::set_permissions(&podman, fs::Permissions::from_mode(0o755)).unwrap();
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = Some(dir.path().into()));
let started = std::time::Instant::now();
let result = launch_target(Target::Lepton {
runner: dir.path().join("must-not-start"),
context: "existing".into(),
package: "com.example.app".into(),
});
crate::process::TEST_TOOLS.with(|p| *p.borrow_mut() = None);
assert!(format!("{:#}", result.unwrap_err()).contains("timed out"));
assert!(started.elapsed() < std::time::Duration::from_secs(7));
}
}
#[test]
fn unknown_id_never_launches() {
let dir = tempfile::tempdir().unwrap();
assert!(launch(dir.path(), "desktop:../../arbitrary").is_err());
}
}
+57
View File
@@ -0,0 +1,57 @@
use anyhow::{ensure, Context, Result};
use serde_json::Value;
use std::{path::Path, process::Command};
pub fn collect(state: &Path) -> Result<Value> {
let output = Command::new("python3")
.args([
"-c",
include_str!("../tools/export-diagnostics.py"),
"--state",
])
.arg(state)
.output()
.context("Could not collect diagnostics")?;
ensure!(
output.status.success(),
"Diagnostic export failed: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).context("Invalid diagnostic archive")
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{engine::general_purpose::STANDARD, Engine};
use std::io::{Cursor, Read};
#[test]
fn exports_without_a_running_daemon_and_with_corrupt_state() {
let state = tempfile::tempdir().unwrap();
std::fs::create_dir(state.path().join("logs")).unwrap();
std::fs::write(state.path().join("state.json"), "corrupt").unwrap();
std::fs::write(
state.path().join("logs/demo.log"),
"useful error\npassword=not-public",
)
.unwrap();
let result = collect(state.path()).unwrap();
let bytes = STANDARD.decode(result["data"].as_str().unwrap()).unwrap();
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut log = String::new();
archive
.by_name("logs/demo.log")
.unwrap()
.read_to_string(&mut log)
.unwrap();
assert!(log.contains("useful error"));
assert!(!log.contains("not-public"));
assert!(archive.by_name("report.json").is_ok());
assert!(archive.by_name("renderer/gpu.json").is_ok());
assert_eq!(
std::fs::read_to_string(state.path().join("state.json")).unwrap(),
"corrupt"
);
}
}
+28
View File
@@ -19,6 +19,12 @@ pub fn write(stream: &mut impl Write, v: &Value) -> Result<()> {
stream.flush()?;
Ok(())
}
pub(crate) fn response(result: Result<Value>) -> Value {
match result {
Ok(v) => json!({"result":v}),
Err(e) => json!({"error":format!("{e:#}")}),
}
}
pub fn call(path: &Path, method: &str, params: Value) -> Result<Value> {
call_timeout(path, method, params, Duration::from_secs(90))
}
@@ -38,6 +44,28 @@ pub fn call_timeout(path: &Path, method: &str, params: Value, timeout: Duration)
mod tests {
use super::*;
#[test]
fn service_errors_keep_the_cause_across_ipc() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("service.sock");
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
let worker = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let request = read(&mut stream).unwrap();
assert_eq!(request["method"], "install.batch");
let result = Err(anyhow::anyhow!("Permission denied (os error 13)"))
.context("安装插件 test.plugin 失败")
.context("批量安装失败,已恢复原版本和启用状态");
write(&mut stream, &response(result)).unwrap();
});
let error = call(&socket, "install.batch", json!({})).unwrap_err();
let message = error.to_string();
assert!(message.contains("批量安装失败,已恢复原版本和启用状态"));
assert!(message.contains("test.plugin"));
assert!(message.contains("Permission denied (os error 13)"));
worker.join().unwrap();
}
#[test]
fn package_messages_can_exceed_previous_transport_limit() {
let request = json!({"method":"install","params":{"package":"A".repeat(97 * 1024 * 1024)}});
+387
View File
@@ -0,0 +1,387 @@
//! Offline, cached phonetic indexes. This runs in the session HTTP worker, never CEF.
use anyhow::{Context, Result};
use pinyin::{ToPinyin, ToPinyinMulti};
use serde::Serialize;
use std::{
collections::{BTreeMap, BTreeSet},
fs::File,
path::Path,
};
use unicode_normalization::UnicodeNormalization;
use vibrato::{dictionary::Dictionary, Tokenizer};
#[derive(Clone, Serialize)]
pub struct SearchIndex {
terms: Vec<String>,
}
#[derive(Default)]
pub struct SearchCache {
tokenizer: Option<Tokenizer>,
entries: BTreeMap<String, SearchIndex>,
}
fn normalize(s: &str) -> String {
let decomposed = s
.nfkc()
.collect::<String>()
.nfd()
.filter(|c| {
!unicode_normalization::char::is_combining_mark(*c)
|| ['\u{3099}', '\u{309a}'].contains(c)
})
.collect::<String>();
decomposed
.nfc()
.flat_map(char::to_lowercase)
.filter(|c| c.is_alphanumeric())
.collect()
}
impl SearchCache {
pub fn batch(&mut self, path: &Path, names: &[String]) -> Result<serde_json::Value> {
if self.tokenizer.is_none() {
let reader = zstd::Decoder::new(
File::open(path).context("Japanese search dictionary missing")?,
)?;
self.tokenizer = Some(Tokenizer::new(Dictionary::read(reader)?).ignore_space(true)?);
}
// A bounded cache also avoids keeping every renamed app forever.
if self.entries.len() + names.len() > 8192 {
self.entries.clear();
}
for name in names {
if !self.entries.contains_key(name) {
self.entries
.insert(name.clone(), index(name, self.tokenizer.as_ref().unwrap()));
}
}
let result = serde_json::to_value(
names
.iter()
.map(|name| (name, &self.entries[name]))
.collect::<BTreeMap<_, _>>(),
)?;
let bytes: usize = self
.entries
.iter()
.map(|(name, index)| name.len() + index.terms.iter().map(String::len).sum::<usize>())
.sum();
if bytes > 8 * 1024 * 1024 {
self.entries.clear();
}
Ok(result)
}
}
fn index(name: &str, tokenizer: &Tokenizer) -> SearchIndex {
let name = name.nfkc().collect::<String>();
let mut terms = BTreeSet::new();
terms.insert(normalize(&name));
terms.insert(normalize(
&name
.split(|c: char| !c.is_alphanumeric())
.filter_map(|w| w.chars().next())
.collect::<String>(),
));
let parts: Vec<String> = name
.chars()
.map(|c| {
c.to_pinyin()
.map(|p| p.plain().into())
.unwrap_or_else(|| c.to_string())
})
.collect();
terms.insert(normalize(&parts.concat()));
terms.insert(normalize(
&parts
.iter()
.filter_map(|p| p.chars().next())
.collect::<String>(),
));
// Add individual alternate readings without a combinatorial expansion.
for (i, c) in name.chars().enumerate() {
if terms.len() >= 64 {
break;
}
if let Some(readings) = c.to_pinyin_multi() {
for reading in readings.into_iter().take(8) {
if terms.len() >= 64 {
break;
}
let mut variant = parts.clone();
variant[i] = reading.plain().into();
terms.insert(normalize(&variant.concat()));
terms.insert(normalize(
&variant
.iter()
.filter_map(|p| p.chars().next())
.collect::<String>(),
));
}
}
}
let mut worker = tokenizer.new_worker();
worker.reset_sentence(&name);
worker.tokenize();
let mut roman = String::new();
let mut initials = String::new();
for i in 0..worker.num_tokens() {
let token = worker.token(i);
let reading = token
.feature()
.split(',')
.nth(7)
.filter(|r| *r != "*")
.unwrap_or(token.surface());
let (full, short) = romanize(reading);
roman.push_str(&full);
initials.push_str(&short);
}
terms.insert(normalize(&roman));
terms.insert(normalize(&initials));
let (full, short) = romanize(&name);
terms.insert(normalize(&full));
terms.insert(normalize(&short));
terms.remove("");
SearchIndex {
terms: terms.into_iter().collect(),
}
}
// Hepburn-style syllables, retaining explicit vowels (トウキョウ -> toukyou).
fn romanize(value: &str) -> (String, String) {
let chars: Vec<char> = value
.nfkc()
.map(|c| {
if ('ぁ'..='ゖ').contains(&c) {
char::from_u32(c as u32 + 0x60).unwrap()
} else {
c
}
})
.collect();
let mut full = String::new();
let mut short = String::new();
let mut i = 0;
let mut geminate = false;
let mut in_latin = false;
while i < chars.len() {
let c = chars[i];
i += 1;
if c == 'ッ' {
geminate = true;
continue;
}
if c == 'ー' {
if let Some(v) = full.chars().rev().find(|c| "aeiou".contains(*c)) {
full.push(v);
}
continue;
}
let syllable = match c {
'ア' | 'ァ' => "a",
'イ' | 'ィ' => "i",
'ウ' | 'ゥ' => "u",
'エ' | 'ェ' => "e",
'オ' | 'ォ' => "o",
'カ' => "ka",
'キ' => "ki",
'ク' => "ku",
'ケ' => "ke",
'コ' => "ko",
'サ' => "sa",
'シ' => "shi",
'ス' => "su",
'セ' => "se",
'ソ' => "so",
'タ' => "ta",
'チ' => "chi",
'ツ' => "tsu",
'テ' => "te",
'ト' => "to",
'ナ' => "na",
'ニ' => "ni",
'ヌ' => "nu",
'ネ' => "ne",
'ノ' => "no",
'ハ' => "ha",
'ヒ' => "hi",
'フ' => "fu",
'ヘ' => "he",
'ホ' => "ho",
'マ' => "ma",
'ミ' => "mi",
'ム' => "mu",
'メ' => "me",
'モ' => "mo",
'ヤ' | 'ャ' => "ya",
'ユ' | 'ュ' => "yu",
'ヨ' | 'ョ' => "yo",
'ラ' => "ra",
'リ' => "ri",
'ル' => "ru",
'レ' => "re",
'ロ' => "ro",
'ワ' | 'ヮ' => "wa",
'ヰ' => "wi",
'ヱ' => "we",
'ヲ' => "wo",
'ン' => "n",
'ガ' => "ga",
'ギ' => "gi",
'グ' => "gu",
'ゲ' => "ge",
'ゴ' => "go",
'ザ' => "za",
'ジ' => "ji",
'ズ' => "zu",
'ゼ' => "ze",
'ゾ' => "zo",
'ダ' => "da",
'ヂ' => "ji",
'ヅ' => "zu",
'デ' => "de",
'ド' => "do",
'バ' => "ba",
'ビ' => "bi",
'ブ' => "bu",
'ベ' => "be",
'ボ' => "bo",
'パ' => "pa",
'ピ' => "pi",
'プ' => "pu",
'ペ' => "pe",
'ポ' => "po",
'ヴ' => "vu",
_ => "",
};
if syllable.is_empty() {
full.push(c);
if c.is_alphanumeric() {
if !in_latin {
short.push(c);
}
in_latin = true;
} else {
in_latin = false;
}
geminate = false;
continue;
}
in_latin = false;
let mut syllable = syllable.to_owned();
if i < chars.len() {
let small = chars[i];
if "ャュョ".contains(small) && syllable.ends_with('i') {
let base = match syllable.as_str() {
"shi" => "sh",
"chi" => "ch",
"ji" => "j",
_ => &syllable[..syllable.len() - 1],
};
syllable = format!(
"{}{}{}",
base,
if ["sh", "ch", "j"].contains(&base) {
""
} else {
"y"
},
match small {
'ャ' => "a",
'ュ' => "u",
_ => "o",
}
);
i += 1;
} else if "ァィゥェォ".contains(small)
&& !["a", "i", "u", "e", "o"].contains(&syllable.as_str())
{
let base = if syllable == "fu" {
"f"
} else if syllable == "vu" {
"v"
} else {
&syllable[..syllable.len() - 1]
};
syllable = format!(
"{}{}",
base,
match small {
'ァ' => "a",
'ィ' => "i",
'ゥ' => "u",
'ェ' => "e",
_ => "o",
}
);
i += 1;
}
}
if geminate {
if let Some(first) = syllable.chars().next().filter(|c| !"aeioun".contains(*c)) {
full.push(first);
}
geminate = false;
}
short.push(syllable.chars().next().unwrap());
full.push_str(&syllable);
}
(full, short)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offline_multilingual_index_and_cache() {
let mut cache = SearchCache::default();
let names = vec![
"透视调色",
"さくら",
"東京",
"Half-Life: Alyx",
"Termix",
"ゲーム",
"キャット",
]
.into_iter()
.map(str::to_owned)
.collect::<Vec<_>>();
let started = std::time::Instant::now();
let v = cache
.batch(Path::new("assets/search/ipadic.dic.zst"), &names)
.unwrap();
println!("Launcher index cold: {:?}", started.elapsed());
for (name, needle) in [
("透视调色", "toushi"),
("透视调色", "tiaose"),
("透视调色", "tsts"),
("さくら", "sakura"),
("さくら", "skr"),
("東京", "toukyou"),
("Half-Life: Alyx", "hla"),
("Termix", "termix"),
("ゲーム", "geemu"),
("キャット", "kyatto"),
] {
assert!(
v[name]["terms"]
.as_array()
.unwrap()
.iter()
.any(|t| t.as_str().unwrap().contains(needle)),
"{name}: {needle}, {}",
v[name]
);
}
let cached = std::time::Instant::now();
assert_eq!(v, cache.batch(Path::new("does-not-exist"), &names).unwrap());
println!("Launcher index cached: {:?}", cached.elapsed());
}
#[test]
fn kana_and_normalization() {
assert_eq!(romanize("さくら"), ("sakura".into(), "skr".into()));
assert_eq!(romanize("トウキョウ").0, "toukyou");
assert_eq!(normalize("TOU shí-"), "toushi");
assert_ne!(normalize("ガ"), normalize("カ"));
assert_eq!(romanize("My アプリ").0, "My apuri");
}
}
+27
View File
@@ -1,7 +1,10 @@
mod auth;
mod desktop;
mod diagnostics;
mod http;
mod ipc;
mod jobs;
mod launcher_search;
mod localization;
mod model;
mod package;
@@ -11,6 +14,7 @@ mod recovery;
mod relations;
mod service;
mod session;
mod steam;
mod subscriptions;
#[cfg(test)]
mod tests;
@@ -31,6 +35,13 @@ struct Cli {
}
#[derive(Subcommand)]
enum Cmd {
/// Export logs even when the manager service is unavailable.
ExportLogs {
#[arg(long, default_value = "/var/lib/framely")]
state: PathBuf,
#[arg(long)]
output: PathBuf,
},
/// Disable and uninstall every plugin before removing the manager.
PrepareUninstall {
#[arg(long)]
@@ -129,6 +140,20 @@ enum Cmd {
}
fn run() -> Result<()> {
match Cli::parse().command {
Cmd::ExportLogs { state, output } => {
let archive = diagnostics::collect(&state)?;
let bytes = STANDARD.decode(archive["data"].as_str().context("Missing archive")?)?;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&output)?;
file.write_all(&bytes)?;
println!("{}", output.display());
Ok(())
}
Cmd::PrepareUninstall { approve, socket } => {
anyhow::ensure!(unsafe { libc::geteuid() } == 0, "Uninstall requires root");
let result = ipc::call_timeout(
@@ -286,6 +311,8 @@ fn run() -> Result<()> {
documentation_url: v.manifest.documentation_url,
homepage: v.manifest.homepage,
api_version: v.manifest.api_version,
engines: v.manifest.engines,
compatible: None,
url: v.manifest.download_url.unwrap_or(format!(
"{}/{}",
base_url.trim_end_matches('/'),
+320 -2
View File
@@ -1,4 +1,4 @@
use anyhow::{bail, ensure, Result};
use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Component, Path};
@@ -116,9 +116,122 @@ fn window_width_meters() -> Option<f32> {
Some(3.0)
}
pub const LAUNCHER_VERSION: &str = "0.4.3-preview.2";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Engines {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub framely: Option<String>,
}
impl Engines {
pub fn validate(&self) -> Result<()> {
if let Some(r) = &self.framely {
crate::relations::requirement(r)?;
}
Ok(())
}
pub fn matches(&self, version: &str) -> bool {
self.framely.as_ref().is_none_or(|r| {
crate::relations::requirement(r)
.ok()
.zip(semver::Version::parse(version).ok())
.is_some_and(|(r, v)| r.matches(&v))
})
}
pub fn check(&self) -> Result<()> {
ensure!(
self.matches(env!("CARGO_PKG_VERSION")),
"Plugin requires Framely {}; current version is {}",
self.framely.as_deref().unwrap_or("*"),
env!("CARGO_PKG_VERSION")
);
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
pub enum LaunchTarget {
QuickPage,
Window { window: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
pub enum LauncherActionTarget {
Window {
window: String,
},
Backend {
method: String,
#[serde(default)]
params: serde_json::Value,
},
Frontend {
entry: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LauncherAction {
pub id: String,
pub label: String,
pub target: LauncherActionTarget,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LauncherSettings {
#[serde(default = "yes")]
pub primary_trigger: bool,
#[serde(default = "yes")]
pub menu_auto_close: bool,
#[serde(default = "menu_timeout")]
pub menu_timeout_seconds: u32,
#[serde(default = "launcher_categories")]
pub all_categories: Vec<String>,
}
fn launcher_categories() -> Vec<String> {
["plugin", "steam", "lepton", "desktop"]
.into_iter()
.map(str::to_owned)
.collect()
}
fn menu_timeout() -> u32 {
10
}
impl Default for LauncherSettings {
fn default() -> Self {
Self {
primary_trigger: true,
menu_auto_close: true,
menu_timeout_seconds: 10,
all_categories: launcher_categories(),
}
}
}
impl LauncherSettings {
pub fn validate(&self) -> Result<()> {
let unique: std::collections::BTreeSet<_> = self.all_categories.iter().collect();
ensure!(
unique.len() == self.all_categories.len()
&& unique
.iter()
.all(|c| ["plugin", "steam", "lepton", "desktop"].contains(&c.as_str())),
"Invalid launcher categories"
);
ensure!(
(1..=300).contains(&self.menu_timeout_seconds),
"Menu timeout must be between 1 and 300 seconds"
);
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Ui {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub launch: BTreeMap<String, LaunchTarget>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub launcher_actions: Vec<LauncherAction>,
pub quick_page: Option<String>,
#[serde(default)]
pub windows: BTreeMap<String, Window>,
@@ -138,6 +251,8 @@ pub struct Publish {
pub struct Manifest {
pub schema_version: u32,
pub api_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engines: Option<Engines>,
pub id: String,
pub name: String,
pub version: String,
@@ -197,6 +312,9 @@ impl Manifest {
self.schema_version == 1 && self.api_version == API_VERSION,
"Unsupported manifest/API version"
);
if let Some(e) = &self.engines {
e.validate()?;
}
valid_id(&self.id)?;
self.relations().validate(&self.id, &self.version)?;
for link in self
@@ -318,6 +436,81 @@ impl Manifest {
if let Some(p) = &self.ui.quick_page {
check(p)?;
}
if !self.ui.launch.is_empty() || !self.ui.launcher_actions.is_empty() {
let range = self
.engines
.as_ref()
.and_then(|e| e.framely.as_ref())
.context("Launcher features require engines.framely")?;
let requirement = crate::relations::requirement(range)?;
// Require an explicit floor so older hosts cannot be included accidentally.
let floor = semver::Version::parse(LAUNCHER_VERSION)?;
ensure!(
requirement.comparators.iter().any(|c| {
matches!(
c.op,
semver::Op::Greater
| semver::Op::GreaterEq
| semver::Op::Exact
| semver::Op::Caret
| semver::Op::Tilde
) && c.minor.is_some()
&& c.patch.is_some()
&& semver::Version {
major: c.major,
minor: c.minor.unwrap(),
patch: c.patch.unwrap(),
pre: c.pre.clone(),
build: semver::BuildMetadata::EMPTY,
} >= floor
}),
"Launcher features require an explicit version floor of {} or newer",
LAUNCHER_VERSION
);
}
for (source, target) in &self.ui.launch {
ensure!(
["launcher", "quickPanel", "manager"].contains(&source.as_str()),
"Unknown launch source"
);
match target {
LaunchTarget::QuickPage => {
ensure!(self.ui.quick_page.is_some(), "No quick page declared")
}
LaunchTarget::Window { window } => ensure!(
self.ui.windows.contains_key(window),
"Launch window not declared"
),
}
}
ensure!(
self.ui.launcher_actions.len() <= 16,
"Too many launcher actions"
);
let mut action_ids = std::collections::BTreeSet::new();
for action in &self.ui.launcher_actions {
valid_id(&action.id)?;
ensure!(
action_ids.insert(&action.id)
&& !action.label.is_empty()
&& action.label.len() <= 120,
"Invalid launcher action"
);
match &action.target {
LauncherActionTarget::Window { window } => ensure!(
self.ui.windows.contains_key(window),
"Action window not declared"
),
LauncherActionTarget::Frontend { entry } => check(entry)?,
LauncherActionTarget::Backend { method, .. } => ensure!(
self.backend.is_some()
&& !method.is_empty()
&& method.len() <= 120
&& !method.starts_with("framely."),
"Invalid backend action method"
),
}
}
ensure!(self.ui.windows.len() <= 8, "Too many declared windows");
for (key, w) in &self.ui.windows {
valid_id(key)?;
@@ -583,13 +776,129 @@ pub struct Database {
pub update_source: Option<UpdateSource>,
#[serde(default, skip_serializing_if = "UpdateChannel::is_stable")]
pub update_channel: UpdateChannel,
#[serde(default)]
pub update_check: UpdateCheckSettings,
#[serde(default)]
pub update_notice: Option<UpdateNotice>,
#[serde(default)]
pub notification_inbox: BTreeMap<String, NotificationEntry>,
#[serde(default)]
pub notification_settings: NotificationSettings,
#[serde(default)]
pub launcher: LauncherSettings,
#[serde(default)]
pub steam_favorites: std::collections::BTreeSet<u32>,
#[serde(default)]
pub launcher_order: Vec<String>,
#[serde(default)]
pub launcher_favorites: std::collections::BTreeSet<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NotificationPreference {
#[serde(default = "yes")]
pub popups: bool,
#[serde(default = "yes")]
pub badge: bool,
}
impl Default for NotificationPreference {
fn default() -> Self {
Self {
popups: true,
badge: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NotificationSettings {
#[serde(default = "yes")]
pub popups: bool,
#[serde(default = "yes")]
pub badge: bool,
#[serde(default)]
pub plugins: BTreeMap<String, NotificationPreference>,
}
impl Default for NotificationSettings {
fn default() -> Self {
Self {
popups: true,
badge: true,
plugins: BTreeMap::new(),
}
}
}
impl NotificationSettings {
pub fn popups_for(&self, plugin: &str) -> bool {
self.popups && self.plugins.get(plugin).is_none_or(|p| p.popups)
}
pub fn badge_for(&self, plugin: &str) -> bool {
self.badge && self.plugins.get(plugin).is_none_or(|p| p.badge)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NotificationSettingsPatch {
pub plugin: Option<String>,
pub popups: Option<bool>,
pub badge: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateCheckSettings {
pub enabled: bool,
pub interval_hours: u64,
}
impl Default for UpdateCheckSettings {
fn default() -> Self {
Self {
enabled: true,
interval_hours: 6,
}
}
}
impl UpdateCheckSettings {
pub fn validate(&self) -> Result<()> {
ensure!(
(1..=168).contains(&self.interval_hours),
"自动检查间隔须为 1-168 小时"
);
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateNotice {
pub source: UpdateSource,
pub channel: UpdateChannel,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NotificationEntry {
pub plugin: String,
pub plugin_name: String,
pub created_at: u64,
pub expires_at: Option<u64>,
pub toast: bool,
pub in_inbox: bool,
#[serde(default)]
pub read: bool,
pub notification: Notification,
#[serde(default)]
pub update: Option<UpdateNotice>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Action {
pub id: String,
pub label: String,
#[serde(default)]
pub icon: String,
#[serde(default = "yes")]
pub close_on_click: bool,
#[serde(default = "yes")]
pub remove_from_inbox_on_click: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -603,6 +912,8 @@ pub struct Notification {
pub actions: Vec<Action>,
#[serde(default = "ttl")]
pub duration_ms: u64,
#[serde(default)]
pub inbox: bool,
}
fn ttl() -> u64 {
8000
@@ -615,7 +926,7 @@ impl Notification {
"Notification limit exceeded"
);
ensure!(
(1000..=60000).contains(&self.duration_ms),
self.duration_ms == 0 || (1000..=60000).contains(&self.duration_ms),
"Invalid notification duration"
);
let mut ids = std::collections::BTreeSet::new();
@@ -663,7 +974,11 @@ pub struct CatalogEntry {
pub documentation_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compatible: Option<bool>,
pub api_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engines: Option<Engines>,
pub url: String,
pub sha256: String,
#[serde(default)]
@@ -710,6 +1025,9 @@ impl CatalogEntry {
}
pub fn validate(&self, allow_http: bool) -> Result<()> {
if let Some(e) = &self.engines {
e.validate()?;
}
valid_id(&self.id)?;
self.relations().validate(&self.id, &self.version)?;
for link in self
+27 -9
View File
@@ -66,10 +66,13 @@ impl Resolver<'_> {
fn matching_catalog(&mut self, source: &Source, id: &str, dep: &Dependency) -> Result<Catalog> {
let mut catalog = self.catalog(source)?;
if catalog.plugins.iter().any(|p| p.id == id)
&& !catalog
.plugins
.iter()
.any(|p| p.id == id && dep.matches(&p.version).unwrap_or(false))
&& !catalog.plugins.iter().any(|p| {
p.id == id
&& p.engines
.as_ref()
.is_none_or(|e| e.matches(env!("CARGO_PKG_VERSION")))
&& dep.matches(&p.version).unwrap_or(false)
})
&& !self.histories.contains(&(source.id.clone(), id.to_owned()))
{
let history = crate::session::fetch_plugin_versions_cancel(
@@ -141,10 +144,13 @@ impl Resolver<'_> {
for source in sources.into_iter().filter(|s| s.enabled) {
match self.matching_catalog(&source, id, dep) {
Ok(c) => {
if c.plugins
.iter()
.any(|p| p.id == id && dep.matches(&p.version).unwrap_or(false))
{
if c.plugins.iter().any(|p| {
p.id == id
&& p.engines
.as_ref()
.is_none_or(|e| e.matches(env!("CARGO_PKG_VERSION")))
&& dep.matches(&p.version).unwrap_or(false)
}) {
let preferred =
selected.is_none() && Some(source.id.as_str()) == origin;
candidates.push(source);
@@ -184,7 +190,13 @@ impl Resolver<'_> {
let entry = catalog
.plugins
.iter()
.find(|p| p.id == id && dep.matches(&p.version).unwrap_or(false))
.find(|p| {
p.id == id
&& p.engines
.as_ref()
.is_none_or(|e| e.matches(env!("CARGO_PKG_VERSION")))
&& dep.matches(&p.version).unwrap_or(false)
})
.context("指定源中不存在依赖")?;
ensure!(
dep.matches(&entry.version)?,
@@ -197,6 +209,9 @@ impl Resolver<'_> {
|_, _| self.cancel.check(),
)?;
let manifest = bytes.manifest()?;
if let Some(e) = &manifest.engines {
e.check()?;
}
ensure!(
manifest.id == entry.id && manifest.version == entry.version,
"依赖包 ID 或版本与目录不一致"
@@ -299,6 +314,9 @@ pub fn prepare_staged(
) -> Result<Prepared> {
cancel.check()?;
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), "未知安装来源");
+132 -11
View File
@@ -29,25 +29,107 @@ pub struct ExitInfo {
}
#[cfg(test)]
thread_local! { pub static TEST_TOOLS: std::cell::RefCell<Option<std::path::PathBuf>> = const { std::cell::RefCell::new(None) }; }
fn tool(name: &str) -> Command {
pub(crate) fn tool(name: &str) -> Command {
#[cfg(test)]
if let Some(root) = TEST_TOOLS.with(|p| p.borrow().clone()) {
return Command::new(root.join(name));
}
Command::new(name)
}
/// Wait only for the launcher helper. Applications may inherit its standard
/// streams and keep them open for their entire lifetime, so never use output().
pub(crate) fn launch_handoff(command: &mut Command, timeout: Duration) -> Result<()> {
let output = command_output_timeout(command, timeout, false)?;
ensure!(
output.status.success(),
"Launch failed ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
Ok(())
}
pub(crate) fn command_output_timeout(
command: &mut Command,
timeout: Duration,
capture_stdout: bool,
) -> Result<std::process::Output> {
use std::io::{Seek, SeekFrom};
use std::os::unix::fs::OpenOptionsExt;
let path = std::env::temp_dir().join(format!(
"framely-launch-{}",
hex::encode(rand::random::<[u8; 16]>())
));
let mut errors = fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)?;
// Keep only the open file descriptors; inherited stderr must not become a
// pipe whose EOF depends on a browser or another long-lived application.
fs::remove_file(&path)?;
let mut stdout = tempfile_output()?;
let mut child = command
.stdin(Stdio::null())
.stdout(if capture_stdout {
Stdio::from(stdout.try_clone()?)
} else {
Stdio::null()
})
.stderr(errors.try_clone()?)
.spawn()?;
let started = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if started.elapsed() < timeout => {
std::thread::sleep(Duration::from_millis(20))
}
result => {
let _ = child.kill();
let _ = child.wait();
if let Err(error) = result {
return Err(error.into());
}
anyhow::bail!("Launch handoff timed out; check whether the application opened before retrying");
}
}
};
errors.seek(SeekFrom::Start(0))?;
let mut stderr = Vec::new();
errors.take(64 * 1024).read_to_end(&mut stderr)?;
stdout.seek(SeekFrom::Start(0))?;
let mut output = Vec::new();
if capture_stdout {
stdout.take(64 * 1024).read_to_end(&mut output)?;
}
Ok(std::process::Output {
status,
stdout: output,
stderr,
})
}
fn tempfile_output() -> Result<fs::File> {
use std::os::unix::fs::OpenOptionsExt;
let path = std::env::temp_dir().join(format!(
"framely-output-{}",
hex::encode(rand::random::<[u8; 16]>())
));
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)?;
fs::remove_file(path)?;
Ok(file)
}
pub fn open_web_link(url: &str) -> Result<()> {
validate_web_link(url)?;
let result = tool("xdg-open")
.arg(url)
.stdin(Stdio::null())
.output()
.context("无法启动系统浏览器")?;
ensure!(
result.status.success(),
"无法打开链接:{}",
String::from_utf8_lossy(&result.stderr)
);
launch_handoff(tool("xdg-open").arg(url), Duration::from_secs(10))
.map_err(|error| anyhow::anyhow!("无法打开链接:{error:#}"))?;
Ok(())
}
@@ -463,3 +545,42 @@ pub fn user_home(uid: u32) -> Result<String> {
}
anyhow::bail!("Steam user not found")
}
#[cfg(test)]
mod launch_tests {
use super::*;
#[test]
fn captured_status_does_not_wait_for_inherited_stdout() {
let output = command_output_timeout(
Command::new("/bin/sh").args(["-c", "printf true; sleep 2 &"]),
Duration::from_secs(1),
true,
)
.unwrap();
assert!(output.status.success());
assert_eq!(output.stdout, b"true");
}
#[test]
fn helper_exit_does_not_wait_for_application_streams() {
let mut command = Command::new("/bin/sh");
command.args(["-c", "sleep 2 &"]);
let began = Instant::now();
launch_handoff(&mut command, Duration::from_secs(1)).unwrap();
assert!(began.elapsed() < Duration::from_secs(1));
}
#[test]
fn handoff_failure_keeps_diagnostics_and_stalled_helper_is_bounded() {
let mut failed = Command::new("/bin/sh");
failed.args(["-c", "printf 'cannot open application' >&2; exit 4"]);
assert!(launch_handoff(&mut failed, Duration::from_secs(1))
.unwrap_err()
.to_string()
.contains("cannot open application"));
let mut stalled = Command::new("/bin/sleep");
stalled.arg("3");
assert!(launch_handoff(&mut stalled, Duration::from_millis(40))
.unwrap_err()
.to_string()
.contains("timed out"));
}
}
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more